#2082 Very slow search function - replace RX with Flow

This commit is contained in:
Sean Mac Gillicuddy 2020-09-22 13:08:00 +01:00
parent 222c0627a1
commit 19bd06a460
25 changed files with 548 additions and 441 deletions

View File

@ -6,7 +6,7 @@ buildscript {
dependencies {
classpath(Libs.com_android_tools_build_gradle)
classpath(Libs.kotlin_gradle_plugin)
classpath(Libs.navigation_kotlin_safeargs)
classpath(Libs.navigation_safe_args_gradle_plugin)
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files

View File

@ -1,3 +1,5 @@
import kotlin.String
/**
* Generated by https://github.com/jmfayard/buildSrcVersions
*
@ -5,54 +7,54 @@
* `$ ./gradlew buildSrcVersions`
*/
object Libs {
/**
* https://github.com/Kotlin/kotlinx.coroutines
*/
const val kotlinx_coroutines_android: String =
"org.jetbrains.kotlinx:kotlinx-coroutines-android:" +
Versions.org_jetbrains_kotlinx_kotlinx_coroutines
/**
* https://developer.android.com/guide/navigation
* https://github.com/Kotlin/kotlinx.coroutines
*/
const val navigation_kotlin_fragment: String =
"androidx.navigation:navigation-fragment-ktx:${Versions.navigation}"
const val navigation_kotlin_ui: String =
"androidx.navigation:navigation-ui-ktx:${Versions.navigation}"
const val navigation_kotlin_testing: String =
"androidx.navigation:navigation-testing:${Versions.navigation}"
const val navigation_kotlin_safeargs: String =
"androidx.navigation:navigation-safe-args-gradle-plugin:${Versions.navigation}"
const val kotlinx_coroutines_test: String = "org.jetbrains.kotlinx:kotlinx-coroutines-test:" +
Versions.org_jetbrains_kotlinx_kotlinx_coroutines
/**
* https://developer.android.com/testing
*/
const val espresso_contrib: String = "androidx.test.espresso:espresso-contrib:" +
Versions.androidx_test_espresso
Versions.androidx_test_espresso
/**
* https://developer.android.com/testing
*/
const val espresso_core: String = "androidx.test.espresso:espresso-core:" +
Versions.androidx_test_espresso
Versions.androidx_test_espresso
/**
* https://developer.android.com/testing
*/
const val espresso_intents: String = "androidx.test.espresso:espresso-intents:" +
Versions.androidx_test_espresso
Versions.androidx_test_espresso
/**
* https://developer.android.com/testing
*/
const val espresso_web: String = "androidx.test.espresso:espresso-web:" +
Versions.androidx_test_espresso
Versions.androidx_test_espresso
/**
* https://github.com/square/retrofit
*/
const val adapter_rxjava2: String = "com.squareup.retrofit2:adapter-rxjava2:" +
Versions.com_squareup_retrofit2
Versions.com_squareup_retrofit2
/**
* https://github.com/square/retrofit
*/
const val converter_simplexml: String = "com.squareup.retrofit2:converter-simplexml:" +
Versions.com_squareup_retrofit2
Versions.com_squareup_retrofit2
/**
* https://github.com/square/retrofit
@ -63,13 +65,13 @@ object Libs {
* https://square.github.io/okhttp/
*/
const val logging_interceptor: String = "com.squareup.okhttp3:logging-interceptor:" +
Versions.com_squareup_okhttp3
Versions.com_squareup_okhttp3
/**
* https://square.github.io/okhttp/
*/
const val mockwebserver: String = "com.squareup.okhttp3:mockwebserver:" +
Versions.com_squareup_okhttp3
Versions.com_squareup_okhttp3
/**
* https://square.github.io/okhttp/
@ -80,31 +82,55 @@ object Libs {
* https://kotlinlang.org/
*/
const val kotlin_android_extensions: String = "org.jetbrains.kotlin:kotlin-android-extensions:" +
Versions.org_jetbrains_kotlin
Versions.org_jetbrains_kotlin
/**
* https://kotlinlang.org/
*/
const val kotlin_android_extensions_runtime: String =
"org.jetbrains.kotlin:kotlin-android-extensions-runtime:" + Versions.org_jetbrains_kotlin
"org.jetbrains.kotlin:kotlin-android-extensions-runtime:" + Versions.org_jetbrains_kotlin
/**
* https://kotlinlang.org/
*/
const val kotlin_annotation_processing_gradle: String =
"org.jetbrains.kotlin:kotlin-annotation-processing-gradle:" + Versions.org_jetbrains_kotlin
"org.jetbrains.kotlin:kotlin-annotation-processing-gradle:" + Versions.org_jetbrains_kotlin
/**
* https://kotlinlang.org/
*/
const val kotlin_gradle_plugin: String = "org.jetbrains.kotlin:kotlin-gradle-plugin:" +
Versions.org_jetbrains_kotlin
Versions.org_jetbrains_kotlin
/**
* https://kotlinlang.org/
*/
const val kotlin_stdlib_jdk7: String = "org.jetbrains.kotlin:kotlin-stdlib-jdk7:" +
Versions.org_jetbrains_kotlin
Versions.org_jetbrains_kotlin
/**
* https://developer.android.com/topic/libraries/architecture/index.html
*/
const val navigation_fragment_ktx: String = "androidx.navigation:navigation-fragment-ktx:" +
Versions.androidx_navigation
/**
* https://developer.android.com/topic/libraries/architecture/index.html
*/
const val navigation_safe_args_gradle_plugin: String =
"androidx.navigation:navigation-safe-args-gradle-plugin:" + Versions.androidx_navigation
/**
* https://developer.android.com/topic/libraries/architecture/index.html
*/
const val navigation_testing: String = "androidx.navigation:navigation-testing:" +
Versions.androidx_navigation
/**
* https://developer.android.com/topic/libraries/architecture/index.html
*/
const val navigation_ui_ktx: String = "androidx.navigation:navigation-ui-ktx:" +
Versions.androidx_navigation
/**
* https://github.com/google/dagger
@ -115,19 +141,19 @@ object Libs {
* https://github.com/google/dagger
*/
const val dagger_android: String = "com.google.dagger:dagger-android:" +
Versions.com_google_dagger
Versions.com_google_dagger
/**
* https://github.com/google/dagger
*/
const val dagger_android_processor: String = "com.google.dagger:dagger-android-processor:" +
Versions.com_google_dagger
Versions.com_google_dagger
/**
* https://github.com/google/dagger
*/
const val dagger_compiler: String = "com.google.dagger:dagger-compiler:" +
Versions.com_google_dagger
Versions.com_google_dagger
/**
* https://github.com/yahoo/squidb
@ -138,13 +164,13 @@ object Libs {
* https://github.com/yahoo/squidb
*/
const val squidb_annotations: String = "com.yahoo.squidb:squidb-annotations:" +
Versions.com_yahoo_squidb
Versions.com_yahoo_squidb
/**
* https://github.com/yahoo/squidb
*/
const val squidb_processor: String = "com.yahoo.squidb:squidb-processor:" +
Versions.com_yahoo_squidb
Versions.com_yahoo_squidb
/**
* https://github.com/JakeWharton/butterknife/
@ -155,13 +181,13 @@ object Libs {
* https://github.com/JakeWharton/butterknife/
*/
const val butterknife_compiler: String = "com.jakewharton:butterknife-compiler:" +
Versions.com_jakewharton
Versions.com_jakewharton
/**
* https://github.com/JakeWharton/butterknife/
*/
const val butterknife_gradle_plugin: String = "com.jakewharton:butterknife-gradle-plugin:" +
Versions.com_jakewharton
Versions.com_jakewharton
/**
* https://developer.android.com/testing
@ -192,7 +218,7 @@ object Libs {
* https://objectbox.io
*/
const val objectbox_gradle_plugin: String = "io.objectbox:objectbox-gradle-plugin:" +
Versions.io_objectbox
Versions.io_objectbox
/**
* https://objectbox.io
@ -208,7 +234,7 @@ object Libs {
* https://objectbox.io
*/
const val objectbox_processor: String = "io.objectbox:objectbox-processor:" +
Versions.io_objectbox
Versions.io_objectbox
/**
* https://objectbox.io
@ -244,45 +270,42 @@ object Libs {
* https://developer.android.com/topic/libraries/architecture/index.html
*/
const val android_arch_lifecycle_extensions: String = "android.arch.lifecycle:extensions:" +
Versions.android_arch_lifecycle_extensions
Versions.android_arch_lifecycle_extensions
/**
* https://developer.android.com/studio
*/
const val com_android_tools_build_gradle: String = "com.android.tools.build:gradle:" +
Versions.com_android_tools_build_gradle
Versions.com_android_tools_build_gradle
const val de_fayard_buildsrcversions_gradle_plugin: String =
"de.fayard.buildSrcVersions:de.fayard.buildSrcVersions.gradle.plugin:" +
"de.fayard.buildSrcVersions:de.fayard.buildSrcVersions.gradle.plugin:" +
Versions.de_fayard_buildsrcversions_gradle_plugin
const val com_github_triplet_play_gradle_plugin: String =
"com.github.triplet.play:com.github.triplet.play.gradle.plugin:" +
"com.github.triplet.play:com.github.triplet.play.gradle.plugin:" +
Versions.com_github_triplet_play_gradle_plugin
const val multidex_instrumentation: String = "androidx.multidex:multidex-instrumentation:" +
Versions.multidex_instrumentation
/**
* http://jcp.org/en/jsr/detail?id=250
*/
const val javax_annotation_api: String = "javax.annotation:javax.annotation-api:" +
Versions.javax_annotation_api
Versions.javax_annotation_api
const val ink_page_indicator: String = "com.pacioianu.david:ink-page-indicator:" +
Versions.ink_page_indicator
Versions.ink_page_indicator
/**
* http://github.com/square/leakcanary/
*/
const val leakcanary_android: String = "com.squareup.leakcanary:leakcanary-android:" +
Versions.leakcanary_android
Versions.leakcanary_android
/**
* http://tools.android.com
*/
const val constraintlayout: String = "androidx.constraintlayout:constraintlayout:" +
Versions.constraintlayout
Versions.constraintlayout
/**
* http://developer.android.com/tools/extras/support-library.html
@ -300,7 +323,7 @@ object Libs {
const val junit_jupiter: String = "org.junit.jupiter:junit-jupiter:" + Versions.junit_jupiter
const val xfetch2okhttp: String = "androidx.tonyodev.fetch2okhttp:xfetch2okhttp:" +
Versions.xfetch2okhttp
Versions.xfetch2okhttp
/**
* http://assertj.org

View File

@ -1,3 +1,4 @@
import kotlin.String
import org.gradle.plugin.use.PluginDependenciesSpec
import org.gradle.plugin.use.PluginDependencySpec
@ -11,23 +12,27 @@ import org.gradle.plugin.use.PluginDependencySpec
* YOU are responsible for updating manually the dependency version.
*/
object Versions {
const val androidx_test_espresso: String = "3.2.0"
const val org_jetbrains_kotlinx_kotlinx_coroutines: String = "1.3.9"
const val androidx_test_espresso: String = "3.2.0" // available: "3.3.0"
const val com_squareup_retrofit2: String = "2.5.0" // available: "2.9.0"
const val com_squareup_okhttp3: String = "3.6.0" // available: "4.8.0"
const val com_squareup_okhttp3: String = "3.6.0" // available: "4.9.0"
const val org_jetbrains_kotlin: String = "1.3.72"
const val org_jetbrains_kotlin: String = "1.3.72" // available: "1.4.10"
const val com_google_dagger: String = "2.28.3"
const val androidx_navigation: String = "2.3.0"
const val com_google_dagger: String = "2.28.3" // available: "2.29.1"
const val com_yahoo_squidb: String = "2.0.0" // available: "3.2.3"
const val com_jakewharton: String = "10.2.2"
const val com_jakewharton: String = "10.2.2" // available: "10.2.3"
const val androidx_test: String = "1.2.0"
const val androidx_test: String = "1.2.0" // available: "1.3.0"
const val io_objectbox: String = "2.7.0"
const val io_objectbox: String = "2.7.0" // available: "2.7.1"
const val org_jacoco: String = "0.7.9"
@ -39,9 +44,7 @@ object Versions {
const val de_fayard_buildsrcversions_gradle_plugin: String = "0.7.0"
const val com_github_triplet_play_gradle_plugin: String = "2.8.0"
const val multidex_instrumentation: String = "2.0.0"
const val com_github_triplet_play_gradle_plugin: String = "2.8.0" // available: "3.0.0"
const val javax_annotation_api: String = "1.3.2"
@ -49,17 +52,17 @@ object Versions {
const val leakcanary_android: String = "2.4"
const val constraintlayout: String = "1.1.3"
const val constraintlayout: String = "1.1.3" // available: "2.0.1"
const val collection_ktx: String = "1.1.0"
const val preference_ktx: String = "1.1.1"
const val junit_jupiter: String = "5.6.2"
const val junit_jupiter: String = "5.6.2" // available: "5.7.0"
const val xfetch2okhttp: String = "3.1.4"
const val xfetch2okhttp: String = "3.1.4" // available: "3.1.5"
const val assertj_core: String = "3.16.1"
const val assertj_core: String = "3.16.1" // available: "3.17.2"
const val core_testing: String = "2.1.0"
@ -85,34 +88,32 @@ object Versions {
const val kiwixlib: String = "9.4.0"
const val material: String = "1.2.0"
const val material: String = "1.2.0" // available: "1.2.1"
const val multidex: String = "2.0.1"
const val barista: String = "2.7.1" // available: "3.5.0"
const val barista: String = "2.7.1" // available: "3.6.0"
const val xfetch2: String = "3.1.4"
const val xfetch2: String = "3.1.4" // available: "3.1.5"
const val jsr305: String = "3.0.2"
const val ktlint: String = "0.37.2"
const val ktlint: String = "0.36.0" // available: "0.39.0"
const val rxjava: String = "2.2.19"
const val webkit: String = "1.2.0"
const val webkit: String = "1.2.0" // available: "1.3.0"
const val aapt2: String = "4.0.1-6197926"
const val junit: String = "1.1.1"
const val navigation: String = "2.3.0"
const val junit: String = "1.1.1" // available: "1.1.2"
/**
* Current version: "6.2"
* See issue 19: How to update Gradle itself?
* https://github.com/jmfayard/buildSrcVersions/issues/19
*/
const val gradleLatestVersion: String = "6.5.1"
const val gradleLatestVersion: String = "6.6.1"
}
/**
@ -121,4 +122,4 @@ object Versions {
*/
val PluginDependenciesSpec.buildSrcVersions: PluginDependencySpec
inline get() =
id("de.fayard.buildSrcVersions").version(Versions.de_fayard_buildsrcversions_gradle_plugin)
id("de.fayard.buildSrcVersions").version(Versions.de_fayard_buildsrcversions_gradle_plugin)

View File

@ -161,9 +161,9 @@ class AllProjectConfigurer {
implementation(Libs.constraintlayout)
implementation(Libs.multidex)
// navigation
implementation(Libs.navigation_kotlin_fragment)
implementation(Libs.navigation_kotlin_ui)
androidTestImplementation(Libs.navigation_kotlin_testing)
implementation(Libs.navigation_fragment_ktx)
implementation(Libs.navigation_ui_ktx)
androidTestImplementation(Libs.navigation_testing)
implementation(Libs.okhttp)
implementation(Libs.logging_interceptor)
implementation(Libs.retrofit)

View File

@ -61,4 +61,6 @@ dependencies {
implementation(Libs.objectbox_kotlin)
implementation(Libs.objectbox_rxjava)
implementation(Libs.webkit)
testImplementation(Libs.kotlinx_coroutines_test)
implementation(Libs.kotlinx_coroutines_android)
}

View File

@ -0,0 +1,36 @@
/*
* 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.dao
import io.objectbox.query.Query
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.sendBlocking
import kotlinx.coroutines.flow.callbackFlow
import javax.inject.Inject
class FlowBuilder @Inject constructor() {
@OptIn(ExperimentalCoroutinesApi::class)
fun <T> buildCallbackFlow(query: Query<T>) =
callbackFlow<List<T>> {
val subscription = query.subscribe()
.observer { sendBlocking(it) }
awaitClose(subscription::cancel)
}
}

View File

@ -19,14 +19,20 @@ package org.kiwix.kiwixmobile.core.dao
import io.objectbox.Box
import io.objectbox.kotlin.query
import kotlinx.coroutines.ExperimentalCoroutinesApi
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.data.local.entity.RecentSearch
import org.kiwix.kiwixmobile.core.search.adapter.SearchListItem.RecentSearchListItem
import javax.inject.Inject
class NewRecentSearchDao @Inject constructor(private val box: Box<RecentSearchEntity>) {
fun recentSearches(zimId: String?) = box.asFlowable(
class NewRecentSearchDao @Inject constructor(
private val box: Box<RecentSearchEntity>,
private val flowBuilder: FlowBuilder
) {
@OptIn(ExperimentalCoroutinesApi::class)
fun recentSearches(zimId: String?) = flowBuilder.buildCallbackFlow(
box.query {
equal(RecentSearchEntity_.zimId, zimId ?: "")
orderDesc(RecentSearchEntity_.id)

View File

@ -23,6 +23,7 @@ import dagger.Provides
import io.objectbox.BoxStore
import io.objectbox.kotlin.boxFor
import org.kiwix.kiwixmobile.core.dao.FetchDownloadDao
import org.kiwix.kiwixmobile.core.dao.FlowBuilder
import org.kiwix.kiwixmobile.core.dao.HistoryDao
import org.kiwix.kiwixmobile.core.dao.NewBookDao
import org.kiwix.kiwixmobile.core.dao.NewBookmarksDao
@ -57,8 +58,10 @@ open class DatabaseModule {
@Provides @Singleton fun providesNewBookmarksDao(boxStore: BoxStore): NewBookmarksDao =
NewBookmarksDao(boxStore.boxFor())
@Provides @Singleton fun providesNewRecentSearchDao(boxStore: BoxStore): NewRecentSearchDao =
NewRecentSearchDao(boxStore.boxFor())
@Provides @Singleton fun providesNewRecentSearchDao(
boxStore: BoxStore,
flowBuilder: FlowBuilder
): NewRecentSearchDao = NewRecentSearchDao(boxStore.boxFor(), flowBuilder)
@Provides @Singleton fun providesFetchDownloadDao(
boxStore: BoxStore,

View File

@ -24,21 +24,23 @@ import android.view.Menu
import android.view.MenuItem
import android.view.MenuItem.OnActionExpandListener
import androidx.appcompat.widget.SearchView
import androidx.lifecycle.Observer
import androidx.core.view.isVisible
import androidx.core.widget.ContentLoadingProgressBar
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import io.reactivex.disposables.CompositeDisposable
import kotlinx.android.synthetic.main.activity_search.searchViewAnimator
import kotlinx.android.synthetic.main.activity_search.searchLoadingIndicator
import kotlinx.android.synthetic.main.activity_search.searchNoResults
import kotlinx.android.synthetic.main.activity_search.search_list
import kotlinx.android.synthetic.main.layout_toolbar.toolbar
import kotlinx.coroutines.flow.collect
import org.kiwix.kiwixmobile.core.R
import org.kiwix.kiwixmobile.core.R.id
import org.kiwix.kiwixmobile.core.base.BaseActivity
import org.kiwix.kiwixmobile.core.di.components.CoreComponent
import org.kiwix.kiwixmobile.core.extensions.ActivityExtensions.coreActivityComponent
import org.kiwix.kiwixmobile.core.extensions.ActivityExtensions.viewModel
import org.kiwix.kiwixmobile.core.extensions.setDistinctDisplayedChild
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
@ -49,13 +51,11 @@ import org.kiwix.kiwixmobile.core.search.viewmodel.Action.CreatedWithIntent
import org.kiwix.kiwixmobile.core.search.viewmodel.Action.ExitedSearch
import org.kiwix.kiwixmobile.core.search.viewmodel.Action.Filter
import org.kiwix.kiwixmobile.core.search.viewmodel.Action.OnItemClick
import org.kiwix.kiwixmobile.core.search.viewmodel.Action.OnOpenInNewTabClick
import org.kiwix.kiwixmobile.core.search.viewmodel.Action.OnItemLongClick
import org.kiwix.kiwixmobile.core.search.viewmodel.Action.OnOpenInNewTabClick
import org.kiwix.kiwixmobile.core.search.viewmodel.SearchOrigin.FromWebView
import org.kiwix.kiwixmobile.core.search.viewmodel.SearchState
import org.kiwix.kiwixmobile.core.search.viewmodel.SearchViewModel
import org.kiwix.kiwixmobile.core.search.viewmodel.State
import org.kiwix.kiwixmobile.core.search.viewmodel.State.NoResults
import org.kiwix.kiwixmobile.core.search.viewmodel.State.Results
import org.kiwix.kiwixmobile.core.utils.SimpleTextListener
import javax.inject.Inject
@ -68,7 +68,6 @@ class SearchActivity : BaseActivity() {
private lateinit var searchInTextMenuItem: MenuItem
private val searchViewModel by lazy { viewModel<SearchViewModel>(viewModelFactory) }
private val compositeDisposable = CompositeDisposable()
private val searchAdapter: SearchAdapter by lazy {
SearchAdapter(
RecentSearchDelegate(::onItemClick, ::onItemClickNewTab) {
@ -93,12 +92,9 @@ class SearchActivity : BaseActivity() {
layoutManager = LinearLayoutManager(context, RecyclerView.VERTICAL, false)
setHasFixedSize(true)
}
compositeDisposable.add(searchViewModel.effects.subscribe { it.invokeWith(this) })
}
override fun onDestroy() {
compositeDisposable.clear()
super.onDestroy()
lifecycleScope.launchWhenCreated {
searchViewModel.effects.collect { it.invokeWith(this@SearchActivity) }
}
}
override fun finish() {
@ -127,29 +123,19 @@ class SearchActivity : BaseActivity() {
searchViewModel.actions.offer(ClickedSearchInText)
true
}
searchViewModel.state.observe(this, Observer(::render))
lifecycleScope.launchWhenCreated {
searchViewModel.state.collect { render(it) }
}
searchViewModel.actions.offer(CreatedWithIntent(intent))
return true
}
private fun render(state: State) {
private fun render(state: SearchState) {
searchInTextMenuItem.isVisible = state.searchOrigin == FromWebView
when (state) {
is Results -> {
searchViewAnimator.setDistinctDisplayedChild(0)
searchAdapter.items = state.values
render(state.searchString)
}
is NoResults -> {
searchViewAnimator.setDistinctDisplayedChild(1)
render(state.searchString)
}
}
}
private fun render(searchString: String) {
searchInTextMenuItem.isEnabled = searchString.isNotBlank()
searchInTextMenuItem.isEnabled = state.searchTerm.isNotBlank()
searchLoadingIndicator.isShowing(state.isLoading)
searchNoResults.isVisible = state.visibleResults.isEmpty()
searchAdapter.items = state.visibleResults
}
private fun onItemClick(it: SearchListItem) {
@ -165,3 +151,11 @@ class SearchActivity : BaseActivity() {
searchViewModel.actions.offer(ActivityResultReceived(requestCode, resultCode, data))
}
}
private fun ContentLoadingProgressBar.isShowing(show: Boolean) {
if (show) {
show()
} else {
hide()
}
}

View File

@ -18,33 +18,49 @@
package org.kiwix.kiwixmobile.core.search.viewmodel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield
import org.kiwix.kiwixmobile.core.reader.ZimFileReader
import org.kiwix.kiwixmobile.core.reader.ZimReaderContainer
import org.kiwix.kiwixmobile.core.search.adapter.SearchListItem
import org.kiwix.kiwixmobile.core.search.adapter.SearchListItem.ZimSearchResultListItem
import javax.inject.Inject
interface SearchResultGenerator {
fun generateSearchResults(searchTerm: String): List<SearchListItem>
suspend fun generateSearchResults(
searchTerm: String,
zimFileReader: ZimFileReader?
): List<SearchListItem>
}
class ZimSearchResultGenerator @Inject constructor(
private val zimReaderContainer: ZimReaderContainer
) : SearchResultGenerator {
override fun generateSearchResults(searchTerm: String) =
if (searchTerm.isNotEmpty()) readResultsFromZim(searchTerm, zimReaderContainer.copyReader())
else emptyList()
class ZimSearchResultGenerator @Inject constructor() : SearchResultGenerator {
private fun readResultsFromZim(
it: String,
override suspend fun generateSearchResults(searchTerm: String, zimFileReader: ZimFileReader?) =
withContext(Dispatchers.IO) {
if (searchTerm.isNotEmpty()) readResultsFromZim(searchTerm, zimFileReader)
else emptyList()
}
private suspend fun readResultsFromZim(
searchTerm: String,
reader: ZimFileReader?
) =
reader?.searchSuggestions(it, 200).run { suggestionResults(reader) }
reader.also { yield() }
?.searchSuggestions(searchTerm, 200)
.also { yield() }
.run { suggestionResults(reader) }
private fun suggestionResults(reader: ZimFileReader?) = generateSequence {
reader?.getNextSuggestion()?.let { ZimSearchResultListItem(it.title) }
private suspend fun suggestionResults(reader: ZimFileReader?) = createList {
yield()
reader?.getNextSuggestion()
?.let { ZimSearchResultListItem(it.title) }
}
.distinct()
.toList()
.also { reader?.dispose() }
private suspend fun <T> createList(readSearchResult: suspend () -> T?): List<T> {
return mutableListOf<T>().apply {
while (true) readSearchResult()?.let(::add) ?: break
}
}
}

View File

@ -20,21 +20,21 @@ package org.kiwix.kiwixmobile.core.search.viewmodel
import org.kiwix.kiwixmobile.core.search.adapter.SearchListItem
data class SearchState(
val searchTerm: String,
val searchResultsWithTerm: SearchResultsWithTerm,
val recentResults: List<SearchListItem.RecentSearchListItem>,
val searchOrigin: SearchOrigin
) {
val visibleResults = when {
searchTerm.isNotEmpty() -> searchResultsWithTerm.results
else -> recentResults
}
val isLoading = searchTerm != searchResultsWithTerm.searchTerm
}
enum class SearchOrigin {
FromWebView,
FromTabView
}
sealed class State {
abstract val searchString: String
abstract val searchOrigin: SearchOrigin
data class Results(
override val searchString: String,
val values: List<SearchListItem>,
override val searchOrigin: SearchOrigin
) : State()
data class NoResults(override val searchString: String, override val searchOrigin: SearchOrigin) :
State()
}

View File

@ -18,15 +18,20 @@
package org.kiwix.kiwixmobile.core.search.viewmodel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import io.reactivex.Flowable
import io.reactivex.disposables.CompositeDisposable
import io.reactivex.disposables.Disposable
import io.reactivex.functions.Function4
import io.reactivex.processors.BehaviorProcessor
import io.reactivex.processors.PublishProcessor
import io.reactivex.schedulers.Schedulers
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.ConflatedBroadcastChannel
import kotlinx.coroutines.channels.consumeEach
import kotlinx.coroutines.channels.sendBlocking
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import org.kiwix.kiwixmobile.core.R
import org.kiwix.kiwixmobile.core.base.SideEffect
import org.kiwix.kiwixmobile.core.dao.NewRecentSearchDao
@ -45,8 +50,6 @@ import org.kiwix.kiwixmobile.core.search.viewmodel.Action.ReceivedPromptForSpeec
import org.kiwix.kiwixmobile.core.search.viewmodel.Action.ScreenWasStartedFrom
import org.kiwix.kiwixmobile.core.search.viewmodel.Action.StartSpeechInputFailed
import org.kiwix.kiwixmobile.core.search.viewmodel.SearchOrigin.FromWebView
import org.kiwix.kiwixmobile.core.search.viewmodel.State.NoResults
import org.kiwix.kiwixmobile.core.search.viewmodel.State.Results
import org.kiwix.kiwixmobile.core.search.viewmodel.effects.DeleteRecentSearch
import org.kiwix.kiwixmobile.core.search.viewmodel.effects.FinishActivity
import org.kiwix.kiwixmobile.core.search.viewmodel.effects.OpenSearchItem
@ -57,118 +60,85 @@ import org.kiwix.kiwixmobile.core.search.viewmodel.effects.SearchIntentProcessin
import org.kiwix.kiwixmobile.core.search.viewmodel.effects.ShowDeleteSearchDialog
import org.kiwix.kiwixmobile.core.search.viewmodel.effects.ShowToast
import org.kiwix.kiwixmobile.core.search.viewmodel.effects.StartSpeechInput
import java.util.concurrent.TimeUnit
import javax.inject.Inject
private const val DEBOUNCE_MS = 500L
@OptIn(ExperimentalCoroutinesApi::class)
class SearchViewModel @Inject constructor(
private val recentSearchDao: NewRecentSearchDao,
private val zimReaderContainer: ZimReaderContainer,
private val searchResultGenerator: SearchResultGenerator
) : ViewModel() {
val state = MutableLiveData<State>().apply { value = NoResults("", FromWebView) }
val effects = PublishProcessor.create<SideEffect<*>>()
val actions = PublishProcessor.create<Action>()
private val filter = BehaviorProcessor.createDefault("")
private val searchOrigin = BehaviorProcessor.createDefault(FromWebView)
private val searchResults = PublishProcessor.create<List<SearchListItem>>()
private val compositeDisposable = CompositeDisposable()
private var searchTask: Disposable? = null
private val initialState: SearchState =
SearchState("", SearchResultsWithTerm("", emptyList()), emptyList(), FromWebView)
val state: MutableStateFlow<SearchState> = MutableStateFlow(initialState)
private val _effects = Channel<SideEffect<*>>()
val effects = _effects.receiveAsFlow()
val actions = Channel<Action>(Channel.UNLIMITED)
private val filter = ConflatedBroadcastChannel("")
private val searchOrigin = ConflatedBroadcastChannel(FromWebView)
init {
compositeDisposable.addAll(
viewStateReducer(),
actionMapper(),
searchResultEventsFromZimReader()
)
viewModelScope.launch { reducer() }
viewModelScope.launch { actionMapper() }
}
override fun onCleared() {
compositeDisposable.clear()
super.onCleared()
private suspend fun reducer() {
combine(
filter.asFlow(),
searchResults(),
recentSearchDao.recentSearches(zimReaderContainer.id),
searchOrigin.asFlow()
) { searchTerm, searchResultsWithTerm, recentResults, searchOrigin ->
SearchState(searchTerm, searchResultsWithTerm, recentResults, searchOrigin)
}
.collect { state.value = it }
}
private fun actionMapper() = actions.map {
private fun searchResults() = filter.asFlow()
.mapLatest {
val zimFileReader = zimReaderContainer.copyReader()
try {
SearchResultsWithTerm(it, searchResultGenerator.generateSearchResults(it, zimFileReader))
} finally {
zimFileReader?.dispose()
}
}
private suspend fun actionMapper() = actions.consumeEach {
when (it) {
ExitedSearch -> effects.offer(FinishActivity)
ExitedSearch -> _effects.offer(FinishActivity)
is OnItemClick -> saveSearchAndOpenItem(it.searchListItem, false)
is OnOpenInNewTabClick -> saveSearchAndOpenItem(it.searchListItem, true)
is OnItemLongClick -> showDeleteDialog(it)
is Filter -> filter.offer(it.term)
is Filter -> filter.sendBlocking(it.term)
ClickedSearchInText -> searchPreviousScreenWhenStateIsValid()
is ConfirmedDelete -> deleteItemAndShowToast(it)
is CreatedWithIntent -> effects.offer(SearchIntentProcessing(it.intent, actions))
ReceivedPromptForSpeechInput -> effects.offer(StartSpeechInput(actions))
StartSpeechInputFailed -> effects.offer(ShowToast(R.string.speech_not_supported))
is CreatedWithIntent -> _effects.offer(SearchIntentProcessing(it.intent, actions))
ReceivedPromptForSpeechInput -> _effects.offer(StartSpeechInput(actions))
StartSpeechInputFailed -> _effects.offer(ShowToast(R.string.speech_not_supported))
is ActivityResultReceived ->
effects.offer(ProcessActivityResult(it.requestCode, it.resultCode, it.data, actions))
is ScreenWasStartedFrom -> searchOrigin.offer(it.searchOrigin)
_effects.offer(ProcessActivityResult(it.requestCode, it.resultCode, it.data, actions))
is ScreenWasStartedFrom -> searchOrigin.sendBlocking(it.searchOrigin)
}
}.subscribe(
{},
Throwable::printStackTrace
)
}
private fun deleteItemAndShowToast(it: ConfirmedDelete) {
effects.offer(DeleteRecentSearch(it.searchListItem, recentSearchDao))
effects.offer(ShowToast(R.string.delete_specific_search_toast))
_effects.offer(DeleteRecentSearch(it.searchListItem, recentSearchDao))
_effects.offer(ShowToast(R.string.delete_specific_search_toast))
}
private fun searchPreviousScreenWhenStateIsValid(): Any =
effects.offer(SearchInPreviousScreen(state.value!!.searchString))
_effects.offer(SearchInPreviousScreen(state.value.searchTerm))
private fun showDeleteDialog(longClick: OnItemLongClick) {
effects.offer(ShowDeleteSearchDialog(longClick.searchListItem, actions))
_effects.offer(ShowDeleteSearchDialog(longClick.searchListItem, actions))
}
private fun saveSearchAndOpenItem(searchListItem: SearchListItem, openInNewTab: Boolean) {
effects.offer(
SaveSearchToRecents(recentSearchDao, searchListItem, zimReaderContainer.id)
)
effects.offer(
OpenSearchItem(searchListItem, openInNewTab)
)
}
private fun viewStateReducer() =
Flowable.combineLatest(
recentSearchDao.recentSearches(zimReaderContainer.id),
searchResults,
filter,
searchOrigin,
Function4(this::reduce)
).subscribe(state::postValue, Throwable::printStackTrace)
private fun reduce(
recentSearchResults: List<SearchListItem>,
zimSearchResults: List<SearchListItem>,
searchString: String,
searchOrigin: SearchOrigin
) = when {
searchString.isNotEmpty() && zimSearchResults.isNotEmpty() ->
Results(searchString, zimSearchResults, searchOrigin)
searchString.isEmpty() && recentSearchResults.isNotEmpty() ->
Results(searchString, recentSearchResults, searchOrigin)
else -> NoResults(searchString, searchOrigin)
}
private fun searchResultEventsFromZimReader() = filter
.distinctUntilChanged()
.debounce(DEBOUNCE_MS, TimeUnit.MILLISECONDS)
.subscribe(::performSearch)
private fun performSearch(searchTerm: String) {
compositeDisposable.add(
Flowable.fromCallable { searchResultGenerator.generateSearchResults(searchTerm) }
.subscribeOn(Schedulers.io())
.subscribe { searchResults.offer(it) }
.also {
searchTask?.dispose()
searchTask = it
}
)
_effects.offer(SaveSearchToRecents(recentSearchDao, searchListItem, zimReaderContainer.id))
_effects.offer(OpenSearchItem(searchListItem, openInNewTab))
}
}
data class SearchResultsWithTerm(val searchTerm: String, val results: List<SearchListItem>)

View File

@ -22,7 +22,7 @@ import android.app.Activity
import android.content.Intent
import android.speech.RecognizerIntent
import androidx.appcompat.app.AppCompatActivity
import io.reactivex.processors.PublishProcessor
import kotlinx.coroutines.channels.Channel
import org.kiwix.kiwixmobile.core.base.SideEffect
import org.kiwix.kiwixmobile.core.search.viewmodel.Action
import org.kiwix.kiwixmobile.core.search.viewmodel.Action.Filter
@ -31,7 +31,7 @@ data class ProcessActivityResult(
private val requestCode: Int,
private val resultCode: Int,
private val data: Intent?,
private val actions: PublishProcessor<Action>
private val actions: Channel<Action>
) : SideEffect<Unit> {
override fun invokeWith(activity: AppCompatActivity) {
if (requestCode == StartSpeechInput.REQ_CODE_SPEECH_INPUT &&

View File

@ -22,7 +22,7 @@ import android.annotation.TargetApi
import android.content.Intent
import android.os.Build.VERSION_CODES
import androidx.appcompat.app.AppCompatActivity
import io.reactivex.processors.PublishProcessor
import kotlinx.coroutines.channels.Channel
import org.kiwix.kiwixmobile.core.base.SideEffect
import org.kiwix.kiwixmobile.core.search.viewmodel.Action
import org.kiwix.kiwixmobile.core.search.viewmodel.Action.Filter
@ -36,7 +36,7 @@ import org.kiwix.kiwixmobile.core.utils.TAG_FROM_TAB_SWITCHER
data class SearchIntentProcessing(
private val intent: Intent?,
private val actions: PublishProcessor<Action>
private val actions: Channel<Action>
) : SideEffect<Unit> {
@TargetApi(VERSION_CODES.M)
override fun invokeWith(activity: AppCompatActivity) {

View File

@ -19,7 +19,7 @@
package org.kiwix.kiwixmobile.core.search.viewmodel.effects
import androidx.appcompat.app.AppCompatActivity
import io.reactivex.processors.PublishProcessor
import kotlinx.coroutines.channels.Channel
import org.kiwix.kiwixmobile.core.base.SideEffect
import org.kiwix.kiwixmobile.core.search.SearchActivity
import org.kiwix.kiwixmobile.core.search.adapter.SearchListItem
@ -30,7 +30,7 @@ import javax.inject.Inject
data class ShowDeleteSearchDialog(
private val searchListItem: SearchListItem,
private val actions: PublishProcessor<Action>
private val actions: Channel<Action>
) : SideEffect<Unit> {
@Inject lateinit var dialogShower: DialogShower

View File

@ -22,14 +22,14 @@ import android.content.ActivityNotFoundException
import android.content.Intent
import android.speech.RecognizerIntent
import androidx.appcompat.app.AppCompatActivity
import io.reactivex.processors.PublishProcessor
import kotlinx.coroutines.channels.Channel
import org.kiwix.kiwixmobile.core.R
import org.kiwix.kiwixmobile.core.base.SideEffect
import org.kiwix.kiwixmobile.core.search.viewmodel.Action
import org.kiwix.kiwixmobile.core.search.viewmodel.Action.StartSpeechInputFailed
import java.util.Locale
data class StartSpeechInput(private val actions: PublishProcessor<Action>) : SideEffect<Unit> {
data class StartSpeechInput(private val actions: Channel<Action>) : SideEffect<Unit> {
override fun invokeWith(activity: AppCompatActivity) {
try {

View File

@ -1,6 +1,8 @@
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:tools="http://schemas.android.com/tools"
android:animateLayoutChanges="true"
android:fitsSystemWindows="true"
android:orientation="vertical">
@ -16,26 +18,28 @@
<include layout="@layout/layout_toolbar" />
</RelativeLayout>
<ViewAnimator
android:id="@+id/searchViewAnimator"
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/search_list"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:animateLayoutChanges="true">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/search_list"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<TextView
style="@style/no_content_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="@string/no_results" />
</ViewAnimator>
android:layout_height="match_parent" />
</LinearLayout>
<TextView
android:id="@+id/searchNoResults"
style="@style/no_content_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="@string/no_results" />
<androidx.core.widget.ContentLoadingProgressBar
android:id="@+id/searchLoadingIndicator"
style="?android:attr/progressBarStyleLarge"
android:visibility="gone"
tools:visibility="visible"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@ -20,60 +20,72 @@ package org.kiwix.kiwixmobile.core.dao
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.verify
import io.objectbox.Box
import io.objectbox.query.Query
import io.objectbox.query.QueryBuilder
import io.objectbox.rx.RxQuery
import io.reactivex.Observable
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.test.runBlockingTest
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.kiwix.kiwixmobile.core.dao.entities.RecentSearchEntity
import org.kiwix.kiwixmobile.core.dao.entities.RecentSearchEntity_
import org.kiwix.kiwixmobile.core.data.local.entity.RecentSearch
import org.kiwix.kiwixmobile.core.search.adapter.SearchListItem.RecentSearchListItem
import org.kiwix.kiwixmobile.core.search.viewmodel.test
import org.kiwix.sharedFunctions.recentSearchEntity
internal class NewRecentSearchDaoTest {
private val box: Box<RecentSearchEntity> = mockk(relaxed = true)
private val newRecentSearchDao = NewRecentSearchDao(box)
private val flowBuilder: FlowBuilder = mockk()
private val newRecentSearchDao = NewRecentSearchDao(box, flowBuilder)
@Nested
inner class RecentSearchTests {
@Test
fun `recentSearches searches by Id passed`() {
fun `recentSearches searches by Id passed`() = runBlockingTest {
val zimId = "id"
val queryResult = listOf<RecentSearchEntity>(recentSearchEntity())
expectFromRecentSearches(queryResult, zimId)
newRecentSearchDao.recentSearches(zimId).test()
newRecentSearchDao.recentSearches(zimId)
.test(this)
.assertValues(queryResult.map { RecentSearchListItem(it.searchTerm) })
.finish()
}
@Test
fun `recentSearches searches with blank Id if null passed`() {
fun `recentSearches searches with blank Id if null passed`() = runBlockingTest {
val queryResult = listOf<RecentSearchEntity>(recentSearchEntity())
expectFromRecentSearches(queryResult, "")
newRecentSearchDao.recentSearches(null).test()
newRecentSearchDao.recentSearches(null)
.test(this)
.assertValues(queryResult.map { RecentSearchListItem(it.searchTerm) })
.finish()
}
@Test
fun `recentSearches searches returns distinct entities by searchTerm`() {
fun `recentSearches searches returns distinct entities by searchTerm`() = runBlockingTest {
val queryResult = listOf<RecentSearchEntity>(recentSearchEntity(), recentSearchEntity())
expectFromRecentSearches(queryResult, "")
newRecentSearchDao.recentSearches("").test()
newRecentSearchDao.recentSearches("")
.test(this)
.assertValues(queryResult.take(1).map { RecentSearchListItem(it.searchTerm) })
.finish()
}
@Test
fun `recentSearches searches returns a limitedNumber of entities`() {
fun `recentSearches searches returns a limitedNumber of entities`() = runBlockingTest {
val searchResults: List<RecentSearchEntity> =
(0..200).map { recentSearchEntity(searchTerm = "$it") }
expectFromRecentSearches(searchResults, "")
newRecentSearchDao.recentSearches("").test()
newRecentSearchDao.recentSearches("")
.test(this)
.assertValue { it.size == 100 }
.finish()
}
private fun expectFromRecentSearches(queryResult: List<RecentSearchEntity>, zimId: String) {
@ -83,8 +95,7 @@ internal class NewRecentSearchDaoTest {
every { queryBuilder.orderDesc(RecentSearchEntity_.id) } returns queryBuilder
val query = mockk<Query<RecentSearchEntity>>()
every { queryBuilder.build() } returns query
mockkStatic(RxQuery::class)
every { RxQuery.observable(query) } returns Observable.just(queryResult)
every { flowBuilder.buildCallbackFlow(query) } returns flowOf(queryResult)
}
}
@ -123,3 +134,13 @@ internal class NewRecentSearchDaoTest {
verify { box.put(listOf(recentSearchEntity(searchTerm = term, zimId = id))) }
}
}
private suspend fun <T> Flow<T>.assertValue(function: (T) -> Boolean) {
val value = toList().last()
val result = function(value)
assertThat(result).isTrue()
}
suspend inline fun <T> Flow<T>.assertValues(expectedValues: List<T>) {
assertThat(toList()).containsExactlyElementsOf(expectedValues)
}

View File

@ -0,0 +1,79 @@
/*
* 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.viewmodel
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import org.kiwix.kiwixmobile.core.search.adapter.SearchListItem.RecentSearchListItem
import org.kiwix.kiwixmobile.core.search.adapter.SearchListItem.ZimSearchResultListItem
import org.kiwix.kiwixmobile.core.search.viewmodel.SearchOrigin.FromWebView
internal class SearchStateTest {
@Test
internal fun `visibleResults use searchResults when searchTerm is not empty`() {
val results = listOf(ZimSearchResultListItem(""))
assertThat(
SearchState(
"notEmpty",
SearchResultsWithTerm("", results),
emptyList(),
FromWebView
).visibleResults
).isEqualTo(results)
}
@Test
internal fun `visibleResults use recentResults when searchTerm is empty`() {
val results = listOf(RecentSearchListItem(""))
assertThat(
SearchState(
"",
SearchResultsWithTerm("", emptyList()),
results,
FromWebView
).visibleResults
).isEqualTo(results)
}
@Test
internal fun `isLoading when searchTerm is not equal to ResultTerm`() {
assertThat(
SearchState(
"",
SearchResultsWithTerm("notEqual", emptyList()),
emptyList(),
FromWebView
).isLoading
).isTrue()
}
@Test
internal fun `is not Loading when searchTerm is equal to ResultTerm`() {
val searchTerm = "equal"
assertThat(
SearchState(
searchTerm,
SearchResultsWithTerm(searchTerm, emptyList()),
emptyList(),
FromWebView
).isLoading
).isFalse()
}
}

View File

@ -19,21 +19,34 @@
package org.kiwix.kiwixmobile.core.search.viewmodel
import android.content.Intent
import com.jraska.livedata.test
import io.mockk.clearAllMocks
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import io.reactivex.processors.PublishProcessor
import io.reactivex.schedulers.TestScheduler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.consumeAsFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.TestCoroutineDispatcher
import kotlinx.coroutines.test.TestCoroutineScope
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runBlockingTest
import kotlinx.coroutines.test.setMain
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.kiwix.kiwixmobile.core.R
import org.kiwix.kiwixmobile.core.R.string
import org.kiwix.kiwixmobile.core.base.SideEffect
import org.kiwix.kiwixmobile.core.dao.NewRecentSearchDao
import org.kiwix.kiwixmobile.core.reader.ZimFileReader
import org.kiwix.kiwixmobile.core.reader.ZimReaderContainer
import org.kiwix.kiwixmobile.core.search.adapter.SearchListItem.RecentSearchListItem
import org.kiwix.kiwixmobile.core.search.adapter.SearchListItem.ZimSearchResultListItem
@ -49,10 +62,7 @@ import org.kiwix.kiwixmobile.core.search.viewmodel.Action.OnOpenInNewTabClick
import org.kiwix.kiwixmobile.core.search.viewmodel.Action.ReceivedPromptForSpeechInput
import org.kiwix.kiwixmobile.core.search.viewmodel.Action.ScreenWasStartedFrom
import org.kiwix.kiwixmobile.core.search.viewmodel.Action.StartSpeechInputFailed
import org.kiwix.kiwixmobile.core.search.viewmodel.SearchOrigin.FromTabView
import org.kiwix.kiwixmobile.core.search.viewmodel.SearchOrigin.FromWebView
import org.kiwix.kiwixmobile.core.search.viewmodel.State.NoResults
import org.kiwix.kiwixmobile.core.search.viewmodel.State.Results
import org.kiwix.kiwixmobile.core.search.viewmodel.effects.DeleteRecentSearch
import org.kiwix.kiwixmobile.core.search.viewmodel.effects.FinishActivity
import org.kiwix.kiwixmobile.core.search.viewmodel.effects.OpenSearchItem
@ -63,166 +73,83 @@ import org.kiwix.kiwixmobile.core.search.viewmodel.effects.SearchIntentProcessin
import org.kiwix.kiwixmobile.core.search.viewmodel.effects.ShowDeleteSearchDialog
import org.kiwix.kiwixmobile.core.search.viewmodel.effects.ShowToast
import org.kiwix.kiwixmobile.core.search.viewmodel.effects.StartSpeechInput
import org.kiwix.sharedFunctions.InstantExecutorExtension
import org.kiwix.sharedFunctions.resetSchedulers
import org.kiwix.sharedFunctions.setScheduler
import java.util.concurrent.TimeUnit.MILLISECONDS
@ExtendWith(InstantExecutorExtension::class)
@OptIn(ExperimentalCoroutinesApi::class)
internal class SearchViewModelTest {
private val recentSearchDao: NewRecentSearchDao = mockk()
private val zimReaderContainer: ZimReaderContainer = mockk()
private val searchResultGenerator: SearchResultGenerator = mockk()
private val zimFileReader: ZimFileReader = mockk()
private val testDispatcher = TestCoroutineDispatcher()
lateinit var viewModel: SearchViewModel
private val testScheduler = TestScheduler()
init {
setScheduler(testScheduler)
}
@AfterAll
fun teardown() {
resetSchedulers()
Dispatchers.resetMain()
}
private val recentsFromDb: PublishProcessor<List<RecentSearchListItem>> =
PublishProcessor.create()
private lateinit var recentsFromDb: Channel<List<RecentSearchListItem>>
@BeforeEach
fun init() {
Dispatchers.resetMain()
Dispatchers.setMain(testDispatcher)
clearAllMocks()
recentsFromDb = Channel(kotlinx.coroutines.channels.Channel.UNLIMITED)
every { zimReaderContainer.copyReader() } returns zimFileReader
coEvery {
searchResultGenerator.generateSearchResults("", zimFileReader)
} returns emptyList()
every { zimReaderContainer.id } returns "id"
every { recentSearchDao.recentSearches("id") } returns recentsFromDb
every { recentSearchDao.recentSearches("id") } returns recentsFromDb.consumeAsFlow()
viewModel = SearchViewModel(recentSearchDao, zimReaderContainer, searchResultGenerator)
}
@Nested
inner class StateTests {
@Test
fun `initial state is Initialising`() {
viewModel.state.test().assertValue(NoResults("", FromWebView))
fun `initial state is Initialising`() = runBlockingTest {
viewModel.state.test(this).assertValue(
SearchState("", SearchResultsWithTerm("", emptyList()), emptyList(), FromWebView)
).finish()
}
@Test
fun `non empty search term with search results shows Results`() {
fun `SearchState combines sources from inputs`() = runBlockingTest {
val item = ZimSearchResultListItem("")
val searchTerm = "searchTerm"
val searchOrigin = FromWebView
emissionOf(
searchTerm = searchTerm,
searchResults = listOf(item),
databaseResults = listOf(RecentSearchListItem("")),
searchOrigin = searchOrigin
)
resultsIn(Results(searchTerm, listOf(item), searchOrigin))
}
@Test
fun `non empty search string with no search results is NoResults`() {
emissionOf(
searchTerm = "a",
searchResults = emptyList(),
databaseResults = listOf(RecentSearchListItem("")),
searchOrigin = FromWebView
)
resultsIn(NoResults("a", FromWebView))
}
@Test
fun `empty search string with database results shows Results`() {
val item = RecentSearchListItem("")
emissionOf(
searchTerm = "",
searchResults = listOf(ZimSearchResultListItem("")),
databaseResults = listOf(item),
searchOrigin = FromWebView
)
resultsIn(Results("", listOf(item), FromWebView))
}
@Test
fun `empty search string with no database results is NoResults`() {
emissionOf(
searchTerm = "",
searchResults = listOf(ZimSearchResultListItem("")),
databaseResults = emptyList(),
searchOrigin = FromWebView
)
resultsIn(NoResults("", FromWebView))
}
@Test
fun `duplicate search terms are ignored`() {
val searchString = "a"
val item = ZimSearchResultListItem("")
emissionOf(
searchTerm = searchString,
searchResults = listOf(item),
databaseResults = emptyList(),
searchOrigin = FromWebView
)
viewModel.actions.offer(Filter(searchString))
viewModel.state.test()
.also { testScheduler.advanceTimeBy(100, MILLISECONDS) }
.assertValueHistory(
Results(searchString, listOf(item), FromWebView)
viewModel.state.test(this)
.also {
emissionOf(
searchTerm = searchTerm,
searchResults = listOf(item),
databaseResults = listOf(RecentSearchListItem("")),
searchOrigin = searchOrigin
)
}
.assertValue(
SearchState(
searchTerm,
SearchResultsWithTerm(searchTerm, listOf(item)),
listOf(RecentSearchListItem("")),
searchOrigin
)
)
}
@Test
fun `only latest search term is used`() {
val item = ZimSearchResultListItem("")
emissionOf(
searchTerm = "a",
searchResults = listOf(item),
databaseResults = emptyList(),
searchOrigin = FromWebView
)
emissionOf(
searchTerm = "b",
searchResults = listOf(item),
databaseResults = emptyList(),
searchOrigin = FromWebView
)
viewModel.state.test()
.also { testScheduler.advanceTimeBy(100, MILLISECONDS) }
.assertValueHistory(Results("b", listOf(item), FromWebView))
}
@Test
fun `webView search origin leads to webView in NoResults`() {
emissionOf(
searchTerm = "",
searchResults = listOf(ZimSearchResultListItem("")),
databaseResults = emptyList(),
searchOrigin = FromWebView
)
resultsIn(NoResults("", FromWebView))
}
@Test
fun `tabView search origin leads to tabView in Results`() {
emissionOf(
searchTerm = "",
searchResults = listOf(ZimSearchResultListItem("")),
databaseResults = emptyList(),
searchOrigin = FromTabView
)
resultsIn(NoResults("", FromTabView))
.finish()
}
}
@Nested
inner class ActionMapping {
@Test
fun `ExitedSearch offers Finish`() {
fun `ExitedSearch offers Finish`() = runBlockingTest {
actionResultsInEffects(ExitedSearch, FinishActivity)
}
@Test
fun `OnItemClick offers Saves and Opens`() {
fun `OnItemClick offers Saves and Opens`() = runBlockingTest {
val searchListItem = RecentSearchListItem("")
actionResultsInEffects(
OnItemClick(searchListItem),
@ -232,7 +159,7 @@ internal class SearchViewModelTest {
}
@Test
fun `OnOpenInNewTabClick offers Saves and Opens in new tab`() {
fun `OnOpenInNewTabClick offers Saves and Opens in new tab`() = runBlockingTest {
val searchListItem = RecentSearchListItem("")
actionResultsInEffects(
OnOpenInNewTabClick(searchListItem),
@ -242,7 +169,7 @@ internal class SearchViewModelTest {
}
@Test
fun `OnItemLongClick offers Saves and Opens`() {
fun `OnItemLongClick offers Saves and Opens`() = runBlockingTest {
val searchListItem = RecentSearchListItem("")
actionResultsInEffects(
OnItemLongClick(searchListItem),
@ -251,12 +178,12 @@ internal class SearchViewModelTest {
}
@Test
fun `ClickedSearchInText offers SearchInPreviousScreen`() {
fun `ClickedSearchInText offers SearchInPreviousScreen`() = runBlockingTest {
actionResultsInEffects(ClickedSearchInText, SearchInPreviousScreen(""))
}
@Test
fun `ConfirmedDelete offers Delete and Toast`() {
fun `ConfirmedDelete offers Delete and Toast`() = runBlockingTest {
val searchListItem = RecentSearchListItem("")
actionResultsInEffects(
ConfirmedDelete(searchListItem),
@ -266,7 +193,7 @@ internal class SearchViewModelTest {
}
@Test
fun `CreatedWithIntent offers SearchIntentProcessing`() {
fun `CreatedWithIntent offers SearchIntentProcessing`() = runBlockingTest {
val intent = mockk<Intent>()
actionResultsInEffects(
CreatedWithIntent(intent),
@ -275,7 +202,7 @@ internal class SearchViewModelTest {
}
@Test
fun `ReceivedPromptForSpeechInput offers SearchIntentProcessing`() {
fun `ReceivedPromptForSpeechInput offers SearchIntentProcessing`() = runBlockingTest {
actionResultsInEffects(
ReceivedPromptForSpeechInput,
StartSpeechInput(viewModel.actions)
@ -283,7 +210,7 @@ internal class SearchViewModelTest {
}
@Test
fun `StartSpeechInputFailed offers ShowToast`() {
fun `StartSpeechInputFailed offers ShowToast`() = runBlockingTest {
actionResultsInEffects(
StartSpeechInputFailed,
ShowToast(string.speech_not_supported)
@ -291,40 +218,71 @@ internal class SearchViewModelTest {
}
@Test
fun `ActivityResultReceived offers ProcessActivityResult`() {
fun `ActivityResultReceived offers ProcessActivityResult`() = runBlockingTest {
actionResultsInEffects(
ActivityResultReceived(0, 1, null),
ProcessActivityResult(0, 1, null, viewModel.actions)
)
}
private fun actionResultsInEffects(
private fun TestCoroutineScope.actionResultsInEffects(
action: Action,
vararg effects: SideEffect<*>
) {
viewModel.effects
.test()
.test(this)
.also { viewModel.actions.offer(action) }
.assertValues(*effects)
.finish()
}
}
private fun resultsIn(st: State) {
viewModel.state.test()
.also { testScheduler.advanceTimeBy(100, MILLISECONDS) }
.assertValue(st)
}
private fun emissionOf(
private fun TestCoroutineScope.emissionOf(
searchTerm: String,
searchResults: List<ZimSearchResultListItem>,
databaseResults: List<RecentSearchListItem>,
searchOrigin: SearchOrigin
) {
every { searchResultGenerator.generateSearchResults(searchTerm) } returns searchResults
coEvery {
searchResultGenerator.generateSearchResults(searchTerm, zimFileReader)
} returns searchResults
viewModel.actions.offer(Filter(searchTerm))
recentsFromDb.offer(databaseResults)
viewModel.actions.offer(ScreenWasStartedFrom(searchOrigin))
testScheduler.advanceTimeBy(500, MILLISECONDS)
advanceTimeBy(500)
}
}
fun <T> Flow<T>.test(scope: CoroutineScope) = TestObserver(scope, this)
class TestObserver<T>(
scope: CoroutineScope,
flow: Flow<T>
) {
private val values = mutableListOf<T>()
private val job: Job = scope.launch {
flow.collect {
values.add(it)
}
}
fun assertValues(vararg values: T): TestObserver<T> {
assertThat(values.toList()).containsExactlyElementsOf(this.values)
return this
}
fun assertValue(value: T): TestObserver<T> {
assertThat(values.last()).isEqualTo(value)
return this
}
fun finish() {
job.cancel()
}
fun assertValue(value: (T) -> Boolean): TestObserver<T> {
assertThat(values.last()).satisfies { value(it) }
return this
}
}

View File

@ -21,33 +21,26 @@ package org.kiwix.kiwixmobile.core.search.viewmodel
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.runBlocking
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.kiwix.kiwixmobile.core.reader.ZimFileReader
import org.kiwix.kiwixmobile.core.reader.ZimReaderContainer
import org.kiwix.kiwixmobile.core.search.SearchSuggestion
import org.kiwix.kiwixmobile.core.search.adapter.SearchListItem.ZimSearchResultListItem
import org.kiwix.kiwixmobile.core.utils.SharedPreferenceUtil
internal class ZimSearchResultGeneratorTest {
private val sharedPreferenceUtil: SharedPreferenceUtil = mockk()
private val zimReaderContainer: ZimReaderContainer = mockk()
private val zimFileReader: ZimFileReader = mockk()
private val zimSearchResultGenerator: ZimSearchResultGenerator =
ZimSearchResultGenerator(zimReaderContainer)
@BeforeEach
internal fun setUp() {
every { zimReaderContainer.copyReader() } returns zimFileReader
}
ZimSearchResultGenerator()
@Test
internal fun `empty search term returns empty list`() {
assertThat(zimSearchResultGenerator.generateSearchResults(""))
.isEqualTo(emptyList<ZimSearchResultListItem>())
runBlocking {
assertThat(zimSearchResultGenerator.generateSearchResults("", zimFileReader))
.isEqualTo(emptyList<ZimSearchResultListItem>())
}
}
@Test
@ -58,11 +51,12 @@ internal class ZimSearchResultGeneratorTest {
every { zimFileReader.searchSuggestions(" ", 200) } returns true
every { zimFileReader.getNextSuggestion() } returnsMany listOf(item, item, null)
every { item.title } returns validTitle
assertThat(zimSearchResultGenerator.generateSearchResults(searchTerm))
.isEqualTo(listOf(ZimSearchResultListItem(validTitle)))
verify {
zimFileReader.searchSuggestions(searchTerm, 200)
zimFileReader.dispose()
runBlocking {
assertThat(zimSearchResultGenerator.generateSearchResults(searchTerm, zimFileReader))
.isEqualTo(listOf(ZimSearchResultListItem(validTitle)))
verify {
zimFileReader.searchSuggestions(searchTerm, 200)
}
}
}
}

View File

@ -27,7 +27,7 @@ import io.mockk.clearAllMocks
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import io.reactivex.processors.PublishProcessor
import kotlinx.coroutines.channels.Channel
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.kiwix.kiwixmobile.core.search.viewmodel.Action
@ -37,7 +37,7 @@ internal class ProcessActivityResultTest {
private val activity: AppCompatActivity = mockk()
private val data = mockk<Intent>()
private val actions = mockk<PublishProcessor<Action>>(relaxed = true)
private val actions = mockk<Channel<Action>>(relaxed = true)
private val successfulResult = ProcessActivityResult(
StartSpeechInput.REQ_CODE_SPEECH_INPUT,
Activity.RESULT_OK,

View File

@ -26,7 +26,7 @@ import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import io.mockk.verifySequence
import io.reactivex.processors.PublishProcessor
import kotlinx.coroutines.channels.Channel
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.kiwix.kiwixmobile.core.search.viewmodel.Action
@ -35,13 +35,13 @@ import org.kiwix.kiwixmobile.core.search.viewmodel.Action.ReceivedPromptForSpeec
import org.kiwix.kiwixmobile.core.search.viewmodel.Action.ScreenWasStartedFrom
import org.kiwix.kiwixmobile.core.search.viewmodel.SearchOrigin.FromTabView
import org.kiwix.kiwixmobile.core.search.viewmodel.SearchOrigin.FromWebView
import org.kiwix.kiwixmobile.core.utils.TAG_FROM_TAB_SWITCHER
import org.kiwix.kiwixmobile.core.utils.EXTRA_SEARCH
import org.kiwix.kiwixmobile.core.utils.EXTRA_IS_WIDGET_VOICE
import org.kiwix.kiwixmobile.core.utils.EXTRA_SEARCH
import org.kiwix.kiwixmobile.core.utils.TAG_FROM_TAB_SWITCHER
internal class SearchIntentProcessingTest {
private val actions: PublishProcessor<Action> = mockk(relaxed = true)
private val actions: Channel<Action> = mockk(relaxed = true)
private val activity: AppCompatActivity = mockk()

View File

@ -22,7 +22,7 @@ import io.mockk.every
import io.mockk.mockk
import io.mockk.slot
import io.mockk.verify
import io.reactivex.processors.PublishProcessor
import kotlinx.coroutines.channels.Channel
import org.junit.jupiter.api.Test
import org.kiwix.kiwixmobile.core.search.SearchActivity
import org.kiwix.kiwixmobile.core.search.adapter.SearchListItem.RecentSearchListItem
@ -35,7 +35,7 @@ internal class ShowDeleteSearchDialogTest {
@Test
fun `invoke with shows dialog that offers ConfirmedDelete action`() {
val actions = mockk<PublishProcessor<Action>>(relaxed = true)
val actions = mockk<Channel<Action>>(relaxed = true)
val searchListItem = RecentSearchListItem("")
val activity = mockk<SearchActivity>()
val showDeleteSearchDialog = ShowDeleteSearchDialog(searchListItem, actions)

View File

@ -26,7 +26,7 @@ import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkConstructor
import io.mockk.verify
import io.reactivex.processors.PublishProcessor
import kotlinx.coroutines.channels.Channel
import org.junit.jupiter.api.Test
import org.kiwix.kiwixmobile.core.R
import org.kiwix.kiwixmobile.core.search.viewmodel.Action
@ -35,7 +35,7 @@ import java.util.Locale
internal class StartSpeechInputTest {
private val actions = mockk<PublishProcessor<Action>>(relaxed = true)
private val actions = mockk<Channel<Action>>(relaxed = true)
@Test
fun `when invoke with throws exception offer StartSpeechInputFailed action`() {