Improved the KiwixSearchView to handle the keyboard onDone button press. Now, when the user presses the Done button on the keyboard, it triggers the search and opens the first matching item, if available.

* Fixed: The `FIND_IN_PAGE` button was appearing when the search was opened from `FromTabView`.
* Fixed: The `FIND_IN_PAGE` button was not displaying properly (only half was visible).
* Improved: The `NavigationIcon` to better support test cases.
* Fixed: `VoiceSearch` was not working when launched from the `SearchWidget`.
* Refactored: `SearchFragmentTest` and `SearchFragmentTestForCustomApp` to align with the Compose UI.
* Removed: Additional unused code from the project.
This commit is contained in:
MohitMaliFtechiz 2025-05-26 22:31:25 +05:30
parent a3b1b95181
commit 31cc84664c
16 changed files with 318 additions and 327 deletions

View File

@ -31,6 +31,7 @@ import org.kiwix.kiwixmobile.BaseRobot
import org.kiwix.kiwixmobile.Findable.ViewId import org.kiwix.kiwixmobile.Findable.ViewId
import org.kiwix.kiwixmobile.R import org.kiwix.kiwixmobile.R
import org.kiwix.kiwixmobile.core.page.SEARCH_ICON_TESTING_TAG import org.kiwix.kiwixmobile.core.page.SEARCH_ICON_TESTING_TAG
import org.kiwix.kiwixmobile.core.search.SEARCH_FIELD_TESTING_TAG
import org.kiwix.kiwixmobile.language.composables.LANGUAGE_ITEM_CHECKBOX_TESTING_TAG import org.kiwix.kiwixmobile.language.composables.LANGUAGE_ITEM_CHECKBOX_TESTING_TAG
import org.kiwix.kiwixmobile.nav.destination.library.online.LANGUAGE_MENU_ICON_TESTING_TAG import org.kiwix.kiwixmobile.nav.destination.library.online.LANGUAGE_MENU_ICON_TESTING_TAG
import org.kiwix.kiwixmobile.testutils.TestUtils import org.kiwix.kiwixmobile.testutils.TestUtils

View File

@ -18,6 +18,7 @@
package org.kiwix.kiwixmobile.search package org.kiwix.kiwixmobile.search
import android.os.Build import android.os.Build
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.core.content.edit import androidx.core.content.edit
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
@ -25,7 +26,6 @@ import androidx.navigation.fragment.NavHostFragment
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import androidx.test.core.app.ActivityScenario import androidx.test.core.app.ActivityScenario
import androidx.test.espresso.accessibility.AccessibilityChecks import androidx.test.espresso.accessibility.AccessibilityChecks
import androidx.test.espresso.matcher.ViewMatchers
import androidx.test.espresso.matcher.ViewMatchers.withContentDescription import androidx.test.espresso.matcher.ViewMatchers.withContentDescription
import androidx.test.internal.runner.junit4.statement.UiThreadStatement import androidx.test.internal.runner.junit4.statement.UiThreadStatement
import androidx.test.platform.app.InstrumentationRegistry import androidx.test.platform.app.InstrumentationRegistry
@ -47,11 +47,11 @@ import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.kiwix.kiwixmobile.BaseActivityTest import org.kiwix.kiwixmobile.BaseActivityTest
import org.kiwix.kiwixmobile.R import org.kiwix.kiwixmobile.R
import org.kiwix.kiwixmobile.core.R.id
import org.kiwix.kiwixmobile.core.search.SearchFragment import org.kiwix.kiwixmobile.core.search.SearchFragment
import org.kiwix.kiwixmobile.core.search.viewmodel.Action import org.kiwix.kiwixmobile.core.search.viewmodel.Action
import org.kiwix.kiwixmobile.core.utils.LanguageUtils.Companion.handleLocaleChange import org.kiwix.kiwixmobile.core.utils.LanguageUtils.Companion.handleLocaleChange
import org.kiwix.kiwixmobile.core.utils.SharedPreferenceUtil import org.kiwix.kiwixmobile.core.utils.SharedPreferenceUtil
import org.kiwix.kiwixmobile.core.utils.TestingUtils.COMPOSE_TEST_RULE_ORDER
import org.kiwix.kiwixmobile.core.utils.TestingUtils.RETRY_RULE_ORDER import org.kiwix.kiwixmobile.core.utils.TestingUtils.RETRY_RULE_ORDER
import org.kiwix.kiwixmobile.main.KiwixMainActivity import org.kiwix.kiwixmobile.main.KiwixMainActivity
import org.kiwix.kiwixmobile.nav.destination.library.local.LocalLibraryFragmentDirections.actionNavigationLibraryToNavigationReader import org.kiwix.kiwixmobile.nav.destination.library.local.LocalLibraryFragmentDirections.actionNavigationLibraryToNavigationReader
@ -73,6 +73,9 @@ class SearchFragmentTest : BaseActivityTest() {
@JvmField @JvmField
val retryRule = RetryRule() val retryRule = RetryRule()
@get:Rule(order = COMPOSE_TEST_RULE_ORDER)
val composeTestRule = createComposeRule()
private lateinit var kiwixMainActivity: KiwixMainActivity private lateinit var kiwixMainActivity: KiwixMainActivity
private lateinit var uiDevice: UiDevice private lateinit var uiDevice: UiDevice
private lateinit var downloadingZimFile: File private lateinit var downloadingZimFile: File
@ -115,10 +118,6 @@ class SearchFragmentTest : BaseActivityTest() {
setRunChecksFromRootView(true) setRunChecksFromRootView(true)
setSuppressingResultMatcher( setSuppressingResultMatcher(
anyOf( anyOf(
allOf(
matchesCheck(TouchTargetSizeCheck::class.java),
matchesViews(ViewMatchers.withId(id.menu_searchintext))
),
allOf( allOf(
matchesCheck(TouchTargetSizeCheck::class.java), matchesCheck(TouchTargetSizeCheck::class.java),
matchesViews( matchesViews(
@ -142,28 +141,32 @@ class SearchFragmentTest : BaseActivityTest() {
search { checkZimFileSearchSuccessful(R.id.readerFragment) } search { checkZimFileSearchSuccessful(R.id.readerFragment) }
openSearchWithQuery("Android", testZimFile) openSearchWithQuery("Android", testZimFile)
search { search {
clickOnSearchItemInSearchList() clickOnSearchItemInSearchList(composeTestRule)
checkZimFileSearchSuccessful(R.id.readerFragment) checkZimFileSearchSuccessful(R.id.readerFragment)
} }
openSearchWithQuery(zimFile = testZimFile) openSearchWithQuery(zimFile = testZimFile)
search { search {
// test with fast typing/deleting // test with fast typing/deleting
searchWithFrequentlyTypedWords(searchUnitTestingQuery) searchWithFrequentlyTypedWords(searchUnitTestingQuery, composeTestRule = composeTestRule)
assertSearchSuccessful(searchUnitTestResult) assertSearchSuccessful(searchUnitTestResult, composeTestRule)
deleteSearchedQueryFrequently(searchUnitTestingQuery, uiDevice) deleteSearchedQueryFrequently(
searchUnitTestingQuery,
uiDevice,
composeTestRule = composeTestRule
)
// test with a short delay typing/deleting to // test with a short delay typing/deleting to
// properly test the cancelling of previously searching task // properly test the cancelling of previously searching task
searchWithFrequentlyTypedWords(searchUnitTestingQuery, 50) searchWithFrequentlyTypedWords(searchUnitTestingQuery, 50, composeTestRule)
assertSearchSuccessful(searchUnitTestResult) assertSearchSuccessful(searchUnitTestResult, composeTestRule)
deleteSearchedQueryFrequently(searchUnitTestingQuery, uiDevice, 50) deleteSearchedQueryFrequently(searchUnitTestingQuery, uiDevice, 50, composeTestRule)
// test with a long delay typing/deleting to // test with a long delay typing/deleting to
// properly execute the search query letter by letter // properly execute the search query letter by letter
searchWithFrequentlyTypedWords(searchUnitTestingQuery, 300) searchWithFrequentlyTypedWords(searchUnitTestingQuery, 300, composeTestRule)
assertSearchSuccessful(searchUnitTestResult) assertSearchSuccessful(searchUnitTestResult, composeTestRule)
deleteSearchedQueryFrequently(searchUnitTestingQuery, uiDevice, 300) deleteSearchedQueryFrequently(searchUnitTestingQuery, uiDevice, 300, composeTestRule)
// to close the keyboard // to close the keyboard
pressBack() pressBack()
// go to reader screen // go to reader screen
@ -190,39 +193,46 @@ class SearchFragmentTest : BaseActivityTest() {
openSearchWithQuery(zimFile = downloadingZimFile) openSearchWithQuery(zimFile = downloadingZimFile)
search { search {
// test with fast typing/deleting // test with fast typing/deleting
searchWithFrequentlyTypedWords(searchQueryForDownloadedZimFile) searchWithFrequentlyTypedWords(
assertSearchSuccessful(searchResultForDownloadedZimFile) searchQueryForDownloadedZimFile,
deleteSearchedQueryFrequently(searchQueryForDownloadedZimFile, uiDevice) composeTestRule = composeTestRule
)
assertSearchSuccessful(searchResultForDownloadedZimFile, composeTestRule)
deleteSearchedQueryFrequently(
searchQueryForDownloadedZimFile,
uiDevice,
composeTestRule = composeTestRule
)
// test with a short delay typing/deleting to // test with a short delay typing/deleting to
// properly test the cancelling of previously searching task // properly test the cancelling of previously searching task
searchWithFrequentlyTypedWords(searchQueryForDownloadedZimFile, 50) searchWithFrequentlyTypedWords(searchQueryForDownloadedZimFile, 50, composeTestRule)
assertSearchSuccessful(searchResultForDownloadedZimFile) assertSearchSuccessful(searchResultForDownloadedZimFile, composeTestRule)
deleteSearchedQueryFrequently(searchQueryForDownloadedZimFile, uiDevice, 50) deleteSearchedQueryFrequently(searchQueryForDownloadedZimFile, uiDevice, 50, composeTestRule)
// test with a long delay typing/deleting to // test with a long delay typing/deleting to
// properly execute the search query letter by letter // properly execute the search query letter by letter
searchWithFrequentlyTypedWords(searchQueryForDownloadedZimFile, 300) searchWithFrequentlyTypedWords(searchQueryForDownloadedZimFile, 300, composeTestRule)
assertSearchSuccessful(searchResultForDownloadedZimFile) assertSearchSuccessful(searchResultForDownloadedZimFile, composeTestRule)
deleteSearchedQueryFrequently(searchQueryForDownloadedZimFile, uiDevice, 300) deleteSearchedQueryFrequently(searchQueryForDownloadedZimFile, uiDevice, 300, composeTestRule)
// open the reader fragment for next text case. // open the reader fragment for next text case.
openKiwixReaderFragmentWithFile(downloadingZimFile) clickOnNavigationIcon(composeTestRule)
} }
// Added test for checking the crash scenario where the application was crashing when we // Added test for checking the crash scenario where the application was crashing when we
// frequently searched for article, and clicked on the searched item. // frequently searched for article, and clicked on the searched item.
search { search {
// test by searching 10 article and clicking on them // test by searching 10 article and clicking on them
searchAndClickOnArticle(searchQueryForDownloadedZimFile) searchAndClickOnArticle(searchQueryForDownloadedZimFile, composeTestRule)
searchAndClickOnArticle("A Song") searchAndClickOnArticle("A Song", composeTestRule)
searchAndClickOnArticle("The Ra") searchAndClickOnArticle("The Ra", composeTestRule)
searchAndClickOnArticle("The Ge") searchAndClickOnArticle("The Ge", composeTestRule)
searchAndClickOnArticle("Wish") searchAndClickOnArticle("Wish", composeTestRule)
searchAndClickOnArticle("WIFI") searchAndClickOnArticle("WIFI", composeTestRule)
searchAndClickOnArticle("Woman") searchAndClickOnArticle("Woman", composeTestRule)
searchAndClickOnArticle("Big Ba") searchAndClickOnArticle("Big Ba", composeTestRule)
searchAndClickOnArticle("My Wor") searchAndClickOnArticle("My Wor", composeTestRule)
searchAndClickOnArticle("100") searchAndClickOnArticle("100", composeTestRule)
assertArticleLoaded() assertArticleLoaded()
} }
removeTemporaryZimFilesToFreeUpDeviceStorage() removeTemporaryZimFilesToFreeUpDeviceStorage()

View File

@ -19,28 +19,33 @@
package org.kiwix.kiwixmobile.search package org.kiwix.kiwixmobile.search
import android.view.KeyEvent import android.view.KeyEvent
import androidx.recyclerview.widget.RecyclerView import androidx.compose.ui.test.assert
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.ComposeContentTestRule
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextClearance
import androidx.compose.ui.test.performTextInput
import androidx.test.espresso.Espresso.onView import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.clearText
import androidx.test.espresso.action.ViewActions.click import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.action.ViewActions.typeText
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.contrib.RecyclerViewActions.actionOnItemAtPosition
import androidx.test.espresso.matcher.ViewMatchers.hasDescendant
import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.espresso.web.sugar.Web.onWebView import androidx.test.espresso.web.sugar.Web.onWebView
import androidx.test.espresso.web.webdriver.DriverAtoms.findElement import androidx.test.espresso.web.webdriver.DriverAtoms.findElement
import androidx.test.espresso.web.webdriver.Locator import androidx.test.espresso.web.webdriver.Locator
import androidx.test.uiautomator.UiDevice import androidx.test.uiautomator.UiDevice
import applyWithViewHierarchyPrinting import applyWithViewHierarchyPrinting
import com.adevinta.android.barista.interaction.BaristaSleepInteractions import com.adevinta.android.barista.interaction.BaristaSleepInteractions
import com.adevinta.android.barista.internal.matcher.HelperMatchers.atPosition
import org.kiwix.kiwixmobile.BaseRobot import org.kiwix.kiwixmobile.BaseRobot
import org.kiwix.kiwixmobile.Findable.ViewId import org.kiwix.kiwixmobile.Findable.ViewId
import org.kiwix.kiwixmobile.core.R import org.kiwix.kiwixmobile.core.R
import org.kiwix.kiwixmobile.core.search.SEARCH_FIELD_TESTING_TAG
import org.kiwix.kiwixmobile.core.search.SEARCH_ITEM_TESTING_TAG
import org.kiwix.kiwixmobile.core.ui.components.NAVIGATION_ICON_TESTING_TAG
import org.kiwix.kiwixmobile.testutils.TestUtils import org.kiwix.kiwixmobile.testutils.TestUtils
import org.kiwix.kiwixmobile.testutils.TestUtils.TEST_PAUSE_MS
import org.kiwix.kiwixmobile.testutils.TestUtils.testFlakyView import org.kiwix.kiwixmobile.testutils.TestUtils.testFlakyView
import org.kiwix.kiwixmobile.testutils.TestUtils.waitUntilTimeout
fun search(func: SearchRobot.() -> Unit) = SearchRobot().applyWithViewHierarchyPrinting(func) fun search(func: SearchRobot.() -> Unit) = SearchRobot().applyWithViewHierarchyPrinting(func)
@ -50,15 +55,11 @@ class SearchRobot : BaseRobot() {
val searchQueryForDownloadedZimFile = "A Fool" val searchQueryForDownloadedZimFile = "A Fool"
val searchResultForDownloadedZimFile = "A Fool for You" val searchResultForDownloadedZimFile = "A Fool for You"
fun clickOnSearchItemInSearchList() { fun clickOnSearchItemInSearchList(composeTestRule: ComposeContentTestRule) {
BaristaSleepInteractions.sleep(TestUtils.TEST_PAUSE_MS_FOR_SEARCH_TEST.toLong()) composeTestRule.apply {
isVisible(ViewId(R.id.search_list)) waitUntilTimeout()
onView(withId(R.id.search_list)).perform( onAllNodesWithTag(SEARCH_ITEM_TESTING_TAG)[0].performClick()
actionOnItemAtPosition<RecyclerView.ViewHolder>( }
0,
click()
)
)
} }
fun checkZimFileSearchSuccessful(readerFragment: Int) { fun checkZimFileSearchSuccessful(readerFragment: Int) {
@ -66,31 +67,47 @@ class SearchRobot : BaseRobot() {
isVisible(ViewId(readerFragment)) isVisible(ViewId(readerFragment))
} }
fun searchWithFrequentlyTypedWords(query: String, wait: Long = 0L) { fun searchWithFrequentlyTypedWords(
query: String,
wait: Long = 0L,
composeTestRule: ComposeContentTestRule
) {
testFlakyView({ testFlakyView({
val searchView = onView(withId(androidx.appcompat.R.id.search_src_text)) composeTestRule.apply {
waitUntilTimeout()
val searchView = onNodeWithTag(SEARCH_FIELD_TESTING_TAG)
searchView.performTextInput("")
for (char in query) { for (char in query) {
searchView.perform(typeText(char.toString())) searchView.performTextInput(char.toString())
if (wait != 0L) { if (wait != 0L) {
BaristaSleepInteractions.sleep(wait) waitUntilTimeout(wait)
}
} }
} }
}) })
} }
fun assertSearchSuccessful(searchResult: String) { fun assertSearchSuccessful(searchResult: String, composeTestRule: ComposeContentTestRule) {
BaristaSleepInteractions.sleep(TestUtils.TEST_PAUSE_MS_FOR_SEARCH_TEST.toLong()) composeTestRule.apply {
val recyclerViewId = R.id.search_list waitUntil(
timeoutMillis = TEST_PAUSE_MS.toLong(),
onView(withId(recyclerViewId)).check( condition = {
matches( onAllNodesWithTag(SEARCH_ITEM_TESTING_TAG)
atPosition(0, hasDescendant(withText(searchResult))) .fetchSemanticsNodes().isNotEmpty()
) }
) )
onAllNodesWithTag(SEARCH_ITEM_TESTING_TAG)[0]
.assert(hasText(searchResult))
}
} }
fun deleteSearchedQueryFrequently(textToDelete: String, uiDevice: UiDevice, wait: Long = 0L) { fun deleteSearchedQueryFrequently(
for (i in textToDelete.indices) { textToDelete: String,
uiDevice: UiDevice,
wait: Long = 0L,
composeTestRule: ComposeContentTestRule
) {
repeat(textToDelete.length) {
uiDevice.pressKeyCode(KeyEvent.KEYCODE_DEL) uiDevice.pressKeyCode(KeyEvent.KEYCODE_DEL)
if (wait != 0L) { if (wait != 0L) {
BaristaSleepInteractions.sleep(wait) BaristaSleepInteractions.sleep(wait)
@ -98,20 +115,22 @@ class SearchRobot : BaseRobot() {
} }
// clear search query if any remains due to any condition not to affect any other test scenario // clear search query if any remains due to any condition not to affect any other test scenario
val searchView = onView(withId(androidx.appcompat.R.id.search_src_text)) composeTestRule.onNodeWithTag(SEARCH_FIELD_TESTING_TAG).performTextClearance()
searchView.perform(clearText()) }
fun clickOnNavigationIcon(composeTestRule: ComposeContentTestRule) {
composeTestRule.onNodeWithTag(NAVIGATION_ICON_TESTING_TAG).performClick()
} }
private fun openSearchScreen() { private fun openSearchScreen() {
testFlakyView({ onView(withId(R.id.menu_search)).perform(click()) }) testFlakyView({ onView(withId(R.id.menu_search)).perform(click()) })
} }
fun searchAndClickOnArticle(searchString: String) { fun searchAndClickOnArticle(searchString: String, composeTestRule: ComposeContentTestRule) {
// wait a bit to properly load the ZIM file in the reader
BaristaSleepInteractions.sleep(TestUtils.TEST_PAUSE_MS_FOR_SEARCH_TEST.toLong())
openSearchScreen() openSearchScreen()
searchWithFrequentlyTypedWords(searchString) searchWithFrequentlyTypedWords(searchString, composeTestRule = composeTestRule)
clickOnSearchItemInSearchList() clickOnSearchItemInSearchList(composeTestRule)
checkZimFileSearchSuccessful(org.kiwix.kiwixmobile.R.id.readerFragment)
} }
fun assertArticleLoaded() { fun assertArticleLoaded() {

View File

@ -20,16 +20,11 @@ package org.kiwix.kiwixmobile.core.search
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.activity.OnBackPressedCallback import androidx.activity.OnBackPressedCallback
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ComposeView
import androidx.core.view.MenuHost
import androidx.core.view.MenuProvider
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
@ -58,6 +53,7 @@ import org.kiwix.kiwixmobile.core.search.viewmodel.Action.Filter
import org.kiwix.kiwixmobile.core.search.viewmodel.Action.OnItemClick import org.kiwix.kiwixmobile.core.search.viewmodel.Action.OnItemClick
import org.kiwix.kiwixmobile.core.search.viewmodel.Action.OnItemLongClick import org.kiwix.kiwixmobile.core.search.viewmodel.Action.OnItemLongClick
import org.kiwix.kiwixmobile.core.search.viewmodel.Action.OnOpenInNewTabClick import org.kiwix.kiwixmobile.core.search.viewmodel.Action.OnOpenInNewTabClick
import org.kiwix.kiwixmobile.core.search.viewmodel.SearchOrigin.FromWebView
import org.kiwix.kiwixmobile.core.search.viewmodel.SearchState import org.kiwix.kiwixmobile.core.search.viewmodel.SearchState
import org.kiwix.kiwixmobile.core.search.viewmodel.SearchViewModel import org.kiwix.kiwixmobile.core.search.viewmodel.SearchViewModel
import org.kiwix.kiwixmobile.core.ui.components.NavigationIcon import org.kiwix.kiwixmobile.core.ui.components.NavigationIcon
@ -79,7 +75,15 @@ class SearchFragment : BaseFragment() {
val searchViewModel by lazy { viewModel<SearchViewModel>(viewModelFactory) } val searchViewModel by lazy { viewModel<SearchViewModel>(viewModelFactory) }
private var isDataLoading = mutableStateOf(false) private var isDataLoading = mutableStateOf(false)
private var renderingJob: Job? = null private var renderingJob: Job? = null
private var isFindInPageMenuItemEnabled = mutableStateOf(false)
/**
* Represents the state of the FIND_IN_PAGE menu item.
*
* A [Pair] containing:
* - [Boolean]: Whether the menu item is enabled (clickable).
* - [Boolean]: Whether the menu item is visible.
*/
private var findInPageMenuItem = mutableStateOf(false to false)
private var composeView: ComposeView? = null private var composeView: ComposeView? = null
private val searchScreenState = mutableStateOf( private val searchScreenState = mutableStateOf(
SearchScreenState( SearchScreenState(
@ -97,7 +101,10 @@ class SearchFragment : BaseFragment() {
navigationIcon = { navigationIcon = {
NavigationIcon(onClick = { requireActivity().onBackPressedDispatcher.onBackPressed() }) NavigationIcon(onClick = { requireActivity().onBackPressedDispatcher.onBackPressed() })
}, },
onLoadMore = { loadMoreSearchResult() } onLoadMore = { loadMoreSearchResult() },
onKeyboardSubmitButtonClick = {
getSearchListItemForQuery(it)?.let(::onItemClick)
}
) )
) )
@ -125,10 +132,10 @@ class SearchFragment : BaseFragment() {
DialogHost(dialogShower as AlertDialogShower) DialogHost(dialogShower as AlertDialogShower)
} }
} }
searchViewModel.setAlertDialogShower(dialogShower as AlertDialogShower)
observeViewModelData() observeViewModelData()
handleSearchArgument() handleSearchArgument()
handleBackPress() handleBackPress()
searchViewModel.setAlertDialogShower(dialogShower as AlertDialogShower)
} }
private fun handleSearchArgument() { private fun handleSearchArgument() {
@ -136,15 +143,18 @@ class SearchFragment : BaseFragment() {
if (searchStringFromArguments != null) { if (searchStringFromArguments != null) {
onSearchValueChanged(searchStringFromArguments) onSearchValueChanged(searchStringFromArguments)
} }
searchViewModel.actions.trySend(Action.CreatedWithArguments(arguments)).isSuccess val argsCopy = Bundle(arguments)
searchViewModel.actions.trySend(Action.CreatedWithArguments(argsCopy)).isSuccess
arguments?.remove(EXTRA_IS_WIDGET_VOICE) arguments?.remove(EXTRA_IS_WIDGET_VOICE)
} }
private fun observeViewModelData() { private fun observeViewModelData() {
viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.CREATED) { Log.e("VOICE_SEARCH", "Starting observeViewModelData")
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
launch { launch {
searchViewModel.effects.collect { effect -> searchViewModel.effects.collect { effect ->
Log.e("VOICE_SEARCH", "Collected effect: ${effect::class.simpleName}")
effect.invokeWith(this@SearchFragment.coreMainActivity) effect.invokeWith(this@SearchFragment.coreMainActivity)
} }
} }
@ -221,41 +231,6 @@ class SearchFragment : BaseFragment() {
findNavController().popBackStack(readerFragmentResId, false) findNavController().popBackStack(readerFragmentResId, false)
} }
private fun setupMenu() {
(requireActivity() as MenuHost).addMenuProvider(
object : MenuProvider {
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
// menuInflater.inflate(R.menu.menu_search, menu)
// searchView?.apply {
// setUpSearchView(requireActivity())
// searchView?.setOnQueryTextListener(
// SimpleTextListener { query, isSubmit ->
// if (query.isNotEmpty()) {
// setIsPageSearchEnabled(true)
// when {
// isSubmit -> {
// // if user press the search/enter button on keyboard,
// // try to open the article if present
// getSearchListItemForQuery(query)?.let(::onItemClick)
// }
//
// else -> searchViewModel.actions.trySend(Filter(query)).isSuccess
// }
// } else {
// setIsPageSearchEnabled(false)
// }
// }
// )
// }
}
override fun onMenuItemSelected(menuItem: MenuItem) = true
},
viewLifecycleOwner,
Lifecycle.State.RESUMED
)
}
private fun getSearchListItemForQuery(query: String): SearchListItem? = private fun getSearchListItemForQuery(query: String): SearchListItem? =
searchScreenState.value.searchList.firstOrNull { searchScreenState.value.searchList.firstOrNull {
it.value.equals(query, ignoreCase = true) it.value.equals(query, ignoreCase = true)
@ -277,7 +252,10 @@ class SearchFragment : BaseFragment() {
searchViewModel.actions.trySend(Filter(searchText)).isSuccess searchViewModel.actions.trySend(Filter(searchText)).isSuccess
} }
private fun actionMenuItems() = listOf( private fun actionMenuItems() = listOfNotNull(
// Check if the `FIND_IN_PAGE` is visible or not.
// If visible then show it in menu.
if (findInPageMenuItem.value.second) {
ActionMenuItem( ActionMenuItem(
contentDescription = R.string.menu_search_in_text, contentDescription = R.string.menu_search_in_text,
onClick = { onClick = {
@ -285,8 +263,12 @@ class SearchFragment : BaseFragment() {
}, },
testingTag = FIND_IN_PAGE_TESTING_TAG, testingTag = FIND_IN_PAGE_TESTING_TAG,
iconButtonText = R.string.menu_search_in_text, iconButtonText = R.string.menu_search_in_text,
isEnabled = isFindInPageMenuItemEnabled.value isEnabled = findInPageMenuItem.value.first
) )
} else {
// If `FIND_IN_PAGE` is not visible return null so that it will not show on the menu item.
null
}
) )
@Suppress("InjectDispatcher") @Suppress("InjectDispatcher")
@ -310,7 +292,7 @@ class SearchFragment : BaseFragment() {
return return
} }
isDataLoading.value = false isDataLoading.value = false
// searchInTextMenuItem?.actionView?.isVisible = state.searchOrigin == FromWebView findInPageMenuItem.value = findInPageMenuItem.value.first to (state.searchOrigin == FromWebView)
setIsPageSearchEnabled(state.searchTerm) setIsPageSearchEnabled(state.searchTerm)
searchScreenState.update { copy(isLoading = true) } searchScreenState.update { copy(isLoading = true) }
renderingJob = renderingJob =
@ -341,7 +323,7 @@ class SearchFragment : BaseFragment() {
} }
private fun setIsPageSearchEnabled(searchText: String) { private fun setIsPageSearchEnabled(searchText: String) {
isFindInPageMenuItemEnabled.value = searchText.isNotBlank() findInPageMenuItem.value = searchText.isNotBlank() to findInPageMenuItem.value.second
} }
private fun onItemClick(it: SearchListItem) { private fun onItemClick(it: SearchListItem) {

View File

@ -69,9 +69,10 @@ import org.kiwix.kiwixmobile.core.utils.ComposeDimens.SEARCH_ITEM_TEXT_SIZE
import org.kiwix.kiwixmobile.core.utils.ComposeDimens.SIXTEEN_DP import org.kiwix.kiwixmobile.core.utils.ComposeDimens.SIXTEEN_DP
import org.kiwix.kiwixmobile.core.utils.ComposeDimens.THREE_DP import org.kiwix.kiwixmobile.core.utils.ComposeDimens.THREE_DP
const val SEARCH_FIELD_TESTING_TAG = "searchField" const val SEARCH_FIELD_TESTING_TAG = "searchFieldTestingTag"
const val NO_SEARCH_RESULT_TESTING_TAG = "noSearchResultTestingTag" const val NO_SEARCH_RESULT_TESTING_TAG = "noSearchResultTestingTag"
const val FIND_IN_PAGE_TESTING_TAG = "findInPageTestingTag" const val FIND_IN_PAGE_TESTING_TAG = "findInPageTestingTag"
const val SEARCH_ITEM_TESTING_TAG = "searchItemTestingTag"
const val LOADING_ITEMS_BEFORE = 3 const val LOADING_ITEMS_BEFORE = 3
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@ -95,7 +96,8 @@ fun SearchScreen(
searchViewTextFiledTestTag = SEARCH_FIELD_TESTING_TAG, searchViewTextFiledTestTag = SEARCH_FIELD_TESTING_TAG,
onValueChange = searchScreenState.onSearchViewValueChange, onValueChange = searchScreenState.onSearchViewValueChange,
onClearClick = searchScreenState.onSearchViewClearClick, onClearClick = searchScreenState.onSearchViewClearClick,
modifier = Modifier modifier = Modifier,
onKeyboardSubmitButtonClick = searchScreenState.onKeyboardSubmitButtonClick
) )
} }
) )
@ -223,7 +225,8 @@ private fun SearchListItem(
.combinedClickable( .combinedClickable(
onClick = { onItemClick(searchListItem) }, onClick = { onItemClick(searchListItem) },
onLongClick = { onItemLongClick?.invoke(searchListItem) } onLongClick = { onItemLongClick?.invoke(searchListItem) }
), )
.semantics { testTag = SEARCH_ITEM_TESTING_TAG },
fontSize = SEARCH_ITEM_TEXT_SIZE, fontSize = SEARCH_ITEM_TEXT_SIZE,
) )

View File

@ -61,6 +61,10 @@ data class SearchScreenState(
* Handles the newTabIcon click. * Handles the newTabIcon click.
*/ */
val onNewTabIconClick: (SearchListItem) -> Unit, val onNewTabIconClick: (SearchListItem) -> Unit,
/**
* Handles the Keyboard submit button click.
*/
val onKeyboardSubmitButtonClick: (String) -> Unit,
/** /**
* Manages the navigationIcon shown in the toolbar. * Manages the navigationIcon shown in the toolbar.
*/ */

View File

@ -84,7 +84,7 @@ class SearchViewModel @Inject constructor(
FromWebView FromWebView
) )
val state: MutableStateFlow<SearchState> = MutableStateFlow(initialState) val state: MutableStateFlow<SearchState> = MutableStateFlow(initialState)
private val _effects = Channel<SideEffect<*>>() private val _effects = Channel<SideEffect<*>>(Channel.UNLIMITED)
val effects = _effects.receiveAsFlow() val effects = _effects.receiveAsFlow()
val actions = Channel<Action>(Channel.UNLIMITED) val actions = Channel<Action>(Channel.UNLIMITED)
private val filter = MutableStateFlow("") private val filter = MutableStateFlow("")

View File

@ -46,7 +46,7 @@ data class StartSpeechInput(private val actions: Channel<Action>) : SideEffect<U
}, },
REQ_CODE_SPEECH_INPUT REQ_CODE_SPEECH_INPUT
) )
} catch (a: ActivityNotFoundException) { } catch (_: ActivityNotFoundException) {
actions.trySend(StartSpeechInputFailed).isSuccess actions.trySend(StartSpeechInputFailed).isSuccess
} }
} }

View File

@ -19,9 +19,6 @@
package org.kiwix.kiwixmobile.core.ui.components package org.kiwix.kiwixmobile.core.ui.components
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@ -37,6 +34,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.material3.TopAppBarScrollBehavior
@ -62,7 +60,6 @@ import org.kiwix.kiwixmobile.core.ui.theme.Black
import org.kiwix.kiwixmobile.core.ui.theme.KiwixTheme import org.kiwix.kiwixmobile.core.ui.theme.KiwixTheme
import org.kiwix.kiwixmobile.core.ui.theme.MineShaftGray350 import org.kiwix.kiwixmobile.core.ui.theme.MineShaftGray350
import org.kiwix.kiwixmobile.core.ui.theme.White import org.kiwix.kiwixmobile.core.ui.theme.White
import org.kiwix.kiwixmobile.core.utils.ComposeDimens.ACTION_MENU_TEXTVIEW_BUTTON_PADDING
import org.kiwix.kiwixmobile.core.utils.ComposeDimens.SIXTEEN_DP import org.kiwix.kiwixmobile.core.utils.ComposeDimens.SIXTEEN_DP
const val TOOLBAR_TITLE_TESTING_TAG = "toolbarTitle" const val TOOLBAR_TITLE_TESTING_TAG = "toolbarTitle"
@ -139,30 +136,32 @@ private fun AppBarTitle(
private fun ActionMenu(actionMenuItems: List<ActionMenuItem>) { private fun ActionMenu(actionMenuItems: List<ActionMenuItem>) {
Row { Row {
actionMenuItems.forEach { menuItem -> actionMenuItems.forEach { menuItem ->
val modifier = menuItem.modifier.testTag(menuItem.testingTag)
// If icon is not null show the icon.
menuItem.icon?.let {
IconButton( IconButton(
enabled = menuItem.isEnabled, enabled = menuItem.isEnabled,
onClick = menuItem.onClick, onClick = menuItem.onClick,
modifier = menuItem.modifier.testTag(menuItem.testingTag) modifier = modifier
) { ) {
// If icon is not null show the icon.
menuItem.icon?.let {
Icon( Icon(
painter = it.toPainter(), painter = it.toPainter(),
contentDescription = stringResource(menuItem.contentDescription), contentDescription = stringResource(menuItem.contentDescription),
tint = if (menuItem.isEnabled) menuItem.iconTint else Color.Gray tint = if (menuItem.isEnabled) menuItem.iconTint else Color.Gray
) )
}
} ?: run { } ?: run {
// Else show the textView button in menuItem. // Else show the textView button in menuItem.
TextButton(
enabled = menuItem.isEnabled,
onClick = menuItem.onClick,
modifier = modifier
) {
Text( Text(
text = stringResource(id = menuItem.iconButtonText).uppercase(), text = stringResource(id = menuItem.iconButtonText).uppercase(),
color = Color.White, color = if (menuItem.isEnabled) Color.White else Color.Gray,
modifier = menuItem.modifier maxLines = 1,
.clickable( overflow = TextOverflow.Ellipsis,
indication = LocalIndication.current,
interactionSource = remember { MutableInteractionSource() },
onClick = menuItem.onClick
)
.padding(ACTION_MENU_TEXTVIEW_BUTTON_PADDING),
) )
} }
} }

View File

@ -18,6 +18,8 @@
package org.kiwix.kiwixmobile.core.ui.components package org.kiwix.kiwixmobile.core.ui.components
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.Text import androidx.compose.material3.Text
@ -30,10 +32,12 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.ImeAction
import org.kiwix.kiwixmobile.core.R import org.kiwix.kiwixmobile.core.R
import org.kiwix.kiwixmobile.core.utils.ComposeDimens import org.kiwix.kiwixmobile.core.utils.ComposeDimens
@ -45,8 +49,10 @@ fun KiwixSearchView(
searchViewTextFiledTestTag: String = "", searchViewTextFiledTestTag: String = "",
clearButtonTestTag: String = "", clearButtonTestTag: String = "",
onValueChange: (String) -> Unit, onValueChange: (String) -> Unit,
onClearClick: () -> Unit onClearClick: () -> Unit,
onKeyboardSubmitButtonClick: (String) -> Unit = {}
) { ) {
val keyboardController = LocalSoftwareKeyboardController.current
val colors = TextFieldDefaults.colors( val colors = TextFieldDefaults.colors(
focusedIndicatorColor = Color.Transparent, focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent, unfocusedIndicatorColor = Color.Transparent,
@ -89,6 +95,15 @@ fun KiwixSearchView(
) )
} }
} }
},
keyboardOptions = KeyboardOptions.Default.copy(
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = {
keyboardController?.hide()
onKeyboardSubmitButtonClick.invoke(value)
} }
) )
)
} }

View File

@ -24,13 +24,18 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTag
import org.kiwix.kiwixmobile.core.R import org.kiwix.kiwixmobile.core.R
import org.kiwix.kiwixmobile.core.ui.models.IconItem import org.kiwix.kiwixmobile.core.ui.models.IconItem
import org.kiwix.kiwixmobile.core.ui.models.toPainter import org.kiwix.kiwixmobile.core.ui.models.toPainter
import org.kiwix.kiwixmobile.core.ui.theme.White import org.kiwix.kiwixmobile.core.ui.theme.White
const val NAVIGATION_ICON_TESTING_TAG = "navigationIconTestingTag"
/** /**
* A composable function that renders a navigation icon, which can be either a vector * A composable function that renders a navigation icon, which can be either a vector
* or drawable image. * or drawable image.
@ -47,9 +52,10 @@ fun NavigationIcon(
iconItem: IconItem = IconItem.Vector(Icons.AutoMirrored.Filled.ArrowBack), iconItem: IconItem = IconItem.Vector(Icons.AutoMirrored.Filled.ArrowBack),
onClick: () -> Unit, onClick: () -> Unit,
@StringRes contentDescription: Int = R.string.toolbar_back_button_content_description, @StringRes contentDescription: Int = R.string.toolbar_back_button_content_description,
iconTint: Color = White iconTint: Color = White,
testingTag: String = NAVIGATION_ICON_TESTING_TAG
) { ) {
IconButton(onClick = onClick) { IconButton(onClick = onClick, modifier = Modifier.semantics { testTag = testingTag }) {
Icon( Icon(
painter = iconItem.toPainter(), painter = iconItem.toPainter(),
contentDescription = stringResource(contentDescription), contentDescription = stringResource(contentDescription),

View File

@ -1,60 +0,0 @@
/*
* Kiwix Android
* Copyright (c) 2021 Kiwix <android.kiwix.org>
* 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 <http://www.gnu.org/licenses/>.
*
*/
package org.kiwix.kiwixmobile.core.utils
import androidx.recyclerview.widget.RecyclerView
class SimpleRecyclerViewScrollListener(
private val onLayoutScrollListener: (RecyclerView, Int) -> Unit
) :
RecyclerView.OnScrollListener() {
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
super.onScrollStateChanged(recyclerView, newState)
onLayoutScrollListener(
recyclerView,
newState
)
}
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
val currentScrollPosition = recyclerView.computeVerticalScrollOffset()
if (currentScrollPosition > previousScrollPosition) {
onLayoutScrollListener(
recyclerView,
SCROLL_DOWN
)
} else if (currentScrollPosition < previousScrollPosition) {
onLayoutScrollListener(
recyclerView,
SCROLL_UP
)
}
previousScrollPosition = currentScrollPosition
}
private var previousScrollPosition = 0
companion object {
const val SCROLL_DOWN = 2000
const val SCROLL_UP = 2001
}
}

View File

@ -1,34 +0,0 @@
/*
* Kiwix Android
* Copyright (c) 2020 Kiwix <android.kiwix.org>
* 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 <http://www.gnu.org/licenses/>.
*
*/
package org.kiwix.kiwixmobile.core.utils
import androidx.appcompat.widget.SearchView.OnQueryTextListener
class SimpleTextListener(private val onQueryTextChangeAction: (String, Boolean) -> Unit) :
OnQueryTextListener {
override fun onQueryTextSubmit(s: String): Boolean {
onQueryTextChangeAction.invoke(s, true)
return true
}
override fun onQueryTextChange(s: String): Boolean {
onQueryTextChangeAction.invoke(s, false)
return true
}
}

View File

@ -21,6 +21,7 @@ package org.kiwix.kiwixmobile.custom.search
import android.Manifest import android.Manifest
import android.content.Context import android.content.Context
import android.content.res.AssetFileDescriptor import android.content.res.AssetFileDescriptor
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.core.content.edit import androidx.core.content.edit
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.navigation.fragment.NavHostFragment import androidx.navigation.fragment.NavHostFragment
@ -52,6 +53,8 @@ import org.kiwix.kiwixmobile.core.search.SearchFragment
import org.kiwix.kiwixmobile.core.search.viewmodel.Action import org.kiwix.kiwixmobile.core.search.viewmodel.Action
import org.kiwix.kiwixmobile.core.utils.LanguageUtils import org.kiwix.kiwixmobile.core.utils.LanguageUtils
import org.kiwix.kiwixmobile.core.utils.SharedPreferenceUtil import org.kiwix.kiwixmobile.core.utils.SharedPreferenceUtil
import org.kiwix.kiwixmobile.core.utils.TestingUtils.COMPOSE_TEST_RULE_ORDER
import org.kiwix.kiwixmobile.core.utils.TestingUtils.RETRY_RULE_ORDER
import org.kiwix.kiwixmobile.custom.main.CustomMainActivity import org.kiwix.kiwixmobile.custom.main.CustomMainActivity
import org.kiwix.kiwixmobile.custom.main.CustomReaderFragment import org.kiwix.kiwixmobile.custom.main.CustomReaderFragment
import org.kiwix.kiwixmobile.custom.testutils.RetryRule import org.kiwix.kiwixmobile.custom.testutils.RetryRule
@ -80,10 +83,13 @@ class SearchFragmentTestForCustomApp {
InstrumentationRegistry.getInstrumentation().targetContext.applicationContext InstrumentationRegistry.getInstrumentation().targetContext.applicationContext
} }
@Rule @Rule(order = RETRY_RULE_ORDER)
@JvmField @JvmField
var retryRule = RetryRule() var retryRule = RetryRule()
@get:Rule(order = COMPOSE_TEST_RULE_ORDER)
val composeTestRule = createComposeRule()
private lateinit var customMainActivity: CustomMainActivity private lateinit var customMainActivity: CustomMainActivity
private lateinit var uiDevice: UiDevice private lateinit var uiDevice: UiDevice
private lateinit var downloadingZimFile: File private lateinit var downloadingZimFile: File
@ -151,24 +157,24 @@ class SearchFragmentTestForCustomApp {
openZimFileInReader(zimFile = downloadingZimFile) openZimFileInReader(zimFile = downloadingZimFile)
openSearchWithQuery() openSearchWithQuery()
val searchTerm = "gard" val searchTerm = "gard"
val searchedItem = "Gardanta Spirito" val searchedItem = "Gardanta Spirito - Андивионский Научный Альянс"
search { search {
// test with fast typing/deleting // test with fast typing/deleting
searchWithFrequentlyTypedWords(searchTerm) searchWithFrequentlyTypedWords(searchTerm, composeTestRule = composeTestRule)
assertSearchSuccessful(searchedItem) assertSearchSuccessful(searchedItem, composeTestRule)
deleteSearchedQueryFrequently(searchTerm, uiDevice) deleteSearchedQueryFrequently(searchTerm, uiDevice, composeTestRule = composeTestRule)
// test with a short delay typing/deleting to // test with a short delay typing/deleting to
// properly test the cancelling of previously searching task // properly test the cancelling of previously searching task
searchWithFrequentlyTypedWords(searchTerm, 50) searchWithFrequentlyTypedWords(searchTerm, 50, composeTestRule)
assertSearchSuccessful(searchedItem) assertSearchSuccessful(searchedItem, composeTestRule)
deleteSearchedQueryFrequently(searchTerm, uiDevice, 50) deleteSearchedQueryFrequently(searchTerm, uiDevice, 50, composeTestRule)
// test with a long delay typing/deleting to // test with a long delay typing/deleting to
// properly execute the search query letter by letter // properly execute the search query letter by letter
searchWithFrequentlyTypedWords(searchTerm, 300) searchWithFrequentlyTypedWords(searchTerm, 300, composeTestRule)
assertSearchSuccessful(searchedItem) assertSearchSuccessful(searchedItem, composeTestRule)
deleteSearchedQueryFrequently(searchTerm, uiDevice, 300) deleteSearchedQueryFrequently(searchTerm, uiDevice, 300, composeTestRule)
// to close the keyboard // to close the keyboard
pressBack() pressBack()
// go to reader screen // go to reader screen
@ -179,16 +185,16 @@ class SearchFragmentTestForCustomApp {
// frequently searched for article, and clicked on the searched item. // frequently searched for article, and clicked on the searched item.
search { search {
// test by searching 10 article and clicking on them // test by searching 10 article and clicking on them
searchAndClickOnArticle(searchTerm) searchAndClickOnArticle(searchTerm, composeTestRule)
searchAndClickOnArticle("eilum") searchAndClickOnArticle("eilum", composeTestRule)
searchAndClickOnArticle("page") searchAndClickOnArticle("page", composeTestRule)
searchAndClickOnArticle("list") searchAndClickOnArticle("list", composeTestRule)
searchAndClickOnArticle("ladder") searchAndClickOnArticle("ladder", composeTestRule)
searchAndClickOnArticle("welc") searchAndClickOnArticle("welc", composeTestRule)
searchAndClickOnArticle("js") searchAndClickOnArticle("js", composeTestRule)
searchAndClickOnArticle("hizo") searchAndClickOnArticle("hizo", composeTestRule)
searchAndClickOnArticle("fad") searchAndClickOnArticle("fad", composeTestRule)
searchAndClickOnArticle("forum") searchAndClickOnArticle("forum", composeTestRule)
assertArticleLoaded() assertArticleLoaded()
} }
} }

View File

@ -19,14 +19,19 @@
package org.kiwix.kiwixmobile.custom.search package org.kiwix.kiwixmobile.custom.search
import android.view.KeyEvent import android.view.KeyEvent
import androidx.compose.ui.test.assert
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.ComposeContentTestRule
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextClearance
import androidx.compose.ui.test.performTextInput
import androidx.core.view.GravityCompat import androidx.core.view.GravityCompat
import androidx.recyclerview.widget.RecyclerView
import androidx.test.espresso.Espresso import androidx.test.espresso.Espresso
import androidx.test.espresso.Espresso.onView import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions import androidx.test.espresso.action.ViewActions
import androidx.test.espresso.action.ViewActions.click import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.assertion.ViewAssertions
import androidx.test.espresso.contrib.RecyclerViewActions
import androidx.test.espresso.matcher.ViewMatchers import androidx.test.espresso.matcher.ViewMatchers
import androidx.test.espresso.matcher.ViewMatchers.withText import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.espresso.web.assertion.WebViewAssertions.webMatches import androidx.test.espresso.web.assertion.WebViewAssertions.webMatches
@ -40,46 +45,60 @@ import androidx.test.espresso.web.webdriver.Locator
import androidx.test.uiautomator.UiDevice import androidx.test.uiautomator.UiDevice
import com.adevinta.android.barista.interaction.BaristaDrawerInteractions.openDrawerWithGravity import com.adevinta.android.barista.interaction.BaristaDrawerInteractions.openDrawerWithGravity
import com.adevinta.android.barista.interaction.BaristaSleepInteractions import com.adevinta.android.barista.interaction.BaristaSleepInteractions
import com.adevinta.android.barista.internal.matcher.HelperMatchers
import org.hamcrest.CoreMatchers.containsString import org.hamcrest.CoreMatchers.containsString
import org.kiwix.kiwixmobile.core.R import org.kiwix.kiwixmobile.core.R
import org.kiwix.kiwixmobile.core.search.SEARCH_FIELD_TESTING_TAG
import org.kiwix.kiwixmobile.core.search.SEARCH_ITEM_TESTING_TAG
import org.kiwix.kiwixmobile.custom.R.id import org.kiwix.kiwixmobile.custom.R.id
import org.kiwix.kiwixmobile.custom.testutils.TestUtils import org.kiwix.kiwixmobile.custom.testutils.TestUtils
import org.kiwix.kiwixmobile.custom.testutils.TestUtils.TEST_PAUSE_MS
import org.kiwix.kiwixmobile.custom.testutils.TestUtils.testFlakyView import org.kiwix.kiwixmobile.custom.testutils.TestUtils.testFlakyView
import org.kiwix.kiwixmobile.custom.testutils.TestUtils.waitUntilTimeout
fun search(searchRobot: SearchRobot.() -> Unit) = SearchRobot().searchRobot() fun search(searchRobot: SearchRobot.() -> Unit) = SearchRobot().searchRobot()
class SearchRobot { class SearchRobot {
fun searchWithFrequentlyTypedWords(query: String, wait: Long = 0L) { fun searchWithFrequentlyTypedWords(
query: String,
wait: Long = 0L,
composeTestRule: ComposeContentTestRule
) {
testFlakyView({ testFlakyView({
val searchView = Espresso.onView(ViewMatchers.withId(androidx.appcompat.R.id.search_src_text)) composeTestRule.apply {
searchView.perform(ViewActions.clearText()) waitUntilTimeout()
val searchView = onNodeWithTag(SEARCH_FIELD_TESTING_TAG)
searchView.performTextInput("")
for (char in query) { for (char in query) {
searchView.perform(ViewActions.typeText(char.toString())) searchView.performTextInput(char.toString())
if (wait != 0L) { if (wait != 0L) {
BaristaSleepInteractions.sleep(wait) waitUntilTimeout(wait)
}
} }
} }
}) })
} }
fun assertSearchSuccessful(searchResult: String) { fun assertSearchSuccessful(searchResult: String, composeTestRule: ComposeContentTestRule) {
testFlakyView({ composeTestRule.apply {
BaristaSleepInteractions.sleep(TestUtils.TEST_PAUSE_MS_FOR_SEARCH_TEST.toLong()) waitUntil(
Espresso.onView(ViewMatchers.withId(R.id.search_list)).check( timeoutMillis = TEST_PAUSE_MS.toLong(),
ViewAssertions.matches( condition = {
HelperMatchers.atPosition( onAllNodesWithTag(SEARCH_ITEM_TESTING_TAG)
0, .fetchSemanticsNodes().isNotEmpty()
ViewMatchers.hasDescendant(ViewMatchers.withSubstring(searchResult)) }
) )
) onAllNodesWithTag(SEARCH_ITEM_TESTING_TAG)[0]
) .assert(hasText(searchResult))
}) }
} }
fun deleteSearchedQueryFrequently(textToDelete: String, uiDevice: UiDevice, wait: Long = 0L) { fun deleteSearchedQueryFrequently(
testFlakyView({ textToDelete: String,
for (i in textToDelete.indices) { uiDevice: UiDevice,
wait: Long = 0L,
composeTestRule: ComposeContentTestRule
) {
repeat(textToDelete.length) {
uiDevice.pressKeyCode(KeyEvent.KEYCODE_DEL) uiDevice.pressKeyCode(KeyEvent.KEYCODE_DEL)
if (wait != 0L) { if (wait != 0L) {
BaristaSleepInteractions.sleep(wait) BaristaSleepInteractions.sleep(wait)
@ -87,9 +106,7 @@ class SearchRobot {
} }
// clear search query if any remains due to any condition not to affect any other test scenario // clear search query if any remains due to any condition not to affect any other test scenario
val searchView = Espresso.onView(ViewMatchers.withId(androidx.appcompat.R.id.search_src_text)) composeTestRule.onNodeWithTag(SEARCH_FIELD_TESTING_TAG).performTextClearance()
searchView.perform(ViewActions.clearText())
})
} }
private fun openSearchScreen() { private fun openSearchScreen() {
@ -99,26 +116,21 @@ class SearchRobot {
}) })
} }
fun searchAndClickOnArticle(searchString: String) { fun searchAndClickOnArticle(searchString: String, composeTestRule: ComposeContentTestRule) {
// Wait a bit to properly load the ZIM file in the reader. // wait a bit to properly load the ZIM file in the reader
BaristaSleepInteractions.sleep(TestUtils.TEST_PAUSE_MS_FOR_SEARCH_TEST.toLong()) composeTestRule.waitUntilTimeout(TestUtils.TEST_PAUSE_MS_FOR_SEARCH_TEST.toLong())
openSearchScreen() openSearchScreen()
// Wait a bit to properly visible the search screen. // Wait a bit to properly visible the search screen.
BaristaSleepInteractions.sleep(TestUtils.TEST_PAUSE_MS_FOR_SEARCH_TEST.toLong()) composeTestRule.waitUntilTimeout(TestUtils.TEST_PAUSE_MS_FOR_SEARCH_TEST.toLong())
searchWithFrequentlyTypedWords(searchString) searchWithFrequentlyTypedWords(searchString, composeTestRule = composeTestRule)
clickOnSearchItemInSearchList() clickOnSearchItemInSearchList(composeTestRule)
} }
private fun clickOnSearchItemInSearchList() { private fun clickOnSearchItemInSearchList(composeTestRule: ComposeContentTestRule) {
testFlakyView({ composeTestRule.apply {
BaristaSleepInteractions.sleep(TestUtils.TEST_PAUSE_MS_FOR_SEARCH_TEST.toLong()) waitUntilTimeout()
Espresso.onView(ViewMatchers.withId(R.id.search_list)).perform( onAllNodesWithTag(SEARCH_ITEM_TESTING_TAG)[0].performClick()
RecyclerViewActions.actionOnItemAtPosition<RecyclerView.ViewHolder>( }
0,
ViewActions.click()
)
)
})
} }
fun assertArticleLoaded() { fun assertArticleLoaded() {

View File

@ -21,12 +21,15 @@ package org.kiwix.kiwixmobile.custom.testutils
import android.content.Context import android.content.Context
import android.content.ContextWrapper import android.content.ContextWrapper
import android.content.Intent import android.content.Intent
import androidx.compose.ui.test.junit4.ComposeContentTestRule
import androidx.test.uiautomator.By import androidx.test.uiautomator.By
import androidx.test.uiautomator.UiDevice import androidx.test.uiautomator.UiDevice
import androidx.test.uiautomator.UiObject import androidx.test.uiautomator.UiObject
import androidx.test.uiautomator.UiSelector import androidx.test.uiautomator.UiSelector
import org.kiwix.kiwixmobile.core.utils.files.Log import org.kiwix.kiwixmobile.core.utils.files.Log
import java.io.File import java.io.File
import java.util.Timer
import java.util.TimerTask
object TestUtils { object TestUtils {
private const val TAG = "TESTUTILS" private const val TAG = "TESTUTILS"
@ -90,6 +93,31 @@ object TestUtils {
} }
} }
fun ComposeContentTestRule.waitUntilTimeout(timeoutMillis: Long = TEST_PAUSE_MS.toLong()) {
AsyncTimer.start(timeoutMillis)
waitUntil(
condition = { AsyncTimer.expired },
timeoutMillis = timeoutMillis + 1000
)
}
object AsyncTimer {
var expired = false
fun start(delay: Long = 1000) {
expired = false
val timerTask = TimerTaskImpl {
expired = true
}
Timer().schedule(timerTask, delay)
}
}
class TimerTaskImpl(private val runnable: Runnable) : TimerTask() {
override fun run() {
runnable.run()
}
}
@JvmStatic @JvmStatic
fun deleteTemporaryFilesOfTestCases(context: Context) { fun deleteTemporaryFilesOfTestCases(context: Context) {
context.getExternalFilesDirs(null).filterNotNull() context.getExternalFilesDirs(null).filterNotNull()