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.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 {
for (char in query) { waitUntilTimeout()
searchView.perform(typeText(char.toString())) val searchView = onNodeWithTag(SEARCH_FIELD_TESTING_TAG)
if (wait != 0L) { searchView.performTextInput("")
BaristaSleepInteractions.sleep(wait) for (char in query) {
searchView.performTextInput(char.toString())
if (wait != 0L) {
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

@ -47,7 +47,6 @@ import org.kiwix.kiwixmobile.language.viewmodel.LanguageViewModel
import javax.inject.Inject import javax.inject.Inject
const val SAVE_ICON_TESTING_TAG = "saveLanguages" const val SAVE_ICON_TESTING_TAG = "saveLanguages"
const val SEARCH_FIELD_TESTING_TAG = "searchField"
class LanguageFragment : BaseFragment() { class LanguageFragment : BaseFragment() {
private val languageViewModel by lazy { viewModel<LanguageViewModel>(viewModelFactory) } 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 androidx.compose.ui.unit.dp
import org.kiwix.kiwixmobile.core.R import org.kiwix.kiwixmobile.core.R
import org.kiwix.kiwixmobile.core.extensions.CollectSideEffectWithActivity 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.ContentLoadingProgressBar
import org.kiwix.kiwixmobile.core.ui.components.KiwixAppBar import org.kiwix.kiwixmobile.core.ui.components.KiwixAppBar
import org.kiwix.kiwixmobile.core.ui.components.KiwixSearchView 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.semantics.testTag
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign 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.R.string
import org.kiwix.kiwixmobile.core.extensions.hideKeyboardOnLazyColumnScroll import org.kiwix.kiwixmobile.core.extensions.hideKeyboardOnLazyColumnScroll
import org.kiwix.kiwixmobile.core.ui.components.ContentLoadingProgressBar 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.FOUR_DP
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.SIX_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.nav.destination.library.local.rememberScrollBehavior
import org.kiwix.kiwixmobile.zimManager.libraryView.adapter.LibraryListItem import org.kiwix.kiwixmobile.zimManager.libraryView.adapter.LibraryListItem
import org.kiwix.kiwixmobile.zimManager.libraryView.adapter.LibraryListItem.DividerItem import org.kiwix.kiwixmobile.zimManager.libraryView.adapter.LibraryListItem.DividerItem
@ -258,7 +258,7 @@ private fun ShowFetchingLibraryLayout(message: String) {
) { ) {
ContentLoadingProgressBar( ContentLoadingProgressBar(
modifier = Modifier.size(DOWNLOADING_LIBRARY_PROGRESSBAR_SIZE), modifier = Modifier.size(DOWNLOADING_LIBRARY_PROGRESSBAR_SIZE),
circularProgressBarStockWidth = 3.dp, circularProgressBarStockWidth = THREE_DP,
progressBarTrackColor = cardContainerColor progressBarTrackColor = cardContainerColor
) )
Text( Text(

View File

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

View File

@ -23,7 +23,7 @@ import io.objectbox.query.QueryBuilder
import kotlinx.coroutines.flow.map 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.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 import javax.inject.Inject
class NewRecentSearchDao @Inject constructor( class NewRecentSearchDao @Inject constructor(

View File

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

View File

@ -17,30 +17,19 @@
*/ */
package org.kiwix.kiwixmobile.core.search package org.kiwix.kiwixmobile.core.search
import android.annotation.SuppressLint
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.MenuItem.OnActionExpandListener
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.TextView
import androidx.activity.OnBackPressedCallback import androidx.activity.OnBackPressedCallback
import androidx.appcompat.widget.SearchView import androidx.compose.runtime.mutableStateOf
import androidx.core.view.MenuHost import androidx.compose.ui.platform.ComposeView
import androidx.core.view.MenuProvider
import androidx.core.view.isVisible
import androidx.core.widget.ContentLoadingProgressBar
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@ -51,22 +40,15 @@ import kotlinx.coroutines.withContext
import org.kiwix.kiwixmobile.core.R import org.kiwix.kiwixmobile.core.R
import org.kiwix.kiwixmobile.core.base.BaseActivity import org.kiwix.kiwixmobile.core.base.BaseActivity
import org.kiwix.kiwixmobile.core.base.BaseFragment 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.ActivityExtensions.cachedComponent
import org.kiwix.kiwixmobile.core.extensions.closeKeyboard import org.kiwix.kiwixmobile.core.extensions.closeKeyboard
import org.kiwix.kiwixmobile.core.extensions.coreMainActivity import org.kiwix.kiwixmobile.core.extensions.coreMainActivity
import org.kiwix.kiwixmobile.core.extensions.getDialogHostComposeView import org.kiwix.kiwixmobile.core.extensions.update
import org.kiwix.kiwixmobile.core.extensions.setUpSearchView
import org.kiwix.kiwixmobile.core.extensions.viewModel import org.kiwix.kiwixmobile.core.extensions.viewModel
import org.kiwix.kiwixmobile.core.main.CoreMainActivity 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
import org.kiwix.kiwixmobile.core.search.viewmodel.Action.ActivityResultReceived 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.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.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
@ -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.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.models.ActionMenuItem
import org.kiwix.kiwixmobile.core.utils.EXTRA_IS_WIDGET_VOICE 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.AlertDialogShower
import org.kiwix.kiwixmobile.core.utils.dialog.DialogHost
import org.kiwix.kiwixmobile.core.utils.dialog.DialogShower import org.kiwix.kiwixmobile.core.utils.dialog.DialogShower
import org.kiwix.kiwixmobile.core.utils.files.Log import org.kiwix.kiwixmobile.core.utils.files.Log
import javax.inject.Inject import javax.inject.Inject
const val NAV_ARG_SEARCH_STRING = "searchString" 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() { class SearchFragment : BaseFragment() {
@Inject lateinit var viewModelFactory: ViewModelProvider.Factory @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 @Inject lateinit var dialogShower: DialogShower
val searchViewModel by lazy { viewModel<SearchViewModel>(viewModelFactory) } val searchViewModel by lazy { viewModel<SearchViewModel>(viewModelFactory) }
private var searchAdapter: SearchAdapter? = null private var isDataLoading = mutableStateOf(false)
private var isDataLoading = false
private var renderingJob: Job? = null 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) { override fun inject(baseActivity: BaseActivity) {
baseActivity.cachedComponent.inject(this) baseActivity.cachedComponent.inject(this)
} }
@ -111,80 +116,87 @@ class SearchFragment : BaseFragment() {
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup?, container: ViewGroup?,
savedInstanceState: Bundle? savedInstanceState: Bundle?
): View? { ): View? = ComposeView(requireContext()).also {
fragmentSearchBinding = FragmentSearchBinding.inflate(inflater, container, false) composeView = it
setupMenu()
return fragmentSearchBinding?.root
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
searchAdapter = SearchAdapter( composeView?.apply {
RecentSearchDelegate(::onItemClick, ::onItemClickNewTab) { setContent {
searchViewModel.actions.trySend(OnItemLongClick(it)).isSuccess SearchScreen(
}, searchScreenState.value,
ZimSearchResultDelegate(::onItemClick, ::onItemClickNewTab) actionMenuItems(),
) isDataLoading.value
setupToolbar(view) )
fragmentSearchBinding?.searchList?.run { DialogHost(dialogShower as AlertDialogShower)
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) }
} }
} }
handleBackPress()
fragmentSearchBinding?.root?.addView(
requireContext().getDialogHostComposeView(dialogShower as AlertDialogShower)
)
searchViewModel.setAlertDialogShower(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. * 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. * This function is typically triggered when the RecyclerView is near about its last item.
*/ */
@SuppressLint("CheckResult")
private fun loadMoreSearchResult() { private fun loadMoreSearchResult() {
if (isDataLoading) return if (isDataLoading.value) return
isDataLoading = true isDataLoading.value = true
val safeStartIndex = searchAdapter?.itemCount ?: 0 val searchList = searchScreenState.value.searchList
// Show a loading indicator while data is being loaded // Show a loading indicator while data is being loaded
fragmentSearchBinding?.loadingMoreDataIndicator?.isShowing(true) searchScreenState.update { copy(shouldShowLoadingMoreProgressBar = true) }
lifecycleScope.launch { lifecycleScope.launch {
// Request more search results from the ViewModel, providing the start // Request more search results from the ViewModel, providing the start
// index and existing results // index and existing results
searchViewModel.loadMoreSearchResults(safeStartIndex, searchAdapter?.items) searchViewModel.loadMoreSearchResults(searchList.size, searchList)
.let { searchResults -> .let { searchResults ->
// Hide the loading indicator when data loading is complete // 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 // Update data loading status based on the received search results
isDataLoading = when { isDataLoading.value = when {
searchResults == null -> true searchResults == null -> true
searchResults.isEmpty() -> false searchResults.isEmpty() -> false
else -> { else -> {
// Append the new search results to the existing list // Append the new search results to the existing list
searchAdapter?.addData(searchResults) searchScreenState.update {
copy(searchList = searchScreenState.value.searchList + searchResults)
}
false 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() { override fun onDestroyView() {
super.onDestroyView() super.onDestroyView()
renderingJob?.cancel() renderingJob?.cancel()
renderingJob = null renderingJob = null
activity?.intent?.action = null activity?.intent?.action = null
searchView?.setOnQueryTextListener(null) composeView?.disposeComposition()
searchView = null composeView = null
searchInTextMenuItem = null
findInPageTextView = null
searchMenuItem?.setOnActionExpandListener(null)
searchMenuItem = null
fragmentSearchBinding?.searchList?.adapter = null
searchAdapter = null
fragmentSearchBinding?.root?.removeAllViews()
fragmentSearchBinding = null
} }
private fun goBack() { private fun goBack() {
@ -238,84 +229,48 @@ 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)
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? = private fun getSearchListItemForQuery(query: String): SearchListItem? =
searchAdapter?.items?.firstOrNull { searchScreenState.value.searchList.firstOrNull {
it.value.equals(query, ignoreCase = true) 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") @Suppress("InjectDispatcher")
suspend fun render(state: SearchState) { private suspend fun render(state: SearchState) {
renderingJob?.apply { renderingJob?.apply {
// cancel the children job. Since we are getting the result on IO thread // cancel the children job. Since we are getting the result on IO thread
// with `withContext` that is child for this job // with `withContext` that is child for this job
@ -334,11 +289,10 @@ class SearchFragment : BaseFragment() {
if (!isVisible) { if (!isVisible) {
return return
} }
isDataLoading = false isDataLoading.value = false
searchInTextMenuItem?.actionView?.isVisible = state.searchOrigin == FromWebView findInPageMenuItem.value = findInPageMenuItem.value.first to (state.searchOrigin == FromWebView)
setIsPageSearchEnabled(state.searchTerm.isNotBlank()) setIsPageSearchEnabled(state.searchTerm)
searchScreenState.update { copy(isLoading = true) }
fragmentSearchBinding?.searchLoadingIndicator?.isShowing(true)
renderingJob = renderingJob =
lifecycleScope.launch { lifecycleScope.launch {
try { try {
@ -347,10 +301,11 @@ class SearchFragment : BaseFragment() {
state.getVisibleResults(0, coroutineContext[Job]) state.getVisibleResults(0, coroutineContext[Job])
} }
fragmentSearchBinding?.searchLoadingIndicator?.isShowing(false) searchScreenState.update { copy(isLoading = false) }
searchResult?.let { searchResult?.let {
fragmentSearchBinding?.searchNoResults?.isVisible = it.isEmpty() searchScreenState.update {
searchAdapter?.items = it copy(searchList = it)
}
} }
} catch (ignore: CancellationException) { } catch (ignore: CancellationException) {
Log.e("SEARCH_RESULT", "Cancelled the previous job ${ignore.message}") 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}" "Error in getting searched result\nOriginal exception ${ignore.message}"
) )
} finally { } finally {
fragmentSearchBinding?.searchLoadingIndicator?.isShowing(false) searchScreenState.update { copy(isLoading = false) }
} }
} }
} }
private fun setIsPageSearchEnabled(isEnabled: Boolean) { private fun setIsPageSearchEnabled(searchText: String) {
searchInTextMenuItem?.actionView?.isEnabled = isEnabled findInPageMenuItem.value = searchText.isNotBlank() to findInPageMenuItem.value.second
findInPageTextView?.alpha =
if (isEnabled) {
ENABLED_SEARCH_IN_TEXT_OPACITY
} else {
DISABLED_SEARCH_IN_TEXT_OPACITY
}
} }
private fun onItemClick(it: SearchListItem) { private fun onItemClick(it: SearchListItem) {
@ -388,14 +337,12 @@ class SearchFragment : BaseFragment() {
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data) super.onActivityResult(requestCode, resultCode, data)
searchViewModel.actions.trySend(ActivityResultReceived(requestCode, resultCode, data)).isSuccess searchViewModel.actions.trySend(
} ActivityResultReceived(
} requestCode,
resultCode,
private fun ContentLoadingProgressBar.isShowing(show: Boolean) { data
if (show) { )
show() ).isSuccess
} else {
hide()
} }
} }

View File

@ -1,6 +1,6 @@
/* /*
* Kiwix Android * 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 * 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 * it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or * 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 { sealed class SearchListItem {
abstract val value: String 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.content.Intent
import android.os.Bundle import android.os.Bundle
import org.kiwix.kiwixmobile.core.search.adapter.SearchListItem import org.kiwix.kiwixmobile.core.search.SearchListItem
sealed class Action { sealed class Action {
object ExitedSearch : Action() object ExitedSearch : Action()

View File

@ -21,7 +21,7 @@ package org.kiwix.kiwixmobile.core.search.viewmodel
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.yield 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 import org.kiwix.kiwixmobile.core.utils.files.Log
data class SearchState( 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.base.SideEffect
import org.kiwix.kiwixmobile.core.dao.RecentSearchRoomDao import org.kiwix.kiwixmobile.core.dao.RecentSearchRoomDao
import org.kiwix.kiwixmobile.core.reader.ZimReaderContainer 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.ActivityResultReceived
import org.kiwix.kiwixmobile.core.search.viewmodel.Action.ClickedSearchInText import org.kiwix.kiwixmobile.core.search.viewmodel.Action.ClickedSearchInText
import org.kiwix.kiwixmobile.core.search.viewmodel.Action.ConfirmedDelete import org.kiwix.kiwixmobile.core.search.viewmodel.Action.ConfirmedDelete
@ -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

@ -24,7 +24,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.kiwix.kiwixmobile.core.base.SideEffect import org.kiwix.kiwixmobile.core.base.SideEffect
import org.kiwix.kiwixmobile.core.dao.RecentSearchRoomDao import org.kiwix.kiwixmobile.core.dao.RecentSearchRoomDao
import org.kiwix.kiwixmobile.core.search.adapter.SearchListItem import org.kiwix.kiwixmobile.core.search.SearchListItem
@Suppress("InjectDispatcher") @Suppress("InjectDispatcher")
data class DeleteRecentSearch( 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.CoreMainActivity
import org.kiwix.kiwixmobile.core.main.SEARCH_ITEM_TITLE_KEY import org.kiwix.kiwixmobile.core.main.SEARCH_ITEM_TITLE_KEY
import org.kiwix.kiwixmobile.core.reader.addContentPrefix 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 import org.kiwix.kiwixmobile.core.utils.TAG_FILE_SEARCHED
data class OpenSearchItem( 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.base.SideEffect
import org.kiwix.kiwixmobile.core.dao.RecentSearchRoomDao import org.kiwix.kiwixmobile.core.dao.RecentSearchRoomDao
import org.kiwix.kiwixmobile.core.reader.addContentPrefix import org.kiwix.kiwixmobile.core.reader.addContentPrefix
import org.kiwix.kiwixmobile.core.search.adapter.SearchListItem import org.kiwix.kiwixmobile.core.search.SearchListItem
@Suppress("InjectDispatcher") @Suppress("InjectDispatcher")
data class SaveSearchToRecents( data class SaveSearchToRecents(

View File

@ -22,7 +22,7 @@ import androidx.appcompat.app.AppCompatActivity
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import org.kiwix.kiwixmobile.core.base.SideEffect import org.kiwix.kiwixmobile.core.base.SideEffect
import org.kiwix.kiwixmobile.core.extensions.ActivityExtensions.cachedComponent 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.search.viewmodel.Action
import org.kiwix.kiwixmobile.core.utils.dialog.DialogShower import org.kiwix.kiwixmobile.core.utils.dialog.DialogShower
import org.kiwix.kiwixmobile.core.utils.dialog.KiwixDialog.DeleteSearch 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 REQ_CODE_SPEECH_INPUT
) )
} catch (a: ActivityNotFoundException) { } catch (_: ActivityNotFoundException) {
actions.trySend(StartSpeechInputFailed).isSuccess actions.trySend(StartSpeechInputFailed).isSuccess
} }
} }

View File

@ -34,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
@ -135,16 +136,34 @@ private fun AppBarTitle(
private fun ActionMenu(actionMenuItems: List<ActionMenuItem>) { private fun ActionMenu(actionMenuItems: List<ActionMenuItem>) {
Row { Row {
actionMenuItems.forEach { menuItem -> actionMenuItems.forEach { menuItem ->
IconButton( val modifier = menuItem.modifier.testTag(menuItem.testingTag)
enabled = menuItem.isEnabled, // If icon is not null show the icon.
onClick = menuItem.onClick, menuItem.icon?.let {
modifier = menuItem.modifier.testTag(menuItem.testingTag) IconButton(
) { enabled = menuItem.isEnabled,
Icon( onClick = menuItem.onClick,
painter = menuItem.icon.toPainter(), modifier = modifier
contentDescription = stringResource(menuItem.contentDescription), ) {
tint = if (menuItem.isEnabled) menuItem.iconTint else Color.Gray 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 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

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

View File

@ -35,8 +35,8 @@ object ComposeDimens {
// Error screen dimens // Error screen dimens
val CRASH_IMAGE_SIZE = 70.dp val CRASH_IMAGE_SIZE = 70.dp
// KiwixAppBar(Toolbar) height // KiwixAppBar(Toolbar) dimens
val KIWIX_APP_BAR_HEIGHT = 56.dp val ACTION_MENU_TEXTVIEW_BUTTON_PADDING = 13.dp
// Padding & Margins // Padding & Margins
val SIXTY_DP = 60.dp val SIXTY_DP = 60.dp
@ -51,6 +51,7 @@ object ComposeDimens {
val SIX_DP = 6.dp val SIX_DP = 6.dp
val FIVE_DP = 5.dp val FIVE_DP = 5.dp
val FOUR_DP = 4.dp val FOUR_DP = 4.dp
val THREE_DP = 3.dp
val TWO_DP = 2.dp val TWO_DP = 2.dp
val ONE_DP = 1.dp val ONE_DP = 1.dp
val SEVENTY_DP = 70.dp val SEVENTY_DP = 70.dp
@ -169,4 +170,9 @@ object ComposeDimens {
val DOWNLOADING_LIBRARY_MESSAGE_TEXT_SIZE = 8.sp val DOWNLOADING_LIBRARY_MESSAGE_TEXT_SIZE = 8.sp
val DOWNLOADING_LIBRARY_PROGRESSBAR_SIZE = 30.dp val DOWNLOADING_LIBRARY_PROGRESSBAR_SIZE = 30.dp
const val ONLINE_BOOK_DISABLED_COLOR_ALPHA = 0.5F 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_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
app:popupTheme="@style/KiwixTheme" app:popupTheme="@style/KiwixTheme"
android:theme="@style/ThemeOverlay.MaterialComponents.Dark.ActionBar" android:theme="@style/ThemeOverlay.MaterialComponents.Dark.ActionBar">
tools:showIn="@layout/fragment_search">
<androidx.constraintlayout.widget.ConstraintLayout <androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/toolbarWithSearchPlaceholder" 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.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.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.kiwixmobile.core.search.viewmodel.test
import org.kiwix.sharedFunctions.recentSearchEntity import org.kiwix.sharedFunctions.recentSearchEntity

View File

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

View File

@ -27,8 +27,8 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.kiwix.kiwixmobile.core.dao.RecentSearchRoomDao import org.kiwix.kiwixmobile.core.dao.RecentSearchRoomDao
import org.kiwix.kiwixmobile.core.search.adapter.SearchListItem import org.kiwix.kiwixmobile.core.search.SearchListItem
import org.kiwix.kiwixmobile.core.search.adapter.SearchListItem.RecentSearchListItem import org.kiwix.kiwixmobile.core.search.SearchListItem.RecentSearchListItem
internal class DeleteRecentSearchTest { internal class DeleteRecentSearchTest {
@Test @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.extensions.ActivityExtensions.setNavigationResultOnCurrent
import org.kiwix.kiwixmobile.core.main.CoreMainActivity import org.kiwix.kiwixmobile.core.main.CoreMainActivity
import org.kiwix.kiwixmobile.core.reader.ZimFileReader 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
import org.kiwix.kiwixmobile.core.utils.TAG_FILE_SEARCHED_NEW_TAB 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.junit.jupiter.api.Test
import org.kiwix.kiwixmobile.core.dao.RecentSearchRoomDao import org.kiwix.kiwixmobile.core.dao.RecentSearchRoomDao
import org.kiwix.kiwixmobile.core.reader.ZimFileReader 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 { internal class SaveSearchToRecentsTest {
private val newRecentSearchDao: RecentSearchRoomDao = mockk() private val newRecentSearchDao: RecentSearchRoomDao = mockk()

View File

@ -25,7 +25,7 @@ import io.mockk.verify
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.kiwix.kiwixmobile.core.main.CoreMainActivity 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
import org.kiwix.kiwixmobile.core.search.viewmodel.Action.ConfirmedDelete import org.kiwix.kiwixmobile.core.search.viewmodel.Action.ConfirmedDelete
import org.kiwix.kiwixmobile.core.utils.dialog.DialogShower 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.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,56 +45,68 @@ 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()
for (char in query) { val searchView = onNodeWithTag(SEARCH_FIELD_TESTING_TAG)
searchView.perform(ViewActions.typeText(char.toString())) searchView.performTextInput("")
if (wait != 0L) { for (char in query) {
BaristaSleepInteractions.sleep(wait) searchView.performTextInput(char.toString())
if (wait != 0L) {
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,
uiDevice.pressKeyCode(KeyEvent.KEYCODE_DEL) wait: Long = 0L,
if (wait != 0L) { composeTestRule: ComposeContentTestRule
BaristaSleepInteractions.sleep(wait) ) {
} 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 // 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()
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()
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()