diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 86e7b26af..58659c10c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -89,6 +89,25 @@ jobs: channel: canary script: bash contrib/instrumentation.sh + - name: Test custom app + uses: reactivecircus/android-emulator-runner@v2 + env: + GRADLE_OPTS: "-Dorg.gradle.internal.http.connectionTimeout=60000 -Dorg.gradle.internal.http.socketTimeout=60000 -Dorg.gradle.internal.network.retry.max.attempts=6 -Dorg.gradle.internal.network.retry.initial.backOff=2000" + with: + api-level: ${{ matrix.api-level }} + target: ${{ matrix.api-level != 33 && 'default' || 'aosp_atd' }} + arch: x86_64 + profile: pixel_2 + emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + ram-size: 4096M + cores: 4 + force-avd-creation: false + sdcard-path-or-size: 2048M + disable-animations: true + heap-size: 512M + channel: canary + script: bash contrib/instrumentation-customapps.sh + - name: Upload screenshot result uses: actions/upload-artifact@v3 diff --git a/contrib/instrumentation-customapps.sh b/contrib/instrumentation-customapps.sh new file mode 100644 index 000000000..665bc125c --- /dev/null +++ b/contrib/instrumentation-customapps.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash + +# +# Kiwix Android +# Copyright (c) 2024 Kiwix +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +# + +# Enable Wi-Fi on the emulator +adb shell svc wifi enable +adb logcat -c +# shellcheck disable=SC2035 +adb logcat *:E -v color & + +PACKAGE_NAME="org.kiwix.kiwixmobile.custom" +TEST_PACKAGE_NAME="${PACKAGE_NAME}.test" +# Function to check if the application is installed +is_app_installed() { + adb shell pm list packages | grep -q "$1" +} + +if is_app_installed "$PACKAGE_NAME"; then + # Delete the application to properly run the test cases. + adb uninstall "${PACKAGE_NAME}" +fi + +if is_app_installed "$TEST_PACKAGE_NAME"; then + # Delete the test application to properly run the test cases. + adb uninstall "${TEST_PACKAGE_NAME}" +fi +retry=0 +while [ $retry -le 3 ]; do + if ./gradlew connectedCustomexampleDebugAndroidTest; then + echo "connectedCustomexampleDebugAndroidTest succeeded" >&2 + break + else + adb kill-server + adb start-server + # Enable Wi-Fi on the emulator + adb shell svc wifi enable + adb logcat -c + # shellcheck disable=SC2035 + adb logcat *:E -v color & + + if is_app_installed "$PACKAGE_NAME"; then + # Delete the application to properly run the test cases. + adb uninstall "${PACKAGE_NAME}" + fi + if is_app_installed "$TEST_PACKAGE_NAME"; then + # Delete the test application to properly run the test cases. + adb uninstall "${TEST_PACKAGE_NAME}" + fi + ./gradlew clean + retry=$(( retry + 1 )) + if [ $retry -eq 3 ]; then + adb exec-out screencap -p >screencap.png + exit 1 + fi + fi +done diff --git a/core/src/main/java/org/kiwix/kiwixmobile/core/main/CoreReaderFragment.kt b/core/src/main/java/org/kiwix/kiwixmobile/core/main/CoreReaderFragment.kt index 9291adde9..c7b409f41 100644 --- a/core/src/main/java/org/kiwix/kiwixmobile/core/main/CoreReaderFragment.kt +++ b/core/src/main/java/org/kiwix/kiwixmobile/core/main/CoreReaderFragment.kt @@ -1551,7 +1551,7 @@ abstract class CoreReaderFragment : unsupportedMimeTypeHandler?.showSaveOrOpenUnsupportedFilesDialog(url, documentType) } - protected fun openZimFile( + fun openZimFile( file: File?, isCustomApp: Boolean = false, assetFileDescriptorList: List = emptyList(), diff --git a/core/src/main/java/org/kiwix/kiwixmobile/core/reader/ZimFileReader.kt b/core/src/main/java/org/kiwix/kiwixmobile/core/reader/ZimFileReader.kt index 9a5aa7f7b..591a4211b 100644 --- a/core/src/main/java/org/kiwix/kiwixmobile/core/reader/ZimFileReader.kt +++ b/core/src/main/java/org/kiwix/kiwixmobile/core/reader/ZimFileReader.kt @@ -124,7 +124,7 @@ class ZimFileReader constructor( ): Array = assetFileDescriptorList.map { FdInput( - it.parcelFileDescriptor.fileDescriptor, + it.parcelFileDescriptor.dup().fileDescriptor, it.startOffset, it.length ) diff --git a/core/src/main/java/org/kiwix/kiwixmobile/core/reader/ZimReaderContainer.kt b/core/src/main/java/org/kiwix/kiwixmobile/core/reader/ZimReaderContainer.kt index 514e5c346..e21341430 100644 --- a/core/src/main/java/org/kiwix/kiwixmobile/core/reader/ZimReaderContainer.kt +++ b/core/src/main/java/org/kiwix/kiwixmobile/core/reader/ZimReaderContainer.kt @@ -51,7 +51,7 @@ class ZimReaderContainer @Inject constructor(private val zimFileReaderFactory: F ) { zimFileReader = runBlocking { if (assetFileDescriptorList.isNotEmpty() && - assetFileDescriptorList[0].parcelFileDescriptor.fileDescriptor.valid() + assetFileDescriptorList[0].parcelFileDescriptor.dup().fileDescriptor.valid() ) zimFileReaderFactory.create(assetFileDescriptorList, filePath) else null diff --git a/custom/src/androidTest/AndroidManifest.xml b/custom/src/androidTest/AndroidManifest.xml new file mode 100644 index 000000000..25df4733f --- /dev/null +++ b/custom/src/androidTest/AndroidManifest.xml @@ -0,0 +1,30 @@ + + + + + + + + diff --git a/custom/src/androidTest/java/org/kiwix/kiwixmobile/custom/search/SearchFragmentTestForCustomApp.kt b/custom/src/androidTest/java/org/kiwix/kiwixmobile/custom/search/SearchFragmentTestForCustomApp.kt new file mode 100644 index 000000000..2296043e1 --- /dev/null +++ b/custom/src/androidTest/java/org/kiwix/kiwixmobile/custom/search/SearchFragmentTestForCustomApp.kt @@ -0,0 +1,333 @@ +/* + * Kiwix Android + * Copyright (c) 2024 Kiwix + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package org.kiwix.kiwixmobile.custom.search + +import android.Manifest +import android.content.Context +import android.content.res.AssetFileDescriptor +import android.os.ParcelFileDescriptor +import androidx.core.content.edit +import androidx.lifecycle.Lifecycle +import androidx.navigation.fragment.NavHostFragment +import androidx.preference.PreferenceManager +import androidx.test.core.app.ActivityScenario +import androidx.test.espresso.Espresso.pressBack +import androidx.test.espresso.accessibility.AccessibilityChecks +import androidx.test.espresso.matcher.ViewMatchers +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.internal.runner.junit4.statement.UiThreadStatement +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.rule.GrantPermissionRule +import androidx.test.uiautomator.UiDevice +import com.google.android.apps.common.testing.accessibility.framework.AccessibilityCheckResultUtils +import com.google.android.apps.common.testing.accessibility.framework.checks.TouchTargetSizeCheck +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.ResponseBody +import org.hamcrest.Matchers +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.kiwix.kiwixmobile.core.search.SearchFragment +import org.kiwix.kiwixmobile.core.search.viewmodel.Action +import org.kiwix.kiwixmobile.core.utils.LanguageUtils +import org.kiwix.kiwixmobile.core.utils.SharedPreferenceUtil +import org.kiwix.kiwixmobile.custom.main.CustomMainActivity +import org.kiwix.kiwixmobile.custom.main.CustomReaderFragment +import org.kiwix.kiwixmobile.custom.testutils.RetryRule +import org.kiwix.kiwixmobile.custom.testutils.TestUtils.closeSystemDialogs +import org.kiwix.kiwixmobile.custom.testutils.TestUtils.isSystemUINotRespondingDialogVisible +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.net.URI + +@RunWith(AndroidJUnit4::class) +class SearchFragmentTestForCustomApp { + private val permissions = arrayOf( + Manifest.permission.READ_EXTERNAL_STORAGE, + Manifest.permission.WRITE_EXTERNAL_STORAGE + ) + + @get:Rule + var permissionRules: GrantPermissionRule = + GrantPermissionRule.grant(*permissions) + + private val context: Context by lazy { + InstrumentationRegistry.getInstrumentation().targetContext.applicationContext + } + + @Rule + @JvmField + var retryRule = RetryRule() + + private lateinit var customMainActivity: CustomMainActivity + private lateinit var uiDevice: UiDevice + private lateinit var downloadingZimFile: File + private lateinit var activityScenario: ActivityScenario + + private val rayCharlesZimFileUrl = + "https://dev.kiwix.org/kiwix-android/test/wikipedia_en_ray_charles_maxi_2023-12.zim" + + @Before + fun waitForIdle() { + uiDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()).apply { + if (isSystemUINotRespondingDialogVisible(this)) { + closeSystemDialogs(context, this) + } + waitForIdle() + } + PreferenceManager.getDefaultSharedPreferences(context).edit { + putBoolean(SharedPreferenceUtil.PREF_SHOW_INTRO, false) + putBoolean(SharedPreferenceUtil.PREF_WIFI_ONLY, false) + putBoolean(SharedPreferenceUtil.PREF_IS_TEST, true) + putBoolean(SharedPreferenceUtil.PREF_PLAY_STORE_RESTRICTION, false) + putString(SharedPreferenceUtil.PREF_LANG, "en") + } + activityScenario = ActivityScenario.launch(CustomMainActivity::class.java).apply { + moveToState(Lifecycle.State.RESUMED) + onActivity { + LanguageUtils.handleLocaleChange( + it, + "en", + SharedPreferenceUtil(context) + ) + } + } + } + + init { + AccessibilityChecks.enable().apply { + setRunChecksFromRootView(true) + setSuppressingResultMatcher( + Matchers.allOf( + AccessibilityCheckResultUtils.matchesCheck(TouchTargetSizeCheck::class.java), + AccessibilityCheckResultUtils.matchesViews( + ViewMatchers.withId(org.kiwix.kiwixmobile.core.R.id.menu_searchintext) + ) + ) + ) + } + } + + @Test + fun searchFragment() { + activityScenario.onActivity { + customMainActivity = it + } + // test with a large ZIM file to properly test the scenario + downloadingZimFile = getDownloadingZimFile() + if (downloadingZimFile.length() == 0L) { + OkHttpClient().newCall(downloadRequest()).execute().use { response -> + if (response.isSuccessful) { + response.body?.let { responseBody -> + writeZimFileData(responseBody, downloadingZimFile) + } + } else { + throw RuntimeException( + "Download Failed. Error: ${response.message}\n" + + " Status Code: ${response.code}" + ) + } + } + } + openZimFileInReaderWithAssetFileDescriptor(downloadingZimFile) + openSearchWithQuery() + val searchTerm = "A Fool" + val searchedItem = "A Fool for You" + search { + // test with fast typing/deleting + searchWithFrequentlyTypedWords(searchTerm) + assertSearchSuccessful(searchedItem) + deleteSearchedQueryFrequently(searchTerm, uiDevice) + + // test with a short delay typing/deleting to + // properly test the cancelling of previously searching task + searchWithFrequentlyTypedWords(searchTerm, 50) + assertSearchSuccessful(searchedItem) + deleteSearchedQueryFrequently(searchTerm, uiDevice, 50) + + // test with a long delay typing/deleting to + // properly execute the search query letter by letter + searchWithFrequentlyTypedWords(searchTerm, 300) + assertSearchSuccessful(searchedItem) + deleteSearchedQueryFrequently(searchTerm, uiDevice, 300) + // to close the keyboard + pressBack() + // go to reader screen + pressBack() + } + + // Added test for checking the crash scenario where the application was crashing when we + // frequently searched for article, and clicked on the searched item. + search { + // test by searching 10 article and clicking on them + searchAndClickOnArticle(searchTerm) + searchAndClickOnArticle("A Song") + searchAndClickOnArticle("The Ra") + searchAndClickOnArticle("The Ge") + searchAndClickOnArticle("Wish") + searchAndClickOnArticle("WIFI") + searchAndClickOnArticle("Woman") + searchAndClickOnArticle("Big Ba") + searchAndClickOnArticle("My Wor") + searchAndClickOnArticle("100") + assertArticleLoaded() + } + } + + @Test + fun testConcurrencyOfSearch() = runBlocking { + val searchTerms = listOf( + "A Song", + "The Ra", + "The Ge", + "Wish", + "WIFI", + "Woman", + "Big Ba", + "My Wor", + "100" + ) + activityScenario.onActivity { + customMainActivity = it + } + // test with a large ZIM file to properly test the scenario + downloadingZimFile = getDownloadingZimFile() + if (downloadingZimFile.length() == 0L) { + OkHttpClient().newCall(downloadRequest()).execute().use { response -> + if (response.isSuccessful) { + response.body?.let { responseBody -> + writeZimFileData(responseBody, downloadingZimFile) + } + } else { + throw RuntimeException( + "Download Failed. Error: ${response.message}\n" + + " Status Code: ${response.code}" + ) + } + } + } + openZimFileInReaderWithAssetFileDescriptor(downloadingZimFile) + openSearchWithQuery(searchTerms[0]) + // wait for searchFragment become visible on screen. + delay(2000) + val navHostFragment: NavHostFragment = + customMainActivity.supportFragmentManager + .findFragmentById( + customMainActivity.activityCustomMainBinding.customNavController.id + ) as NavHostFragment + val searchFragment = navHostFragment.childFragmentManager.fragments[0] as SearchFragment + for (i in 1..100) { + // This will execute the render method 100 times frequently. + val searchTerm = searchTerms[i % searchTerms.size] + searchFragment.searchViewModel.actions.trySend(Action.Filter(searchTerm)).isSuccess + } + for (i in 1..100) { + // this will execute the render method 100 times with 100MS delay. + delay(100) + val searchTerm = searchTerms[i % searchTerms.size] + searchFragment.searchViewModel.actions.trySend(Action.Filter(searchTerm)).isSuccess + } + for (i in 1..100) { + // this will execute the render method 100 times with 200MS delay. + delay(200) + val searchTerm = searchTerms[i % searchTerms.size] + searchFragment.searchViewModel.actions.trySend(Action.Filter(searchTerm)).isSuccess + } + for (i in 1..100) { + // this will execute the render method 100 times with 200MS delay. + delay(300) + val searchTerm = searchTerms[i % searchTerms.size] + searchFragment.searchViewModel.actions.trySend(Action.Filter(searchTerm)).isSuccess + } + } + + private fun openSearchWithQuery(query: String = "") { + UiThreadStatement.runOnUiThread { + customMainActivity.openSearch(searchString = query) + } + } + + private fun openZimFileInReaderWithAssetFileDescriptor(downloadingZimFile: File) { + getAssetFileDescriptorFromFile(downloadingZimFile)?.let(::openZimFileInReader) ?: run { + throw RuntimeException("Unable to get fileDescriptor from file. Original exception") + } + } + + private fun openZimFileInReader(assetFileDescriptor: AssetFileDescriptor) { + UiThreadStatement.runOnUiThread { + val navHostFragment: NavHostFragment = + customMainActivity.supportFragmentManager + .findFragmentById( + customMainActivity.activityCustomMainBinding.customNavController.id + ) as NavHostFragment + val customReaderFragment = + navHostFragment.childFragmentManager.fragments[0] as CustomReaderFragment + customReaderFragment.openZimFile(null, true, listOf(assetFileDescriptor)) + } + } + + private fun getAssetFileDescriptorFromFile(file: File): AssetFileDescriptor? { + val parcelFileDescriptor = getFileDescriptor(file) + if (parcelFileDescriptor != null) { + return AssetFileDescriptor(parcelFileDescriptor, 0, file.length()) + } + return null + } + + private fun getFileDescriptor(file: File?): ParcelFileDescriptor? { + try { + return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY) + } catch (e: IOException) { + e.printStackTrace() + return null + } + } + + private fun writeZimFileData(responseBody: ResponseBody, file: File) { + FileOutputStream(file).use { outputStream -> + responseBody.byteStream().use { inputStream -> + val buffer = ByteArray(4096) + var bytesRead: Int + while (inputStream.read(buffer).also { bytesRead = it } != -1) { + outputStream.write(buffer, 0, bytesRead) + } + outputStream.flush() + } + } + } + + private fun downloadRequest() = + Request.Builder() + .url(URI.create(rayCharlesZimFileUrl).toURL()) + .build() + + private fun getDownloadingZimFile(isDeletePreviousZimFile: Boolean = true): File { + val zimFile = File(context.cacheDir, "ray_charles.zim") + if (isDeletePreviousZimFile) { + if (zimFile.exists()) zimFile.delete() + zimFile.createNewFile() + } + return zimFile + } +} diff --git a/custom/src/androidTest/java/org/kiwix/kiwixmobile/custom/search/SearchRobot.kt b/custom/src/androidTest/java/org/kiwix/kiwixmobile/custom/search/SearchRobot.kt new file mode 100644 index 000000000..041d47c9f --- /dev/null +++ b/custom/src/androidTest/java/org/kiwix/kiwixmobile/custom/search/SearchRobot.kt @@ -0,0 +1,116 @@ +/* + * Kiwix Android + * Copyright (c) 2024 Kiwix + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package org.kiwix.kiwixmobile.custom.search + +import android.view.KeyEvent +import androidx.recyclerview.widget.RecyclerView +import androidx.test.espresso.Espresso +import androidx.test.espresso.action.ViewActions +import androidx.test.espresso.assertion.ViewAssertions +import androidx.test.espresso.contrib.RecyclerViewActions +import androidx.test.espresso.matcher.ViewMatchers +import androidx.test.espresso.web.sugar.Web +import androidx.test.espresso.web.webdriver.DriverAtoms +import androidx.test.espresso.web.webdriver.Locator +import androidx.test.uiautomator.UiDevice +import com.adevinta.android.barista.interaction.BaristaSleepInteractions +import com.adevinta.android.barista.internal.matcher.HelperMatchers +import org.kiwix.kiwixmobile.core.R +import org.kiwix.kiwixmobile.custom.testutils.TestUtils +import org.kiwix.kiwixmobile.custom.testutils.TestUtils.testFlakyView + +fun search(searchRobot: SearchRobot.() -> Unit) = SearchRobot().searchRobot() + +class SearchRobot { + fun searchWithFrequentlyTypedWords(query: String, wait: Long = 0L) { + testFlakyView({ + val searchView = Espresso.onView(ViewMatchers.withId(R.id.search_src_text)) + for (char in query) { + searchView.perform(ViewActions.typeText(char.toString())) + if (wait != 0L) { + BaristaSleepInteractions.sleep(wait) + } + } + }) + } + + fun assertSearchSuccessful(searchResult: String) { + BaristaSleepInteractions.sleep(TestUtils.TEST_PAUSE_MS_FOR_SEARCH_TEST.toLong()) + val recyclerViewId = R.id.search_list + + Espresso.onView(ViewMatchers.withId(recyclerViewId)).check( + ViewAssertions.matches( + HelperMatchers.atPosition( + 0, + ViewMatchers.hasDescendant(ViewMatchers.withText(searchResult)) + ) + ) + ) + } + + fun deleteSearchedQueryFrequently(textToDelete: String, uiDevice: UiDevice, wait: Long = 0L) { + for (i in textToDelete.indices) { + uiDevice.pressKeyCode(KeyEvent.KEYCODE_DEL) + if (wait != 0L) { + BaristaSleepInteractions.sleep(wait) + } + } + + // clear search query if any remains due to any condition not to affect any other test scenario + val searchView = Espresso.onView(ViewMatchers.withId(R.id.search_src_text)) + searchView.perform(ViewActions.clearText()) + } + + private fun openSearchScreen() { + testFlakyView({ + Espresso.onView(ViewMatchers.withId(R.id.menu_search)) + .perform(ViewActions.click()) + }) + } + + fun searchAndClickOnArticle(searchString: String) { + // wait a bit to properly load the ZIM file in the reader + BaristaSleepInteractions.sleep(TestUtils.TEST_PAUSE_MS_FOR_SEARCH_TEST.toLong()) + openSearchScreen() + searchWithFrequentlyTypedWords(searchString) + clickOnSearchItemInSearchList() + } + + fun clickOnSearchItemInSearchList() { + BaristaSleepInteractions.sleep(TestUtils.TEST_PAUSE_MS_FOR_SEARCH_TEST.toLong()) + Espresso.onView(ViewMatchers.withId(R.id.search_list)).perform( + RecyclerViewActions.actionOnItemAtPosition( + 0, + ViewActions.click() + ) + ) + } + + fun assertArticleLoaded() { + testFlakyView({ + Web.onWebView() + .withElement( + DriverAtoms.findElement( + Locator.XPATH, + "//*[contains(text(), 'Big Baby DRAM')]" + ) + ) + }) + } +} diff --git a/custom/src/androidTest/java/org/kiwix/kiwixmobile/custom/testutils/RetryRule.kt b/custom/src/androidTest/java/org/kiwix/kiwixmobile/custom/testutils/RetryRule.kt new file mode 100644 index 000000000..00c020570 --- /dev/null +++ b/custom/src/androidTest/java/org/kiwix/kiwixmobile/custom/testutils/RetryRule.kt @@ -0,0 +1,53 @@ +/* + * Kiwix Android + * Copyright (c) 2022 Kiwix + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package org.kiwix.kiwixmobile.custom.testutils + +import org.junit.rules.TestRule +import org.junit.runner.Description +import org.junit.runners.model.Statement +import java.util.Objects + +class RetryRule : TestRule { + val retryCountForFlakyTest = 3 + + override fun apply(base: Statement, description: Description): Statement = + statement(base, description) + + private fun statement(base: Statement, description: Description): Statement { + return object : Statement() { + @Throws(Throwable::class) override fun evaluate() { + var caughtThrowable: Throwable? = null + for (i in 0 until retryCountForFlakyTest) { + try { + base.evaluate() + return + } catch (t: Throwable) { + caughtThrowable = t + System.err.println(description.displayName + ": run " + (i + 1) + " failed.") + } + } + System.err.println( + description.displayName + ": Giving up after " + + retryCountForFlakyTest + " failures." + ) + throw Objects.requireNonNull(caughtThrowable!!) + } + } + } +} diff --git a/custom/src/androidTest/java/org/kiwix/kiwixmobile/custom/testutils/TestUtils.kt b/custom/src/androidTest/java/org/kiwix/kiwixmobile/custom/testutils/TestUtils.kt new file mode 100644 index 000000000..24e6c175f --- /dev/null +++ b/custom/src/androidTest/java/org/kiwix/kiwixmobile/custom/testutils/TestUtils.kt @@ -0,0 +1,89 @@ +/* + * Kiwix Android + * Copyright (c) 2024 Kiwix + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package org.kiwix.kiwixmobile.custom.testutils + +import android.content.Context +import android.content.Intent +import androidx.test.uiautomator.By +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.UiObject +import androidx.test.uiautomator.UiSelector +import org.kiwix.kiwixmobile.core.utils.files.Log + +object TestUtils { + private const val TAG = "TESTUTILS" + var TEST_PAUSE_MS_FOR_SEARCH_TEST = 1000 + + @JvmStatic + fun isSystemUINotRespondingDialogVisible(uiDevice: UiDevice) = + uiDevice.findObject(By.textContains("System UI isn't responding")) != null || + uiDevice.findObject(By.textContains("Process system isn't responding")) != null || + uiDevice.findObject(By.textContains("Launcher isn't responding")) != null || + uiDevice.findObject(By.textContains("Wait")) != null || + uiDevice.findObject(By.textContains("WAIT")) != null || + uiDevice.findObject(By.textContains("OK")) != null || + uiDevice.findObject(By.textContains("Ok")) != null || + uiDevice.findObject(By.clazz("android.app.Dialog")) != null + + @JvmStatic + fun closeSystemDialogs(context: Context?, uiDevice: UiDevice) { + // Close any system dialogs visible on Android versions below 12 by broadcasting + context?.sendBroadcast(Intent(Intent.ACTION_CLOSE_SYSTEM_DIALOGS)) + // Press the back button as most dialogs can be closed by doing so + uiDevice.pressBack() + try { + // Click on the button of system dialog (Especially applicable to non-closable dialogs) + val waitButton = getSystemDialogButton(uiDevice) + if (waitButton?.exists() == true) { + uiDevice.click(waitButton.bounds.centerX(), waitButton.bounds.centerY()) + } + } catch (ignore: Exception) { + Log.d( + TAG, + "Couldn't click on Wait/OK button, probably no system dialog is " + + "visible with Wait/OK button \n$ignore" + ) + } + } + + private fun getSystemDialogButton(uiDevice: UiDevice): UiObject? { + // All possible button text based on different Android versions. + val possibleButtonTextList = arrayOf("Wait", "WAIT", "OK", "Ok") + return possibleButtonTextList + .asSequence() + .map { uiDevice.findObject(UiSelector().textContains(it)) } + .firstOrNull(UiObject::exists) + } + + @JvmStatic + fun testFlakyView( + action: () -> Unit, + retryCount: Int = 5 + ) { + try { + action() + } catch (ignore: Throwable) { + if (retryCount > 0) { + testFlakyView(action, retryCount - 1) + } else { + throw ignore // No more retries, rethrow the exception + } + } + } +} diff --git a/custom/src/main/java/org/kiwix/kiwixmobile/custom/main/CustomMainActivity.kt b/custom/src/main/java/org/kiwix/kiwixmobile/custom/main/CustomMainActivity.kt index 6b6f8586a..6e2ad0d4d 100644 --- a/custom/src/main/java/org/kiwix/kiwixmobile/custom/main/CustomMainActivity.kt +++ b/custom/src/main/java/org/kiwix/kiwixmobile/custom/main/CustomMainActivity.kt @@ -71,7 +71,7 @@ class CustomMainActivity : CoreMainActivity() { override val topLevelDestinations = setOf(R.id.customReaderFragment) - private lateinit var activityCustomMainBinding: ActivityCustomMainBinding + lateinit var activityCustomMainBinding: ActivityCustomMainBinding override fun onCreate(savedInstanceState: Bundle?) { customActivityComponent.inject(this)