Merge pull request #4329 from kiwix/Fixes#4246

Migrated `SearchFragment` to jetpack compose.
This commit is contained in:
Kelson 2025-05-27 10:45:42 +02:00 committed by GitHub
commit ad62661e67
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
49 changed files with 803 additions and 956 deletions

View File

@ -31,6 +31,7 @@ import org.kiwix.kiwixmobile.BaseRobot
import org.kiwix.kiwixmobile.Findable.ViewId
import org.kiwix.kiwixmobile.R
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.nav.destination.library.online.LANGUAGE_MENU_ICON_TESTING_TAG
import org.kiwix.kiwixmobile.testutils.TestUtils

View File

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

View File

@ -19,28 +19,33 @@
package org.kiwix.kiwixmobile.search
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.action.ViewActions.clearText
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.withText
import androidx.test.espresso.web.sugar.Web.onWebView
import androidx.test.espresso.web.webdriver.DriverAtoms.findElement
import androidx.test.espresso.web.webdriver.Locator
import androidx.test.uiautomator.UiDevice
import applyWithViewHierarchyPrinting
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.Findable.ViewId
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.TEST_PAUSE_MS
import org.kiwix.kiwixmobile.testutils.TestUtils.testFlakyView
import org.kiwix.kiwixmobile.testutils.TestUtils.waitUntilTimeout
fun search(func: SearchRobot.() -> Unit) = SearchRobot().applyWithViewHierarchyPrinting(func)
@ -50,15 +55,11 @@ class SearchRobot : BaseRobot() {
val searchQueryForDownloadedZimFile = "A Fool"
val searchResultForDownloadedZimFile = "A Fool for You"
fun clickOnSearchItemInSearchList() {
BaristaSleepInteractions.sleep(TestUtils.TEST_PAUSE_MS_FOR_SEARCH_TEST.toLong())
isVisible(ViewId(R.id.search_list))
onView(withId(R.id.search_list)).perform(
actionOnItemAtPosition<RecyclerView.ViewHolder>(
0,
click()
)
)
fun clickOnSearchItemInSearchList(composeTestRule: ComposeContentTestRule) {
composeTestRule.apply {
waitUntilTimeout()
onAllNodesWithTag(SEARCH_ITEM_TESTING_TAG)[0].performClick()
}
}
fun checkZimFileSearchSuccessful(readerFragment: Int) {
@ -66,31 +67,47 @@ class SearchRobot : BaseRobot() {
isVisible(ViewId(readerFragment))
}
fun searchWithFrequentlyTypedWords(query: String, wait: Long = 0L) {
fun searchWithFrequentlyTypedWords(
query: String,
wait: Long = 0L,
composeTestRule: ComposeContentTestRule
) {
testFlakyView({
val searchView = onView(withId(androidx.appcompat.R.id.search_src_text))
for (char in query) {
searchView.perform(typeText(char.toString()))
if (wait != 0L) {
BaristaSleepInteractions.sleep(wait)
composeTestRule.apply {
waitUntilTimeout()
val searchView = onNodeWithTag(SEARCH_FIELD_TESTING_TAG)
searchView.performTextInput("")
for (char in query) {
searchView.performTextInput(char.toString())
if (wait != 0L) {
waitUntilTimeout(wait)
}
}
}
})
}
fun assertSearchSuccessful(searchResult: String) {
BaristaSleepInteractions.sleep(TestUtils.TEST_PAUSE_MS_FOR_SEARCH_TEST.toLong())
val recyclerViewId = R.id.search_list
onView(withId(recyclerViewId)).check(
matches(
atPosition(0, hasDescendant(withText(searchResult)))
fun assertSearchSuccessful(searchResult: String, composeTestRule: ComposeContentTestRule) {
composeTestRule.apply {
waitUntil(
timeoutMillis = TEST_PAUSE_MS.toLong(),
condition = {
onAllNodesWithTag(SEARCH_ITEM_TESTING_TAG)
.fetchSemanticsNodes().isNotEmpty()
}
)
)
onAllNodesWithTag(SEARCH_ITEM_TESTING_TAG)[0]
.assert(hasText(searchResult))
}
}
fun deleteSearchedQueryFrequently(textToDelete: String, uiDevice: UiDevice, wait: Long = 0L) {
for (i in textToDelete.indices) {
fun deleteSearchedQueryFrequently(
textToDelete: String,
uiDevice: UiDevice,
wait: Long = 0L,
composeTestRule: ComposeContentTestRule
) {
repeat(textToDelete.length) {
uiDevice.pressKeyCode(KeyEvent.KEYCODE_DEL)
if (wait != 0L) {
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
val searchView = onView(withId(androidx.appcompat.R.id.search_src_text))
searchView.perform(clearText())
composeTestRule.onNodeWithTag(SEARCH_FIELD_TESTING_TAG).performTextClearance()
}
fun clickOnNavigationIcon(composeTestRule: ComposeContentTestRule) {
composeTestRule.onNodeWithTag(NAVIGATION_ICON_TESTING_TAG).performClick()
}
private fun openSearchScreen() {
testFlakyView({ onView(withId(R.id.menu_search)).perform(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())
fun searchAndClickOnArticle(searchString: String, composeTestRule: ComposeContentTestRule) {
openSearchScreen()
searchWithFrequentlyTypedWords(searchString)
clickOnSearchItemInSearchList()
searchWithFrequentlyTypedWords(searchString, composeTestRule = composeTestRule)
clickOnSearchItemInSearchList(composeTestRule)
checkZimFileSearchSuccessful(org.kiwix.kiwixmobile.R.id.readerFragment)
}
fun assertArticleLoaded() {

View File

@ -47,7 +47,6 @@ import org.kiwix.kiwixmobile.language.viewmodel.LanguageViewModel
import javax.inject.Inject
const val SAVE_ICON_TESTING_TAG = "saveLanguages"
const val SEARCH_FIELD_TESTING_TAG = "searchField"
class LanguageFragment : BaseFragment() {
private val languageViewModel by lazy { viewModel<LanguageViewModel>(viewModelFactory) }

View File

@ -39,6 +39,7 @@ import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.dp
import org.kiwix.kiwixmobile.core.R
import org.kiwix.kiwixmobile.core.extensions.CollectSideEffectWithActivity
import org.kiwix.kiwixmobile.core.search.SEARCH_FIELD_TESTING_TAG
import org.kiwix.kiwixmobile.core.ui.components.ContentLoadingProgressBar
import org.kiwix.kiwixmobile.core.ui.components.KiwixAppBar
import org.kiwix.kiwixmobile.core.ui.components.KiwixSearchView

View File

@ -51,7 +51,6 @@ import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTag
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import org.kiwix.kiwixmobile.core.R.string
import org.kiwix.kiwixmobile.core.extensions.hideKeyboardOnLazyColumnScroll
import org.kiwix.kiwixmobile.core.ui.components.ContentLoadingProgressBar
@ -72,6 +71,7 @@ import org.kiwix.kiwixmobile.core.utils.ComposeDimens.EIGHT_DP
import org.kiwix.kiwixmobile.core.utils.ComposeDimens.FOUR_DP
import org.kiwix.kiwixmobile.core.utils.ComposeDimens.SIXTEEN_DP
import org.kiwix.kiwixmobile.core.utils.ComposeDimens.SIX_DP
import org.kiwix.kiwixmobile.core.utils.ComposeDimens.THREE_DP
import org.kiwix.kiwixmobile.nav.destination.library.local.rememberScrollBehavior
import org.kiwix.kiwixmobile.zimManager.libraryView.adapter.LibraryListItem
import org.kiwix.kiwixmobile.zimManager.libraryView.adapter.LibraryListItem.DividerItem
@ -258,7 +258,7 @@ private fun ShowFetchingLibraryLayout(message: String) {
) {
ContentLoadingProgressBar(
modifier = Modifier.size(DOWNLOADING_LIBRARY_PROGRESSBAR_SIZE),
circularProgressBarStockWidth = 3.dp,
circularProgressBarStockWidth = THREE_DP,
progressBarTrackColor = cardContainerColor
)
Text(

View File

@ -132,8 +132,7 @@
<fragment
android:id="@+id/searchFragment"
android:name="org.kiwix.kiwixmobile.core.search.SearchFragment"
android:label="SearchFragment"
tools:layout="@layout/fragment_search">
android:label="SearchFragment">
<action
android:id="@+id/action_searchFragment_to_readerFragment"
app:destination="@id/readerFragment"

View File

@ -23,7 +23,7 @@ import io.objectbox.query.QueryBuilder
import kotlinx.coroutines.flow.map
import org.kiwix.kiwixmobile.core.dao.entities.RecentSearchEntity
import org.kiwix.kiwixmobile.core.dao.entities.RecentSearchEntity_
import org.kiwix.kiwixmobile.core.search.adapter.SearchListItem.RecentSearchListItem
import org.kiwix.kiwixmobile.core.search.SearchListItem.RecentSearchListItem
import javax.inject.Inject
class NewRecentSearchDao @Inject constructor(

View File

@ -23,7 +23,7 @@ import androidx.room.Query
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import org.kiwix.kiwixmobile.core.dao.entities.RecentSearchRoomEntity
import org.kiwix.kiwixmobile.core.search.adapter.SearchListItem
import org.kiwix.kiwixmobile.core.search.SearchListItem
@Dao
abstract class RecentSearchRoomDao {

View File

@ -17,30 +17,19 @@
*/
package org.kiwix.kiwixmobile.core.search
import android.annotation.SuppressLint
import android.content.Intent
import android.os.Bundle
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.MenuItem.OnActionExpandListener
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.activity.OnBackPressedCallback
import androidx.appcompat.widget.SearchView
import androidx.core.view.MenuHost
import androidx.core.view.MenuProvider
import androidx.core.view.isVisible
import androidx.core.widget.ContentLoadingProgressBar
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.platform.ComposeView
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@ -51,22 +40,15 @@ import kotlinx.coroutines.withContext
import org.kiwix.kiwixmobile.core.R
import org.kiwix.kiwixmobile.core.base.BaseActivity
import org.kiwix.kiwixmobile.core.base.BaseFragment
import org.kiwix.kiwixmobile.core.databinding.FragmentSearchBinding
import org.kiwix.kiwixmobile.core.extensions.ActivityExtensions.cachedComponent
import org.kiwix.kiwixmobile.core.extensions.closeKeyboard
import org.kiwix.kiwixmobile.core.extensions.coreMainActivity
import org.kiwix.kiwixmobile.core.extensions.getDialogHostComposeView
import org.kiwix.kiwixmobile.core.extensions.setUpSearchView
import org.kiwix.kiwixmobile.core.extensions.update
import org.kiwix.kiwixmobile.core.extensions.viewModel
import org.kiwix.kiwixmobile.core.main.CoreMainActivity
import org.kiwix.kiwixmobile.core.search.adapter.SearchAdapter
import org.kiwix.kiwixmobile.core.search.adapter.SearchDelegate.RecentSearchDelegate
import org.kiwix.kiwixmobile.core.search.adapter.SearchDelegate.ZimSearchResultDelegate
import org.kiwix.kiwixmobile.core.search.adapter.SearchListItem
import org.kiwix.kiwixmobile.core.search.viewmodel.Action
import org.kiwix.kiwixmobile.core.search.viewmodel.Action.ActivityResultReceived
import org.kiwix.kiwixmobile.core.search.viewmodel.Action.ClickedSearchInText
import org.kiwix.kiwixmobile.core.search.viewmodel.Action.ExitedSearch
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.OnItemLongClick
@ -74,35 +56,58 @@ 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.SearchViewModel
import org.kiwix.kiwixmobile.core.ui.components.NavigationIcon
import org.kiwix.kiwixmobile.core.ui.models.ActionMenuItem
import org.kiwix.kiwixmobile.core.utils.EXTRA_IS_WIDGET_VOICE
import org.kiwix.kiwixmobile.core.utils.SimpleTextListener
import org.kiwix.kiwixmobile.core.utils.dialog.AlertDialogShower
import org.kiwix.kiwixmobile.core.utils.dialog.DialogHost
import org.kiwix.kiwixmobile.core.utils.dialog.DialogShower
import org.kiwix.kiwixmobile.core.utils.files.Log
import javax.inject.Inject
const val NAV_ARG_SEARCH_STRING = "searchString"
const val VISIBLE_ITEMS_THRESHOLD = 5
const val LOADING_ITEMS_BEFORE = 3
const val DISABLED_SEARCH_IN_TEXT_OPACITY = 0.6f
const val ENABLED_SEARCH_IN_TEXT_OPACITY = 1f
class SearchFragment : BaseFragment() {
@Inject lateinit var viewModelFactory: ViewModelProvider.Factory
private var searchView: SearchView? = null
private var searchInTextMenuItem: MenuItem? = null
private var searchMenuItem: MenuItem? = null
private var findInPageTextView: TextView? = null
private var fragmentSearchBinding: FragmentSearchBinding? = null
@Inject lateinit var dialogShower: DialogShower
val searchViewModel by lazy { viewModel<SearchViewModel>(viewModelFactory) }
private var searchAdapter: SearchAdapter? = null
private var isDataLoading = false
private var isDataLoading = mutableStateOf(false)
private var renderingJob: Job? = null
/**
* 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 val searchScreenState = mutableStateOf(
SearchScreenState(
searchList = emptyList(),
isLoading = true,
shouldShowLoadingMoreProgressBar = false,
searchText = "",
onSearchViewClearClick = { onSearchClear() },
onSearchViewValueChange = { onSearchValueChanged(it) },
onItemClick = { onItemClick(it) },
onNewTabIconClick = { onItemClickNewTab(it) },
onItemLongClick = {
searchViewModel.actions.trySend(OnItemLongClick(it)).isSuccess
},
navigationIcon = {
NavigationIcon(onClick = { requireActivity().onBackPressedDispatcher.onBackPressed() })
},
onLoadMore = { loadMoreSearchResult() },
onKeyboardSubmitButtonClick = {
getSearchListItemForQuery(it)?.let(::onItemClick)
}
)
)
override fun inject(baseActivity: BaseActivity) {
baseActivity.cachedComponent.inject(this)
}
@ -111,80 +116,87 @@ class SearchFragment : BaseFragment() {
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
fragmentSearchBinding = FragmentSearchBinding.inflate(inflater, container, false)
setupMenu()
return fragmentSearchBinding?.root
): View? = ComposeView(requireContext()).also {
composeView = it
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
searchAdapter = SearchAdapter(
RecentSearchDelegate(::onItemClick, ::onItemClickNewTab) {
searchViewModel.actions.trySend(OnItemLongClick(it)).isSuccess
},
ZimSearchResultDelegate(::onItemClick, ::onItemClickNewTab)
)
setupToolbar(view)
fragmentSearchBinding?.searchList?.run {
adapter = searchAdapter
layoutManager = LinearLayoutManager(context, RecyclerView.VERTICAL, false)
setHasFixedSize(true)
// Add scroll listener to detect when the last item is reached
addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
val layoutManager = recyclerView.layoutManager as LinearLayoutManager
val totalItemCount = layoutManager.itemCount
val lastVisibleItem = layoutManager.findLastVisibleItemPosition()
// Check if the user is about to reach the last item
if (!isDataLoading &&
totalItemCount <= lastVisibleItem + VISIBLE_ITEMS_THRESHOLD - LOADING_ITEMS_BEFORE
) {
// Load more data when the last item is almost visible
loadMoreSearchResult()
}
}
})
}
viewLifecycleOwner.lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.CREATED) {
searchViewModel.effects.collect { it.invokeWith(this@SearchFragment.coreMainActivity) }
composeView?.apply {
setContent {
SearchScreen(
searchScreenState.value,
actionMenuItems(),
isDataLoading.value
)
DialogHost(dialogShower as AlertDialogShower)
}
}
handleBackPress()
fragmentSearchBinding?.root?.addView(
requireContext().getDialogHostComposeView(dialogShower as AlertDialogShower)
)
searchViewModel.setAlertDialogShower(dialogShower as AlertDialogShower)
observeViewModelData()
handleSearchArgument()
handleBackPress()
}
private fun handleSearchArgument() {
val searchStringFromArguments = arguments?.getString(NAV_ARG_SEARCH_STRING)
if (searchStringFromArguments != null) {
onSearchValueChanged(searchStringFromArguments)
}
val argsCopy = Bundle(arguments)
searchViewModel.actions.trySend(Action.CreatedWithArguments(argsCopy)).isSuccess
arguments?.remove(EXTRA_IS_WIDGET_VOICE)
}
private fun observeViewModelData() {
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
launch {
searchViewModel.effects.collect { effect ->
effect.invokeWith(this@SearchFragment.coreMainActivity)
}
}
launch {
searchViewModel.state.collect { state ->
render(state)
}
}
}
}
searchViewModel.voiceSearchResult.observe(viewLifecycleOwner) { searchTerm ->
searchTerm?.let {
onSearchValueChanged(it)
}
}
}
/**
* Loads more search results and appends them to the existing search results list in the RecyclerView.
* This function is typically triggered when the RecyclerView is near about its last item.
*/
@SuppressLint("CheckResult")
private fun loadMoreSearchResult() {
if (isDataLoading) return
isDataLoading = true
val safeStartIndex = searchAdapter?.itemCount ?: 0
if (isDataLoading.value) return
isDataLoading.value = true
val searchList = searchScreenState.value.searchList
// Show a loading indicator while data is being loaded
fragmentSearchBinding?.loadingMoreDataIndicator?.isShowing(true)
searchScreenState.update { copy(shouldShowLoadingMoreProgressBar = true) }
lifecycleScope.launch {
// Request more search results from the ViewModel, providing the start
// index and existing results
searchViewModel.loadMoreSearchResults(safeStartIndex, searchAdapter?.items)
searchViewModel.loadMoreSearchResults(searchList.size, searchList)
.let { searchResults ->
// Hide the loading indicator when data loading is complete
fragmentSearchBinding?.loadingMoreDataIndicator?.isShowing(false)
searchScreenState.update { copy(shouldShowLoadingMoreProgressBar = false) }
// Update data loading status based on the received search results
isDataLoading = when {
isDataLoading.value = when {
searchResults == null -> true
searchResults.isEmpty() -> false
else -> {
// Append the new search results to the existing list
searchAdapter?.addData(searchResults)
searchScreenState.update {
copy(searchList = searchScreenState.value.searchList + searchResults)
}
false
}
}
@ -203,34 +215,13 @@ class SearchFragment : BaseFragment() {
)
}
@Suppress("UnnecessarySafeCall")
private fun setupToolbar(view: View) {
view.post {
with(activity as? CoreMainActivity) {
this?.setSupportActionBar(view.findViewById(R.id.toolbar))
this?.supportActionBar?.apply {
setHomeButtonEnabled(true)
title = getString(R.string.menu_search_in_text)
}
}
}
}
override fun onDestroyView() {
super.onDestroyView()
renderingJob?.cancel()
renderingJob = null
activity?.intent?.action = null
searchView?.setOnQueryTextListener(null)
searchView = null
searchInTextMenuItem = null
findInPageTextView = null
searchMenuItem?.setOnActionExpandListener(null)
searchMenuItem = null
fragmentSearchBinding?.searchList?.adapter = null
searchAdapter = null
fragmentSearchBinding?.root?.removeAllViews()
fragmentSearchBinding = null
composeView?.disposeComposition()
composeView = null
}
private fun goBack() {
@ -238,84 +229,48 @@ class SearchFragment : BaseFragment() {
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)
searchMenuItem = menu.findItem(R.id.menu_search)
searchMenuItem?.expandActionView()
searchView = searchMenuItem?.actionView as SearchView
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)
}
}
)
}
searchMenuItem?.setOnActionExpandListener(
object : OnActionExpandListener {
override fun onMenuItemActionExpand(item: MenuItem) = false
override fun onMenuItemActionCollapse(item: MenuItem): Boolean {
searchViewModel.actions.trySend(ExitedSearch).isSuccess
return false
}
}
)
searchInTextMenuItem = menu.findItem(R.id.menu_searchintext)
findInPageTextView =
searchInTextMenuItem?.actionView?.findViewById(R.id.find_in_page_text_view)
searchInTextMenuItem?.actionView?.setOnClickListener {
searchViewModel.actions.trySend(ClickedSearchInText).isSuccess
}
viewLifecycleOwner.lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.CREATED) {
searchViewModel.state.collect { render(it) }
}
}
val searchStringFromArguments = arguments?.getString(NAV_ARG_SEARCH_STRING)
if (searchStringFromArguments != null) {
searchView?.setQuery(searchStringFromArguments, false)
}
searchViewModel.actions.trySend(Action.CreatedWithArguments(arguments)).isSuccess
arguments?.remove(EXTRA_IS_WIDGET_VOICE)
searchViewModel.voiceSearchResult.observe(viewLifecycleOwner) { searchTerm ->
searchTerm?.let {
searchView?.setQuery(it, false)
}
}
}
override fun onMenuItemSelected(menuItem: MenuItem) = true
},
viewLifecycleOwner,
Lifecycle.State.RESUMED
)
}
private fun getSearchListItemForQuery(query: String): SearchListItem? =
searchAdapter?.items?.firstOrNull {
searchScreenState.value.searchList.firstOrNull {
it.value.equals(query, ignoreCase = true)
}
private fun onSearchClear() {
searchScreenState.update { copy(searchText = "") }
setIsPageSearchEnabled("")
searchEntryForSearchTerm("")
}
private fun onSearchValueChanged(searchText: String) {
searchScreenState.update { copy(searchText = searchText) }
setIsPageSearchEnabled(searchText)
searchEntryForSearchTerm(searchText)
}
private fun searchEntryForSearchTerm(searchText: String) {
searchViewModel.actions.trySend(Filter(searchText)).isSuccess
}
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(
contentDescription = R.string.menu_search_in_text,
onClick = {
searchViewModel.actions.trySend(ClickedSearchInText).isSuccess
},
testingTag = FIND_IN_PAGE_TESTING_TAG,
iconButtonText = R.string.menu_search_in_text,
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")
suspend fun render(state: SearchState) {
private suspend fun render(state: SearchState) {
renderingJob?.apply {
// cancel the children job. Since we are getting the result on IO thread
// with `withContext` that is child for this job
@ -334,11 +289,10 @@ class SearchFragment : BaseFragment() {
if (!isVisible) {
return
}
isDataLoading = false
searchInTextMenuItem?.actionView?.isVisible = state.searchOrigin == FromWebView
setIsPageSearchEnabled(state.searchTerm.isNotBlank())
fragmentSearchBinding?.searchLoadingIndicator?.isShowing(true)
isDataLoading.value = false
findInPageMenuItem.value = findInPageMenuItem.value.first to (state.searchOrigin == FromWebView)
setIsPageSearchEnabled(state.searchTerm)
searchScreenState.update { copy(isLoading = true) }
renderingJob =
lifecycleScope.launch {
try {
@ -347,10 +301,11 @@ class SearchFragment : BaseFragment() {
state.getVisibleResults(0, coroutineContext[Job])
}
fragmentSearchBinding?.searchLoadingIndicator?.isShowing(false)
searchScreenState.update { copy(isLoading = false) }
searchResult?.let {
fragmentSearchBinding?.searchNoResults?.isVisible = it.isEmpty()
searchAdapter?.items = it
searchScreenState.update {
copy(searchList = it)
}
}
} catch (ignore: CancellationException) {
Log.e("SEARCH_RESULT", "Cancelled the previous job ${ignore.message}")
@ -360,19 +315,13 @@ class SearchFragment : BaseFragment() {
"Error in getting searched result\nOriginal exception ${ignore.message}"
)
} finally {
fragmentSearchBinding?.searchLoadingIndicator?.isShowing(false)
searchScreenState.update { copy(isLoading = false) }
}
}
}
private fun setIsPageSearchEnabled(isEnabled: Boolean) {
searchInTextMenuItem?.actionView?.isEnabled = isEnabled
findInPageTextView?.alpha =
if (isEnabled) {
ENABLED_SEARCH_IN_TEXT_OPACITY
} else {
DISABLED_SEARCH_IN_TEXT_OPACITY
}
private fun setIsPageSearchEnabled(searchText: String) {
findInPageMenuItem.value = searchText.isNotBlank() to findInPageMenuItem.value.second
}
private fun onItemClick(it: SearchListItem) {
@ -388,14 +337,12 @@ class SearchFragment : BaseFragment() {
@Suppress("DEPRECATION")
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
searchViewModel.actions.trySend(ActivityResultReceived(requestCode, resultCode, data)).isSuccess
}
}
private fun ContentLoadingProgressBar.isShowing(show: Boolean) {
if (show) {
show()
} else {
hide()
searchViewModel.actions.trySend(
ActivityResultReceived(
requestCode,
resultCode,
data
)
).isSuccess
}
}

View File

@ -1,6 +1,6 @@
/*
* Kiwix Android
* Copyright (c) 2020 Kiwix <android.kiwix.org>
* Copyright (c) 2025 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
@ -16,7 +16,7 @@
*
*/
package org.kiwix.kiwixmobile.core.search.adapter
package org.kiwix.kiwixmobile.core.search
sealed class SearchListItem {
abstract val value: String

View File

@ -0,0 +1,264 @@
/*
* Kiwix Android
* Copyright (c) 2025 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.search
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTag
import androidx.compose.ui.text.style.TextAlign
import org.kiwix.kiwixmobile.core.R
import org.kiwix.kiwixmobile.core.ui.components.ContentLoadingProgressBar
import org.kiwix.kiwixmobile.core.ui.components.KiwixAppBar
import org.kiwix.kiwixmobile.core.ui.components.KiwixSearchView
import org.kiwix.kiwixmobile.core.ui.models.ActionMenuItem
import org.kiwix.kiwixmobile.core.ui.theme.KiwixTheme
import org.kiwix.kiwixmobile.core.utils.ComposeDimens.EIGHT_DP
import org.kiwix.kiwixmobile.core.utils.ComposeDimens.FOUR_DP
import org.kiwix.kiwixmobile.core.utils.ComposeDimens.LOAD_MORE_PROGRESS_BAR_SIZE
import org.kiwix.kiwixmobile.core.utils.ComposeDimens.MINIMUM_HEIGHT_OF_SEARCH_ITEM
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.THREE_DP
const val SEARCH_FIELD_TESTING_TAG = "searchFieldTestingTag"
const val NO_SEARCH_RESULT_TESTING_TAG = "noSearchResultTestingTag"
const val FIND_IN_PAGE_TESTING_TAG = "findInPageTestingTag"
const val SEARCH_ITEM_TESTING_TAG = "searchItemTestingTag"
const val LOADING_ITEMS_BEFORE = 3
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SearchScreen(
searchScreenState: SearchScreenState,
actionMenuItemList: List<ActionMenuItem>,
isLoadingMoreResult: Boolean
) {
val lazyListState = rememberLazyListState()
KiwixTheme {
Scaffold(
topBar = {
KiwixAppBar(
titleId = R.string.empty_string,
navigationIcon = searchScreenState.navigationIcon,
actionMenuItems = actionMenuItemList,
searchBar = {
KiwixSearchView(
value = searchScreenState.searchText,
searchViewTextFiledTestTag = SEARCH_FIELD_TESTING_TAG,
onValueChange = searchScreenState.onSearchViewValueChange,
onClearClick = searchScreenState.onSearchViewClearClick,
modifier = Modifier,
onKeyboardSubmitButtonClick = searchScreenState.onKeyboardSubmitButtonClick
)
}
)
}
) { innerPadding ->
SearchScreenContent(searchScreenState, innerPadding, lazyListState)
}
}
InfiniteListHandler(
listState = lazyListState,
isLoadingMoreResult = isLoadingMoreResult,
onLoadMore = searchScreenState.onLoadMore
)
}
@Composable
private fun SearchScreenContent(
searchScreenState: SearchScreenState,
innerPadding: PaddingValues,
lazyListState: LazyListState
) {
val progressBarTrackColor = MaterialTheme.colorScheme.background
Box(
modifier = Modifier
.fillMaxSize()
.padding(
top = innerPadding.calculateTopPadding(),
start = innerPadding.calculateStartPadding(LocalLayoutDirection.current),
end = innerPadding.calculateEndPadding(LocalLayoutDirection.current)
),
contentAlignment = Alignment.Center
) {
if (searchScreenState.searchList.isEmpty()) {
NoSearchResultView()
} else {
LazyColumn(
modifier = Modifier
.fillMaxSize(),
state = lazyListState
) {
items(searchScreenState.searchList) { item ->
SearchListItem(
searchListItem = item,
onItemClick = { searchScreenState.onItemClick(item) },
onNewTabIconClick = { searchScreenState.onNewTabIconClick(item) },
onItemLongClick = if (item is SearchListItem.RecentSearchListItem) {
{ searchScreenState.onItemLongClick(item) }
} else {
null
}
)
}
showLoadMoreProgressBar(searchScreenState, progressBarTrackColor)
}
}
ShowLoadingProgressBar(searchScreenState.isLoading, progressBarTrackColor)
}
}
private fun LazyListScope.showLoadMoreProgressBar(
searchScreenState: SearchScreenState,
progressBarTrackColor: Color
) {
if (searchScreenState.shouldShowLoadingMoreProgressBar) {
item {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(SIXTEEN_DP),
contentAlignment = Alignment.Center
) {
ContentLoadingProgressBar(
modifier = Modifier.size(LOAD_MORE_PROGRESS_BAR_SIZE),
circularProgressBarStockWidth = THREE_DP,
progressBarTrackColor = progressBarTrackColor
)
}
}
}
}
@Composable
private fun ShowLoadingProgressBar(isLoading: Boolean, progressBarTrackColor: Color) {
if (isLoading) {
Box(
modifier = Modifier
.fillMaxSize(),
contentAlignment = Alignment.Center
) {
ContentLoadingProgressBar(progressBarTrackColor = progressBarTrackColor)
}
}
}
@Composable
private fun NoSearchResultView() {
Text(
text = stringResource(R.string.no_results),
textAlign = TextAlign.Center,
modifier = Modifier
.padding(horizontal = FOUR_DP)
.semantics { testTag = NO_SEARCH_RESULT_TESTING_TAG }
)
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun SearchListItem(
searchListItem: SearchListItem,
onNewTabIconClick: (SearchListItem) -> Unit,
onItemClick: (SearchListItem) -> Unit,
onItemLongClick: ((SearchListItem) -> Unit)? = null
) {
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = MINIMUM_HEIGHT_OF_SEARCH_ITEM),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = searchListItem.value,
modifier = Modifier
.weight(1f)
.padding(horizontal = EIGHT_DP)
.combinedClickable(
onClick = { onItemClick(searchListItem) },
onLongClick = { onItemLongClick?.invoke(searchListItem) }
)
.semantics { testTag = SEARCH_ITEM_TESTING_TAG },
fontSize = SEARCH_ITEM_TEXT_SIZE,
)
IconButton(
onClick = { onNewTabIconClick(searchListItem) },
) {
Icon(
painter = painterResource(id = R.drawable.ic_open_in_new_24dp),
contentDescription = stringResource(id = R.string.search_open_in_new_tab),
)
}
}
}
@Composable
fun InfiniteListHandler(
listState: LazyListState,
buffer: Int = LOADING_ITEMS_BEFORE,
isLoadingMoreResult: Boolean,
onLoadMore: () -> Unit
) {
val shouldLoadMore = remember {
derivedStateOf {
val lastVisibleItemIndex = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
val totalItemCount = listState.layoutInfo.totalItemsCount
!isLoadingMoreResult && lastVisibleItemIndex >= totalItemCount - buffer
}
}
LaunchedEffect(shouldLoadMore) {
snapshotFlow { shouldLoadMore.value }.collect { load ->
if (load) onLoadMore()
}
}
}

View File

@ -0,0 +1,72 @@
/*
* Kiwix Android
* Copyright (c) 2025 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.search
import androidx.compose.runtime.Composable
data class SearchScreenState(
/**
* Manages the search screen list state.
*/
val searchList: List<SearchListItem>,
/**
* Manages the showing of loading progress at the initial.
*/
val isLoading: Boolean,
/**
* Manages the showing of load more progress at the end of search list.
*/
val shouldShowLoadingMoreProgressBar: Boolean,
/**
* Handles the calling for more items.
*/
val onLoadMore: () -> Unit,
/**
* Stores the searchView text, and displayed it inside the searchView.
*/
val searchText: String,
/**
* Handles the click on searchView's close button.
*/
val onSearchViewClearClick: () -> Unit,
/**
* Handles the changing of searchView values.
*/
val onSearchViewValueChange: (String) -> Unit,
/**
* Handles the item click on searchItem
*/
val onItemClick: (SearchListItem) -> Unit,
/**
* Handles the long click on searchItem.
*/
val onItemLongClick: (SearchListItem) -> Unit,
/**
* Handles the newTabIcon click.
*/
val onNewTabIconClick: (SearchListItem) -> Unit,
/**
* Handles the Keyboard submit button click.
*/
val onKeyboardSubmitButtonClick: (String) -> Unit,
/**
* Manages the navigationIcon shown in the toolbar.
*/
val navigationIcon: @Composable() () -> Unit
)

View File

@ -1,28 +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.search.adapter
import org.kiwix.kiwixmobile.core.base.adapter.AdapterDelegate
import org.kiwix.kiwixmobile.core.base.adapter.BaseDelegateAdapter
class SearchAdapter(
vararg delegates: AdapterDelegate<SearchListItem>
) : BaseDelegateAdapter<SearchListItem>(*delegates) {
override fun getIdFor(item: SearchListItem) = item.value.hashCode().toLong()
}

View File

@ -1,61 +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.search.adapter
import android.view.ViewGroup
import org.kiwix.kiwixmobile.core.base.adapter.AbsDelegateAdapter
import org.kiwix.kiwixmobile.core.databinding.ListItemSearchBinding
import org.kiwix.kiwixmobile.core.extensions.ViewGroupExtensions.viewBinding
import org.kiwix.kiwixmobile.core.search.adapter.SearchListItem.RecentSearchListItem
import org.kiwix.kiwixmobile.core.search.adapter.SearchListItem.ZimSearchResultListItem
import org.kiwix.kiwixmobile.core.search.adapter.SearchViewHolder.RecentSearchViewHolder
import org.kiwix.kiwixmobile.core.search.adapter.SearchViewHolder.ZimSearchResultViewHolder
sealed class SearchDelegate<I : SearchListItem, out VH : SearchViewHolder<I>> :
AbsDelegateAdapter<I, SearchListItem, VH> {
class RecentSearchDelegate(
private val onClickListener: (SearchListItem) -> Unit,
private val onClickListenerNewTab: (SearchListItem) -> Unit,
private val onLongClickListener: (SearchListItem) -> Unit
) : SearchDelegate<RecentSearchListItem, RecentSearchViewHolder>() {
override val itemClass = RecentSearchListItem::class.java
override fun createViewHolder(parent: ViewGroup) =
RecentSearchViewHolder(
parent.viewBinding(ListItemSearchBinding::inflate, false),
onClickListener,
onClickListenerNewTab,
onLongClickListener
)
}
class ZimSearchResultDelegate(
private val onClickListener: (SearchListItem) -> Unit,
private val onClickListenerNewTab: (SearchListItem) -> Unit
) : SearchDelegate<ZimSearchResultListItem, ZimSearchResultViewHolder>() {
override val itemClass = ZimSearchResultListItem::class.java
override fun createViewHolder(parent: ViewGroup) =
ZimSearchResultViewHolder(
parent.viewBinding(ListItemSearchBinding::inflate, false),
onClickListener,
onClickListenerNewTab
)
}
}

View File

@ -1,65 +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.search.adapter
import android.view.View
import org.kiwix.kiwixmobile.core.base.adapter.BaseViewHolder
import org.kiwix.kiwixmobile.core.databinding.ListItemSearchBinding
import org.kiwix.kiwixmobile.core.search.adapter.SearchListItem.RecentSearchListItem
import org.kiwix.kiwixmobile.core.search.adapter.SearchListItem.ZimSearchResultListItem
sealed class SearchViewHolder<in T : SearchListItem>(containerView: View) :
BaseViewHolder<T>(containerView) {
class RecentSearchViewHolder(
private val listItemSearchBinding: ListItemSearchBinding,
private val onClickListener: (SearchListItem) -> Unit,
private val onClickListenerNewTab: (SearchListItem) -> Unit,
private val onLongClickListener: (SearchListItem) -> Unit
) : SearchViewHolder<RecentSearchListItem>(listItemSearchBinding.root) {
override fun bind(item: RecentSearchListItem) {
containerView.setOnClickListener { onClickListener(item) }
containerView.setOnLongClickListener {
onLongClickListener(item)
true
}
listItemSearchBinding.listItemSearchNewTabButton.setOnClickListener {
onClickListenerNewTab(
item
)
}
listItemSearchBinding.listItemSearchText.text = item.value
}
}
class ZimSearchResultViewHolder(
private val listItemSearchBinding: ListItemSearchBinding,
private val onClickListener: (SearchListItem) -> Unit,
private val onClickListenerNewTab: (SearchListItem) -> Unit
) : SearchViewHolder<ZimSearchResultListItem>(listItemSearchBinding.root) {
override fun bind(item: ZimSearchResultListItem) {
containerView.setOnClickListener { onClickListener(item) }
listItemSearchBinding.listItemSearchNewTabButton.setOnClickListener {
onClickListenerNewTab(
item
)
}
listItemSearchBinding.listItemSearchText.text = item.value
}
}
}

View File

@ -20,7 +20,7 @@ package org.kiwix.kiwixmobile.core.search.viewmodel
import android.content.Intent
import android.os.Bundle
import org.kiwix.kiwixmobile.core.search.adapter.SearchListItem
import org.kiwix.kiwixmobile.core.search.SearchListItem
sealed class Action {
object ExitedSearch : Action()

View File

@ -21,7 +21,7 @@ package org.kiwix.kiwixmobile.core.search.viewmodel
import kotlinx.coroutines.Job
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.yield
import org.kiwix.kiwixmobile.core.search.adapter.SearchListItem
import org.kiwix.kiwixmobile.core.search.SearchListItem
import org.kiwix.kiwixmobile.core.utils.files.Log
data class SearchState(

View File

@ -36,7 +36,7 @@ import org.kiwix.kiwixmobile.core.R
import org.kiwix.kiwixmobile.core.base.SideEffect
import org.kiwix.kiwixmobile.core.dao.RecentSearchRoomDao
import org.kiwix.kiwixmobile.core.reader.ZimReaderContainer
import org.kiwix.kiwixmobile.core.search.adapter.SearchListItem
import org.kiwix.kiwixmobile.core.search.SearchListItem
import org.kiwix.kiwixmobile.core.search.viewmodel.Action.ActivityResultReceived
import org.kiwix.kiwixmobile.core.search.viewmodel.Action.ClickedSearchInText
import org.kiwix.kiwixmobile.core.search.viewmodel.Action.ConfirmedDelete
@ -84,7 +84,7 @@ class SearchViewModel @Inject constructor(
FromWebView
)
val state: MutableStateFlow<SearchState> = MutableStateFlow(initialState)
private val _effects = Channel<SideEffect<*>>()
private val _effects = Channel<SideEffect<*>>(Channel.UNLIMITED)
val effects = _effects.receiveAsFlow()
val actions = Channel<Action>(Channel.UNLIMITED)
private val filter = MutableStateFlow("")

View File

@ -24,7 +24,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.kiwix.kiwixmobile.core.base.SideEffect
import org.kiwix.kiwixmobile.core.dao.RecentSearchRoomDao
import org.kiwix.kiwixmobile.core.search.adapter.SearchListItem
import org.kiwix.kiwixmobile.core.search.SearchListItem
@Suppress("InjectDispatcher")
data class DeleteRecentSearch(

View File

@ -27,7 +27,7 @@ import org.kiwix.kiwixmobile.core.extensions.ActivityExtensions.setNavigationRes
import org.kiwix.kiwixmobile.core.main.CoreMainActivity
import org.kiwix.kiwixmobile.core.main.SEARCH_ITEM_TITLE_KEY
import org.kiwix.kiwixmobile.core.reader.addContentPrefix
import org.kiwix.kiwixmobile.core.search.adapter.SearchListItem
import org.kiwix.kiwixmobile.core.search.SearchListItem
import org.kiwix.kiwixmobile.core.utils.TAG_FILE_SEARCHED
data class OpenSearchItem(

View File

@ -25,7 +25,7 @@ import kotlinx.coroutines.launch
import org.kiwix.kiwixmobile.core.base.SideEffect
import org.kiwix.kiwixmobile.core.dao.RecentSearchRoomDao
import org.kiwix.kiwixmobile.core.reader.addContentPrefix
import org.kiwix.kiwixmobile.core.search.adapter.SearchListItem
import org.kiwix.kiwixmobile.core.search.SearchListItem
@Suppress("InjectDispatcher")
data class SaveSearchToRecents(

View File

@ -22,7 +22,7 @@ import androidx.appcompat.app.AppCompatActivity
import kotlinx.coroutines.channels.Channel
import org.kiwix.kiwixmobile.core.base.SideEffect
import org.kiwix.kiwixmobile.core.extensions.ActivityExtensions.cachedComponent
import org.kiwix.kiwixmobile.core.search.adapter.SearchListItem
import org.kiwix.kiwixmobile.core.search.SearchListItem
import org.kiwix.kiwixmobile.core.search.viewmodel.Action
import org.kiwix.kiwixmobile.core.utils.dialog.DialogShower
import org.kiwix.kiwixmobile.core.utils.dialog.KiwixDialog.DeleteSearch

View File

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

View File

@ -34,6 +34,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TopAppBarScrollBehavior
@ -135,16 +136,34 @@ private fun AppBarTitle(
private fun ActionMenu(actionMenuItems: List<ActionMenuItem>) {
Row {
actionMenuItems.forEach { menuItem ->
IconButton(
enabled = menuItem.isEnabled,
onClick = menuItem.onClick,
modifier = menuItem.modifier.testTag(menuItem.testingTag)
) {
Icon(
painter = menuItem.icon.toPainter(),
contentDescription = stringResource(menuItem.contentDescription),
tint = if (menuItem.isEnabled) menuItem.iconTint else Color.Gray
)
val modifier = menuItem.modifier.testTag(menuItem.testingTag)
// If icon is not null show the icon.
menuItem.icon?.let {
IconButton(
enabled = menuItem.isEnabled,
onClick = menuItem.onClick,
modifier = modifier
) {
Icon(
painter = it.toPainter(),
contentDescription = stringResource(menuItem.contentDescription),
tint = if (menuItem.isEnabled) menuItem.iconTint else Color.Gray
)
}
} ?: run {
// Else show the textView button in menuItem.
TextButton(
enabled = menuItem.isEnabled,
onClick = menuItem.onClick,
modifier = modifier
) {
Text(
text = stringResource(id = menuItem.iconButtonText).uppercase(),
color = if (menuItem.isEnabled) Color.White else Color.Gray,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}

View File

@ -18,6 +18,8 @@
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.IconButton
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.graphics.Color
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.ImeAction
import org.kiwix.kiwixmobile.core.R
import org.kiwix.kiwixmobile.core.utils.ComposeDimens
@ -45,8 +49,10 @@ fun KiwixSearchView(
searchViewTextFiledTestTag: String = "",
clearButtonTestTag: String = "",
onValueChange: (String) -> Unit,
onClearClick: () -> Unit
onClearClick: () -> Unit,
onKeyboardSubmitButtonClick: (String) -> Unit = {}
) {
val keyboardController = LocalSoftwareKeyboardController.current
val colors = TextFieldDefaults.colors(
focusedIndicatorColor = 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.IconButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
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.ui.models.IconItem
import org.kiwix.kiwixmobile.core.ui.models.toPainter
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
* or drawable image.
@ -47,9 +52,10 @@ fun NavigationIcon(
iconItem: IconItem = IconItem.Vector(Icons.AutoMirrored.Filled.ArrowBack),
onClick: () -> Unit,
@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(
painter = iconItem.toPainter(),
contentDescription = stringResource(contentDescription),

View File

@ -21,14 +21,16 @@ package org.kiwix.kiwixmobile.core.ui.models
import androidx.annotation.StringRes
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import org.kiwix.kiwixmobile.core.R
import org.kiwix.kiwixmobile.core.ui.theme.White
data class ActionMenuItem(
val icon: IconItem,
val icon: IconItem? = null,
@StringRes val contentDescription: Int,
val onClick: () -> Unit,
val iconTint: Color = White,
val isEnabled: Boolean = true,
@StringRes val iconButtonText: Int = R.string.empty_string,
val testingTag: String,
val modifier: Modifier = Modifier
)

View File

@ -35,8 +35,8 @@ object ComposeDimens {
// Error screen dimens
val CRASH_IMAGE_SIZE = 70.dp
// KiwixAppBar(Toolbar) height
val KIWIX_APP_BAR_HEIGHT = 56.dp
// KiwixAppBar(Toolbar) dimens
val ACTION_MENU_TEXTVIEW_BUTTON_PADDING = 13.dp
// Padding & Margins
val SIXTY_DP = 60.dp
@ -51,6 +51,7 @@ object ComposeDimens {
val SIX_DP = 6.dp
val FIVE_DP = 5.dp
val FOUR_DP = 4.dp
val THREE_DP = 3.dp
val TWO_DP = 2.dp
val ONE_DP = 1.dp
val SEVENTY_DP = 70.dp
@ -169,4 +170,9 @@ object ComposeDimens {
val DOWNLOADING_LIBRARY_MESSAGE_TEXT_SIZE = 8.sp
val DOWNLOADING_LIBRARY_PROGRESSBAR_SIZE = 30.dp
const val ONLINE_BOOK_DISABLED_COLOR_ALPHA = 0.5F
// Search screen dimens
val MINIMUM_HEIGHT_OF_SEARCH_ITEM = 64.dp
val SEARCH_ITEM_TEXT_SIZE = 16.sp
val LOAD_MORE_PROGRESS_BAR_SIZE = 40.dp
}

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

@ -1,74 +0,0 @@
/*
* Kiwix Android
* Copyright (c) 2019 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.zim_manager
import android.content.Context
import android.util.AttributeSet
import android.view.LayoutInflater
import com.google.android.material.chip.Chip
import com.google.android.material.chip.ChipGroup
import org.kiwix.kiwixmobile.core.databinding.TagContentBinding
import org.kiwix.kiwixmobile.core.zim_manager.KiwixTag.Companion.YesNoValueTag
import org.kiwix.kiwixmobile.core.zim_manager.KiwixTag.Companion.YesNoValueTag.DetailsTag
import org.kiwix.kiwixmobile.core.zim_manager.KiwixTag.Companion.YesNoValueTag.PicturesTag
import org.kiwix.kiwixmobile.core.zim_manager.KiwixTag.Companion.YesNoValueTag.VideoTag
import org.kiwix.kiwixmobile.core.zim_manager.KiwixTag.TagValue.YES
class TagsView(context: Context, attrs: AttributeSet) : ChipGroup(context, attrs) {
private var tagContentBinding: TagContentBinding? = null
init {
tagContentBinding = TagContentBinding.inflate(LayoutInflater.from(context), this)
}
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
tagContentBinding = null
}
fun render(tags: List<KiwixTag>) {
tagContentBinding?.tagPicture?.selectBy(tags.isYesOrNotDefined<PicturesTag>())
tagContentBinding?.tagVideo?.selectBy(tags.isYesOrNotDefined<VideoTag>())
val shortTextIsSelected = tags.isDefinedAndNo<DetailsTag>()
tagContentBinding?.tagTextOnly?.selectBy(
tags.isDefinedAndNo<PicturesTag>() &&
tags.isDefinedAndNo<VideoTag>() &&
!shortTextIsSelected
)
tagContentBinding?.tagShortText?.selectBy(shortTextIsSelected)
}
private inline fun <reified T : YesNoValueTag> List<KiwixTag>.isYesOrNotDefined() =
isYes<T>() || !isDefined<T>()
private inline fun <reified T : YesNoValueTag> List<KiwixTag>.isDefinedAndNo() =
isDefined<T>() && !isYes<T>()
private inline fun <reified T : YesNoValueTag> List<KiwixTag>.isYes() =
filterIsInstance<T>().getOrNull(0)?.value == YES
private inline fun <reified T : YesNoValueTag> List<KiwixTag>.isDefined() =
filterIsInstance<T>().isNotEmpty()
private fun Chip.selectBy(criteria: Boolean) {
isChecked = criteria
isEnabled = criteria
visibility = if (criteria) VISIBLE else GONE
}
}

View File

@ -1,39 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Kiwix Android
~ Copyright (c) 2024 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/>.
~
-->
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="wrap_content"
android:layout_height="wrap_content">
<TextView
android:id="@+id/find_in_page_text_view"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:padding="@dimen/find_in_page_button_padding"
android:text="@string/menu_search_in_text"
android:textAllCaps="true"
android:textColor="@android:color/white"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:textColor="@android:color/black" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -1,56 +0,0 @@
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:animateLayoutChanges="true"
android:orientation="vertical">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<include layout="@layout/layout_toolbar" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/search_list"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginTop="?actionBarSize"
android:clipToPadding="false"
android:contentDescription="@string/searched_list"
app:layout_constraintBottom_toTopOf="@+id/loadingMoreDataIndicator"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:listitem="@layout/list_item_search" />
<androidx.core.widget.ContentLoadingProgressBar
android:id="@+id/loadingMoreDataIndicator"
style="?android:attr/progressBarStyleLarge"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_gravity="center"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
<TextView
android:id="@+id/searchNoResults"
style="@style/no_content"
android:text="@string/no_results" />
<androidx.core.widget.ContentLoadingProgressBar
android:id="@+id/searchLoadingIndicator"
style="?android:attr/progressBarStyleLarge"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:visibility="gone"
tools:visibility="visible" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@ -7,8 +7,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:popupTheme="@style/KiwixTheme"
android:theme="@style/ThemeOverlay.MaterialComponents.Dark.ActionBar"
tools:showIn="@layout/fragment_search">
android:theme="@style/ThemeOverlay.MaterialComponents.Dark.ActionBar">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/toolbarWithSearchPlaceholder"

View File

@ -1,50 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ 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/>.
~
-->
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:id="@+id/list_item_search_text"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:background="?android:attr/listChoiceBackgroundIndicator"
android:gravity="center_vertical"
android:minHeight="?android:attr/listPreferredItemHeight"
android:paddingStart="8dip"
android:paddingEnd="8dip"
android:textAppearance="?android:attr/textAppearanceListItem"
app:layout_constraintEnd_toStartOf="@+id/list_item_search_new_tab_button"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ImageButton
android:id="@+id/list_item_search_new_tab_button"
style="?android:attr/borderlessButtonStyle"
android:layout_width="wrap_content"
android:layout_height="0dp"
android:contentDescription="@string/search_open_in_new_tab"
android:src="@drawable/ic_open_in_new_24dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -1,33 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
tools:parentTag="com.google.android.material.chip.ChipGroup">
<com.google.android.material.chip.Chip
android:id="@+id/tag_picture"
style="@style/Widget.KiwixTheme.Chip.Choice.Static"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/tag_pic" />
<com.google.android.material.chip.Chip
android:id="@+id/tag_video"
style="@style/Widget.KiwixTheme.Chip.Choice.Static"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/tag_vid" />
<com.google.android.material.chip.Chip
android:id="@+id/tag_short_text"
style="@style/Widget.KiwixTheme.Chip.Choice.Static"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/tag_short_text" />
<com.google.android.material.chip.Chip
android:id="@+id/tag_text_only"
style="@style/Widget.KiwixTheme.Chip.Choice.Static"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/tag_text_only" />
</merge>

View File

@ -1,37 +0,0 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ 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/>.
~
-->
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<item
android:id="@+id/menu_page_search"
android:icon="@drawable/action_search"
android:title="@string/search_label"
app:actionViewClass="androidx.appcompat.widget.SearchView"
app:iconifiedByDefault="true"
app:showAsAction="always|collapseActionView"
tools:ignore="AlwaysShowAction" />
<item
android:id="@+id/menu_pages_clear"
android:icon="@drawable/ic_delete_white_24dp"
android:title="@string/pref_clear_all_bookmarks_title"
app:showAsAction="ifRoom" />
</menu>

View File

@ -1,20 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/menu_search"
android:icon="@drawable/action_search"
android:title="@string/search_label"
android:visible="false"
app:actionViewClass="androidx.appcompat.widget.SearchView"
app:iconifiedByDefault="false"
app:showAsAction="ifRoom|collapseActionView" />
<item
android:id="@+id/menu_searchintext"
android:title="@string/menu_search_in_text"
app:actionLayout = "@layout/action_searchintext"
app:showAsAction="ifRoom|withText" />
</menu>

View File

@ -30,7 +30,7 @@ import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.kiwix.kiwixmobile.core.dao.entities.RecentSearchEntity
import org.kiwix.kiwixmobile.core.dao.entities.RecentSearchEntity_
import org.kiwix.kiwixmobile.core.search.adapter.SearchListItem.RecentSearchListItem
import org.kiwix.kiwixmobile.core.search.SearchListItem.RecentSearchListItem
import org.kiwix.kiwixmobile.core.search.viewmodel.test
import org.kiwix.sharedFunctions.recentSearchEntity

View File

@ -27,8 +27,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runTest
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import org.kiwix.kiwixmobile.core.search.adapter.SearchListItem
import org.kiwix.kiwixmobile.core.search.adapter.SearchListItem.RecentSearchListItem
import org.kiwix.kiwixmobile.core.search.SearchListItem.RecentSearchListItem
import org.kiwix.kiwixmobile.core.search.viewmodel.SearchOrigin.FromWebView
internal class SearchStateTest {
@ -123,8 +122,8 @@ internal class SearchStateTest {
val searchResultsWithTerm =
SearchResultsWithTerm(searchTerm, suggestionSearchWrapper, mockk())
val searchState = SearchState(searchTerm, searchResultsWithTerm, emptyList(), FromWebView)
var list: List<SearchListItem.RecentSearchListItem>? = emptyList()
var list1: List<SearchListItem.RecentSearchListItem>? = emptyList()
var list: List<RecentSearchListItem>? = emptyList()
var list1: List<RecentSearchListItem>? = emptyList()
val job =
launch(Dispatchers.IO) {
delay(1000)

View File

@ -50,7 +50,7 @@ import org.kiwix.kiwixmobile.core.base.SideEffect
import org.kiwix.kiwixmobile.core.dao.RecentSearchRoomDao
import org.kiwix.kiwixmobile.core.reader.ZimFileReader
import org.kiwix.kiwixmobile.core.reader.ZimReaderContainer
import org.kiwix.kiwixmobile.core.search.adapter.SearchListItem.RecentSearchListItem
import org.kiwix.kiwixmobile.core.search.SearchListItem.RecentSearchListItem
import org.kiwix.kiwixmobile.core.search.viewmodel.Action.ActivityResultReceived
import org.kiwix.kiwixmobile.core.search.viewmodel.Action.ClickedSearchInText
import org.kiwix.kiwixmobile.core.search.viewmodel.Action.ConfirmedDelete

View File

@ -27,8 +27,8 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Test
import org.kiwix.kiwixmobile.core.dao.RecentSearchRoomDao
import org.kiwix.kiwixmobile.core.search.adapter.SearchListItem
import org.kiwix.kiwixmobile.core.search.adapter.SearchListItem.RecentSearchListItem
import org.kiwix.kiwixmobile.core.search.SearchListItem
import org.kiwix.kiwixmobile.core.search.SearchListItem.RecentSearchListItem
internal class DeleteRecentSearchTest {
@Test

View File

@ -28,7 +28,7 @@ import org.junit.jupiter.api.Test
import org.kiwix.kiwixmobile.core.extensions.ActivityExtensions.setNavigationResultOnCurrent
import org.kiwix.kiwixmobile.core.main.CoreMainActivity
import org.kiwix.kiwixmobile.core.reader.ZimFileReader
import org.kiwix.kiwixmobile.core.search.adapter.SearchListItem.RecentSearchListItem
import org.kiwix.kiwixmobile.core.search.SearchListItem.RecentSearchListItem
import org.kiwix.kiwixmobile.core.utils.TAG_FILE_SEARCHED
import org.kiwix.kiwixmobile.core.utils.TAG_FILE_SEARCHED_NEW_TAB

View File

@ -29,7 +29,7 @@ import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Test
import org.kiwix.kiwixmobile.core.dao.RecentSearchRoomDao
import org.kiwix.kiwixmobile.core.reader.ZimFileReader
import org.kiwix.kiwixmobile.core.search.adapter.SearchListItem.RecentSearchListItem
import org.kiwix.kiwixmobile.core.search.SearchListItem.RecentSearchListItem
internal class SaveSearchToRecentsTest {
private val newRecentSearchDao: RecentSearchRoomDao = mockk()

View File

@ -25,7 +25,7 @@ import io.mockk.verify
import kotlinx.coroutines.channels.Channel
import org.junit.jupiter.api.Test
import org.kiwix.kiwixmobile.core.main.CoreMainActivity
import org.kiwix.kiwixmobile.core.search.adapter.SearchListItem.RecentSearchListItem
import org.kiwix.kiwixmobile.core.search.SearchListItem.RecentSearchListItem
import org.kiwix.kiwixmobile.core.search.viewmodel.Action
import org.kiwix.kiwixmobile.core.search.viewmodel.Action.ConfirmedDelete
import org.kiwix.kiwixmobile.core.utils.dialog.DialogShower

View File

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

View File

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

View File

@ -21,12 +21,15 @@ package org.kiwix.kiwixmobile.custom.testutils
import android.content.Context
import android.content.ContextWrapper
import android.content.Intent
import androidx.compose.ui.test.junit4.ComposeContentTestRule
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
import java.io.File
import java.util.Timer
import java.util.TimerTask
object 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
fun deleteTemporaryFilesOfTestCases(context: Context) {
context.getExternalFilesDirs(null).filterNotNull()