Merge pull request #4328 from kiwix/Fixes#4302

Refactored `RxJava` to `coroutines` in `OnlineLibrary`.
This commit is contained in:
Kelson 2025-05-23 17:01:19 +02:00 committed by GitHub
commit 10494cccf9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 662 additions and 662 deletions

View File

@ -26,7 +26,7 @@ import org.kiwix.kiwixmobile.core.base.SideEffect
import org.kiwix.kiwixmobile.core.dao.NewLanguagesDao import org.kiwix.kiwixmobile.core.dao.NewLanguagesDao
import org.kiwix.kiwixmobile.core.zim_manager.Language import org.kiwix.kiwixmobile.core.zim_manager.Language
@Suppress("InjectDispatcher", "TooGenericExceptionCaught") @Suppress("InjectDispatcher")
data class SaveLanguagesAndFinish( data class SaveLanguagesAndFinish(
private val languages: List<Language>, private val languages: List<Language>,
private val languageDao: NewLanguagesDao, private val languageDao: NewLanguagesDao,
@ -34,13 +34,13 @@ data class SaveLanguagesAndFinish(
) : SideEffect<Unit> { ) : SideEffect<Unit> {
override fun invokeWith(activity: AppCompatActivity) { override fun invokeWith(activity: AppCompatActivity) {
lifecycleScope.launch { lifecycleScope.launch {
try { runCatching {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
languageDao.insert(languages) languageDao.insert(languages)
} }
activity.onBackPressedDispatcher.onBackPressed() activity.onBackPressedDispatcher.onBackPressed()
} catch (e: Throwable) { }.onFailure {
e.printStackTrace() it.printStackTrace()
} }
} }
} }

View File

@ -520,7 +520,6 @@ class LocalLibraryFragment : BaseFragment(), CopyMoveFileHandler.FileCopyMoveCal
override fun onDestroyView() { override fun onDestroyView() {
super.onDestroyView() super.onDestroyView()
mainRepositoryActions.dispose()
actionMode = null actionMode = null
coroutineJobs.forEach { coroutineJobs.forEach {
it.cancel() it.cancel()

View File

@ -47,6 +47,8 @@ import androidx.lifecycle.lifecycleScope
import com.google.android.material.bottomnavigation.BottomNavigationView import com.google.android.material.bottomnavigation.BottomNavigationView
import com.tonyodev.fetch2.Status import com.tonyodev.fetch2.Status
import eu.mhutti1.utils.storage.StorageDevice import eu.mhutti1.utils.storage.StorageDevice
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.kiwix.kiwixmobile.R.drawable import org.kiwix.kiwixmobile.R.drawable
import org.kiwix.kiwixmobile.cachedComponent import org.kiwix.kiwixmobile.cachedComponent
@ -123,13 +125,13 @@ class OnlineLibraryFragment : BaseFragment(), FragmentActivityExtensions {
OnlineLibraryScreenState( OnlineLibraryScreenState(
onlineLibraryList = null, onlineLibraryList = null,
snackBarHostState = SnackbarHostState(), snackBarHostState = SnackbarHostState(),
swipeRefreshItem = Pair(false, true), isRefreshing = false,
scanningProgressItem = Pair(false, ""), scanningProgressItem = Pair(false, ""),
noContentViewItem = Pair("", false), noContentViewItem = Pair("", false),
bottomNavigationHeight = ZERO, bottomNavigationHeight = ZERO,
onBookItemClick = { onBookItemClick(it) }, onBookItemClick = { onBookItemClick(it) },
availableSpaceCalculator = availableSpaceCalculator, availableSpaceCalculator = availableSpaceCalculator,
onRefresh = { refreshFragment() }, onRefresh = { refreshFragment(true) },
bookUtils = bookUtils, bookUtils = bookUtils,
onPauseResumeButtonClick = { onPauseResumeButtonClick(it) }, onPauseResumeButtonClick = { onPauseResumeButtonClick(it) },
onStopButtonClick = { onStopButtonClick(it) }, onStopButtonClick = { onStopButtonClick(it) },
@ -146,7 +148,7 @@ class OnlineLibraryFragment : BaseFragment(), FragmentActivityExtensions {
copy(searchText = "") copy(searchText = "")
} }
zimManageViewModel.onlineBooksSearchedQuery.value = null zimManageViewModel.onlineBooksSearchedQuery.value = null
zimManageViewModel.requestFiltering.onNext("") zimManageViewModel.requestFiltering.tryEmit("")
} }
private fun onSearchValueChanged(searchText: String) { private fun onSearchValueChanged(searchText: String) {
@ -159,7 +161,7 @@ class OnlineLibraryFragment : BaseFragment(), FragmentActivityExtensions {
onlineLibraryScreenState.value.update { onlineLibraryScreenState.value.update {
copy(searchText = searchText) copy(searchText = searchText)
} }
zimManageViewModel.requestFiltering.onNext(searchText) zimManageViewModel.requestFiltering.tryEmit(searchText)
} }
private val noWifiWithWifiOnlyPreferenceSet private val noWifiWithWifiOnlyPreferenceSet
@ -246,17 +248,39 @@ class OnlineLibraryFragment : BaseFragment(), FragmentActivityExtensions {
) )
DialogHost(alertDialogShower) DialogHost(alertDialogShower)
} }
zimManageViewModel.libraryItems.observe(viewLifecycleOwner, Observer(::onLibraryItemsChange)) observeViewModelData()
showPreviouslySearchedTextInSearchView()
startDownloadingLibrary()
}
private fun observeViewModelData() {
zimManageViewModel.apply {
// Observe when library items changes.
libraryItems
.onEach { onLibraryItemsChange(it) }
.launchIn(viewLifecycleOwner.lifecycleScope)
.also { .also {
coreMainActivity.navHostContainer coreMainActivity.navHostContainer
.setBottomMarginToFragmentContainerView(0) .setBottomMarginToFragmentContainerView(0)
} }
zimManageViewModel.libraryListIsRefreshing.observe( // Observe when online library downloading.
onlineLibraryDownloading
.onEach {
if (it) {
showProgressBarOfFetchingOnlineLibrary()
} else {
hideProgressBarOfFetchingOnlineLibrary()
}
}.launchIn(viewLifecycleOwner.lifecycleScope)
// Observe when library list refreshing e.g. applying filters.
libraryListIsRefreshing.observe(
viewLifecycleOwner, viewLifecycleOwner,
Observer { onRefreshStateChange(it, true) } Observer { onRefreshStateChange(it) }
) )
zimManageViewModel.networkStates.observe(viewLifecycleOwner, Observer(::onNetworkStateChange)) // Observe network changes.
zimManageViewModel.shouldShowWifiOnlyDialog.observe( networkStates.observe(viewLifecycleOwner, Observer(::onNetworkStateChange))
// Observe `shouldShowWifiOnlyDialog` should show.
shouldShowWifiOnlyDialog.observe(
viewLifecycleOwner viewLifecycleOwner
) { ) {
if (it && !NetworkUtils.isWiFi(requireContext())) { if (it && !NetworkUtils.isWiFi(requireContext())) {
@ -264,8 +288,9 @@ class OnlineLibraryFragment : BaseFragment(), FragmentActivityExtensions {
hideProgressBarOfFetchingOnlineLibrary() hideProgressBarOfFetchingOnlineLibrary()
} }
} }
zimManageViewModel.downloadProgress.observe(viewLifecycleOwner, ::onLibraryStatusChanged) // Observe the download progress.
showPreviouslySearchedTextInSearchView() downloadProgress.observe(viewLifecycleOwner, ::onLibraryStatusChanged)
}
} }
private fun showPreviouslySearchedTextInSearchView() { private fun showPreviouslySearchedTextInSearchView() {
@ -275,11 +300,11 @@ class OnlineLibraryFragment : BaseFragment(), FragmentActivityExtensions {
onlineLibraryScreenState.value.update { onlineLibraryScreenState.value.update {
copy(isSearchActive = true, searchText = it) copy(isSearchActive = true, searchText = it)
} }
zimManageViewModel.requestFiltering.onNext(it) zimManageViewModel.requestFiltering.tryEmit(it)
} ?: run { } ?: run {
// If no previously saved query found then normally initiate the search. // If no previously saved query found then normally initiate the search.
zimManageViewModel.onlineBooksSearchedQuery.value = "" zimManageViewModel.onlineBooksSearchedQuery.value = ""
zimManageViewModel.requestFiltering.onNext("") zimManageViewModel.requestFiltering.tryEmit("")
} }
} }
@ -341,6 +366,10 @@ class OnlineLibraryFragment : BaseFragment(), FragmentActivityExtensions {
showRecyclerviewAndHideSwipeDownForLibraryErrorText() showRecyclerviewAndHideSwipeDownForLibraryErrorText()
sharedPreferenceUtil.putPrefWifiOnly(false) sharedPreferenceUtil.putPrefWifiOnly(false)
zimManageViewModel.shouldShowWifiOnlyDialog.value = false zimManageViewModel.shouldShowWifiOnlyDialog.value = false
// User allowed downloading over mobile data.
// Since the download flow now triggers only when appropriate,
// we start the library download explicitly after updating the preference.
startDownloadingLibrary(true)
}, },
{ {
context.toast( context.toast(
@ -356,32 +385,28 @@ class OnlineLibraryFragment : BaseFragment(), FragmentActivityExtensions {
onlineLibraryScreenState.value.update { onlineLibraryScreenState.value.update {
copy(noContentViewItem = "" to false) copy(noContentViewItem = "" to false)
} }
showProgressBarOfFetchingOnlineLibrary()
} }
private fun hideRecyclerviewAndShowSwipeDownForLibraryErrorText() { private fun hideRecyclerviewAndShowSwipeDownForLibraryErrorText() {
onlineLibraryScreenState.value.update { onlineLibraryScreenState.value.update {
copy(noContentViewItem = getString(string.swipe_down_for_library) to true) copy(noContentViewItem = getString(string.swipe_down_for_library) to true)
} }
hideProgressBarOfFetchingOnlineLibrary()
} }
private fun showProgressBarOfFetchingOnlineLibrary() { private fun showProgressBarOfFetchingOnlineLibrary() {
onRefreshStateChange(isRefreshing = false, shouldShowScanningProgressItem = false)
onlineLibraryScreenState.value.update { onlineLibraryScreenState.value.update {
copy( copy(
noContentViewItem = "" to false, noContentViewItem = "" to false,
swipeRefreshItem = onlineLibraryScreenState.value.value.swipeRefreshItem.first to false, isRefreshing = false,
scanningProgressItem = true to getString(string.reaching_remote_library) scanningProgressItem = true to getString(string.reaching_remote_library)
) )
} }
} }
private fun hideProgressBarOfFetchingOnlineLibrary() { private fun hideProgressBarOfFetchingOnlineLibrary() {
onRefreshStateChange(isRefreshing = false, false)
onlineLibraryScreenState.value.update { onlineLibraryScreenState.value.update {
copy( copy(
swipeRefreshItem = onlineLibraryScreenState.value.value.swipeRefreshItem.first to true, isRefreshing = false,
scanningProgressItem = false to getString(string.reaching_remote_library) scanningProgressItem = false to getString(string.reaching_remote_library)
) )
} }
@ -391,7 +416,7 @@ class OnlineLibraryFragment : BaseFragment(), FragmentActivityExtensions {
synchronized(lock) { synchronized(lock) {
onlineLibraryScreenState.value.update { onlineLibraryScreenState.value.update {
copy( copy(
scanningProgressItem = onlineLibraryScreenState.value.value.scanningProgressItem.first to libraryStatus scanningProgressItem = true to libraryStatus
) )
} }
} }
@ -419,35 +444,30 @@ class OnlineLibraryFragment : BaseFragment(), FragmentActivityExtensions {
onSearchClear() onSearchClear()
} }
private fun onRefreshStateChange( private fun onRefreshStateChange(isRefreshing: Boolean?) {
isRefreshing: Boolean?, val refreshing = isRefreshing == true
shouldShowScanningProgressItem: Boolean
) {
var refreshing = isRefreshing == true
val onlineLibraryState = onlineLibraryScreenState.value.value
// do not show the refreshing when the online library is downloading
if (onlineLibraryState.scanningProgressItem.first ||
onlineLibraryState.noContentViewItem.second
) {
refreshing = false
}
onlineLibraryScreenState.value.update { onlineLibraryScreenState.value.update {
copy( copy(isRefreshing = refreshing)
swipeRefreshItem = refreshing to onlineLibraryState.swipeRefreshItem.second,
scanningProgressItem = shouldShowScanningProgressItem to onlineLibraryState.scanningProgressItem.second
)
} }
} }
private fun onNetworkStateChange(networkState: NetworkState?) { private fun onNetworkStateChange(networkState: NetworkState?) {
when (networkState) { when (networkState) {
NetworkState.CONNECTED -> { NetworkState.CONNECTED -> {
if (NetworkUtils.isWiFi(requireContext())) { when {
refreshFragment() NetworkUtils.isWiFi(requireContext()) -> {
} else if (noWifiWithWifiOnlyPreferenceSet) { if (!zimManageViewModel.isOnlineLibraryDownloading) {
refreshFragment(false)
}
}
noWifiWithWifiOnlyPreferenceSet -> {
hideRecyclerviewAndShowSwipeDownForLibraryErrorText() hideRecyclerviewAndShowSwipeDownForLibraryErrorText()
} else if (!noWifiWithWifiOnlyPreferenceSet) { }
if (onlineLibraryScreenState.value.value.onlineLibraryList?.isEmpty() == true) {
onlineLibraryScreenState.value.value.onlineLibraryList.isNullOrEmpty() &&
!zimManageViewModel.isOnlineLibraryDownloading -> {
startDownloadingLibrary(true)
showProgressBarOfFetchingOnlineLibrary() showProgressBarOfFetchingOnlineLibrary()
} }
} }
@ -501,14 +521,20 @@ class OnlineLibraryFragment : BaseFragment(), FragmentActivityExtensions {
hideProgressBarOfFetchingOnlineLibrary() hideProgressBarOfFetchingOnlineLibrary()
} }
private fun refreshFragment() { private fun refreshFragment(isExplicitRefresh: Boolean) {
if (isNotConnected) { if (isNotConnected) {
showNoInternetConnectionError() showNoInternetConnectionError()
} else { } else {
zimManageViewModel.requestDownloadLibrary.onNext(Unit) startDownloadingLibrary(isExplicitRefresh)
if (isExplicitRefresh) {
showRecyclerviewAndHideSwipeDownForLibraryErrorText() showRecyclerviewAndHideSwipeDownForLibraryErrorText()
} }
} }
}
private fun startDownloadingLibrary(isExplicitRefresh: Boolean = false) {
zimManageViewModel.requestOnlineLibraryIfNeeded(isExplicitRefresh)
}
private fun downloadFile() { private fun downloadFile() {
downloadBookItem?.book?.let { downloadBookItem?.book?.let {

View File

@ -19,6 +19,7 @@
package org.kiwix.kiwixmobile.nav.destination.library.online package org.kiwix.kiwixmobile.nav.destination.library.online
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.calculateEndPadding import androidx.compose.foundation.layout.calculateEndPadding
@ -31,6 +32,8 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@ -109,8 +112,8 @@ fun OnlineLibraryScreen(
.padding(bottom = bottomNavHeight.value) .padding(bottom = bottomNavHeight.value)
) { paddingValues -> ) { paddingValues ->
SwipeRefreshLayout( SwipeRefreshLayout(
isRefreshing = state.swipeRefreshItem.first, isRefreshing = state.isRefreshing && !state.scanningProgressItem.first,
isEnabled = state.swipeRefreshItem.second, isEnabled = !state.scanningProgressItem.first,
onRefresh = state.onRefresh, onRefresh = state.onRefresh,
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@ -212,6 +215,13 @@ private fun ShowDividerItem(dividerItem: DividerItem) {
@Composable @Composable
private fun NoContentView(noContentMessage: String) { private fun NoContentView(noContentMessage: String) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text( Text(
text = noContentMessage, text = noContentMessage,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
@ -219,6 +229,7 @@ private fun NoContentView(noContentMessage: String) {
.padding(horizontal = FOUR_DP) .padding(horizontal = FOUR_DP)
.semantics { testTag = NO_CONTENT_VIEW_TEXT_TESTING_TAG } .semantics { testTag = NO_CONTENT_VIEW_TEXT_TESTING_TAG }
) )
}
} }
@Composable @Composable

View File

@ -37,11 +37,9 @@ data class OnlineLibraryScreenState(
/** /**
* Controls the visibility and behavior of the "Pull to refresh" animation. * Controls the visibility and behavior of the "Pull to refresh" animation.
* *
* A [Pair] containing:
* - [Boolean]: The first boolean triggers/hides the "pull to refresh" animation. * - [Boolean]: The first boolean triggers/hides the "pull to refresh" animation.
* - [Boolean]: The second boolean enables/disables the "pull to refresh" gesture.
*/ */
val swipeRefreshItem: Pair<Boolean, Boolean>, val isRefreshing: Boolean,
/** /**
* Handles snack bar messages and displays. * Handles snack bar messages and displays.
*/ */

View File

@ -24,32 +24,37 @@ import androidx.annotation.VisibleForTesting
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import io.reactivex.Flowable
import io.reactivex.disposables.CompositeDisposable
import io.reactivex.disposables.Disposable
import io.reactivex.exceptions.UndeliverableException
import io.reactivex.functions.BiFunction
import io.reactivex.functions.Function6
import io.reactivex.plugins.RxJavaPlugins
import io.reactivex.processors.BehaviorProcessor
import io.reactivex.processors.PublishProcessor
import io.reactivex.schedulers.Schedulers
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapConcat
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.retry
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.rx3.asFlowable import kotlinx.coroutines.reactive.asFlow
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.logging.HttpLoggingInterceptor import okhttp3.logging.HttpLoggingInterceptor
@ -75,11 +80,15 @@ import org.kiwix.kiwixmobile.core.di.modules.KIWIX_DOWNLOAD_URL
import org.kiwix.kiwixmobile.core.di.modules.READ_TIMEOUT import org.kiwix.kiwixmobile.core.di.modules.READ_TIMEOUT
import org.kiwix.kiwixmobile.core.di.modules.USER_AGENT import org.kiwix.kiwixmobile.core.di.modules.USER_AGENT
import org.kiwix.kiwixmobile.core.downloader.downloadManager.DEFAULT_INT_VALUE import org.kiwix.kiwixmobile.core.downloader.downloadManager.DEFAULT_INT_VALUE
import org.kiwix.kiwixmobile.core.downloader.downloadManager.FIVE
import org.kiwix.kiwixmobile.core.downloader.downloadManager.ZERO
import org.kiwix.kiwixmobile.core.downloader.model.DownloadModel import org.kiwix.kiwixmobile.core.downloader.model.DownloadModel
import org.kiwix.kiwixmobile.core.entity.LibraryNetworkEntity import org.kiwix.kiwixmobile.core.entity.LibraryNetworkEntity
import org.kiwix.kiwixmobile.core.entity.LibraryNetworkEntity.Book import org.kiwix.kiwixmobile.core.entity.LibraryNetworkEntity.Book
import org.kiwix.kiwixmobile.core.extensions.calculateSearchMatches import org.kiwix.kiwixmobile.core.extensions.calculateSearchMatches
import org.kiwix.kiwixmobile.core.extensions.registerReceiver import org.kiwix.kiwixmobile.core.extensions.registerReceiver
import org.kiwix.kiwixmobile.core.ui.components.ONE
import org.kiwix.kiwixmobile.core.ui.components.TWO
import org.kiwix.kiwixmobile.core.utils.BookUtils import org.kiwix.kiwixmobile.core.utils.BookUtils
import org.kiwix.kiwixmobile.core.utils.SharedPreferenceUtil import org.kiwix.kiwixmobile.core.utils.SharedPreferenceUtil
import org.kiwix.kiwixmobile.core.utils.dialog.AlertDialogShower import org.kiwix.kiwixmobile.core.utils.dialog.AlertDialogShower
@ -113,16 +122,16 @@ import org.kiwix.kiwixmobile.zimManager.libraryView.adapter.LibraryListItem
import org.kiwix.kiwixmobile.zimManager.libraryView.adapter.LibraryListItem.BookItem import org.kiwix.kiwixmobile.zimManager.libraryView.adapter.LibraryListItem.BookItem
import org.kiwix.kiwixmobile.zimManager.libraryView.adapter.LibraryListItem.DividerItem import org.kiwix.kiwixmobile.zimManager.libraryView.adapter.LibraryListItem.DividerItem
import org.kiwix.kiwixmobile.zimManager.libraryView.adapter.LibraryListItem.LibraryDownloadItem import org.kiwix.kiwixmobile.zimManager.libraryView.adapter.LibraryListItem.LibraryDownloadItem
import java.io.IOException
import java.util.LinkedList import java.util.LinkedList
import java.util.Locale import java.util.Locale
import java.util.concurrent.TimeUnit.MILLISECONDS
import java.util.concurrent.TimeUnit.SECONDS import java.util.concurrent.TimeUnit.SECONDS
import javax.inject.Inject import javax.inject.Inject
const val DEFAULT_PROGRESS = 0 const val DEFAULT_PROGRESS = 0
const val MAX_PROGRESS = 100 const val MAX_PROGRESS = 100
private const val TAG_RX_JAVA_DEFAULT_ERROR_HANDLER = "RxJavaDefaultErrorHandler"
const val THREE = 3
const val FOUR = 4
class ZimManageViewModel @Inject constructor( class ZimManageViewModel @Inject constructor(
private val downloadDao: DownloadRoomDao, private val downloadDao: DownloadRoomDao,
@ -152,31 +161,45 @@ class ZimManageViewModel @Inject constructor(
private var isUnitTestCase: Boolean = false private var isUnitTestCase: Boolean = false
val sideEffects: MutableSharedFlow<SideEffect<*>> = MutableSharedFlow() val sideEffects: MutableSharedFlow<SideEffect<*>> = MutableSharedFlow()
val libraryItems: MutableLiveData<List<LibraryListItem>> = MutableLiveData() private val _libraryItems = MutableStateFlow<List<LibraryListItem>>(emptyList())
val libraryItems: StateFlow<List<LibraryListItem>> = _libraryItems.asStateFlow()
val fileSelectListStates: MutableLiveData<FileSelectListState> = MutableLiveData() val fileSelectListStates: MutableLiveData<FileSelectListState> = MutableLiveData()
val deviceListScanningProgress = MutableLiveData<Int>() val deviceListScanningProgress = MutableLiveData<Int>()
val libraryListIsRefreshing = MutableLiveData<Boolean>() val libraryListIsRefreshing = MutableLiveData<Boolean>()
val onlineLibraryDownloading = MutableStateFlow(false)
val shouldShowWifiOnlyDialog = MutableLiveData<Boolean>() val shouldShowWifiOnlyDialog = MutableLiveData<Boolean>()
val networkStates = MutableLiveData<NetworkState>() val networkStates = MutableLiveData<NetworkState>()
val networkLibrary = MutableSharedFlow<LibraryNetworkEntity>(replay = 0)
val requestFileSystemCheck = MutableSharedFlow<Unit>(replay = 0) val requestFileSystemCheck = MutableSharedFlow<Unit>(replay = 0)
val fileSelectActions = MutableSharedFlow<FileSelectActions>() val fileSelectActions = MutableSharedFlow<FileSelectActions>()
val requestDownloadLibrary = BehaviorProcessor.createDefault(Unit) private val requestDownloadLibrary = MutableSharedFlow<Unit>(
val requestFiltering = BehaviorProcessor.createDefault("") replay = 0,
val onlineBooksSearchedQuery = MutableLiveData<String>() extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
private var compositeDisposable: CompositeDisposable? = CompositeDisposable() @Volatile
var isOnlineLibraryDownloading = false
val requestFiltering = MutableStateFlow("")
val onlineBooksSearchedQuery = MutableLiveData<String>()
private val coroutineJobs: MutableList<Job> = mutableListOf() private val coroutineJobs: MutableList<Job> = mutableListOf()
val downloadProgress = MutableLiveData<String>() val downloadProgress = MutableLiveData<String>()
private lateinit var alertDialogShower: AlertDialogShower private lateinit var alertDialogShower: AlertDialogShower
init { init {
compositeDisposable?.addAll(*disposables())
observeCoroutineFlows() observeCoroutineFlows()
context.registerReceiver(connectivityBroadcastReceiver) context.registerReceiver(connectivityBroadcastReceiver)
} }
fun requestOnlineLibraryIfNeeded(isExplicitRefresh: Boolean) {
if (isOnlineLibraryDownloading && !isExplicitRefresh) return
isOnlineLibraryDownloading = true
viewModelScope.launch {
requestDownloadLibrary.tryEmit(Unit)
}
}
fun setIsUnitTestCase() { fun setIsUnitTestCase() {
isUnitTestCase = true isUnitTestCase = true
} }
@ -259,10 +282,17 @@ class ZimManageViewModel @Inject constructor(
} }
private fun observeCoroutineFlows(dispatcher: CoroutineDispatcher = Dispatchers.IO) { private fun observeCoroutineFlows(dispatcher: CoroutineDispatcher = Dispatchers.IO) {
val downloads = downloadDao.downloads()
val booksFromDao = books()
val languages = languageDao.languages()
coroutineJobs.apply { coroutineJobs.apply {
add(scanBooksFromStorage(dispatcher)) add(scanBooksFromStorage(dispatcher))
add(updateBookItems()) add(updateBookItems())
add(fileSelectActions()) add(fileSelectActions())
add(updateLibraryItems(booksFromDao, downloads, networkLibrary, languages))
add(updateLanguagesInDao(networkLibrary, languages))
add(updateNetworkStates())
add(requestsAndConnectivityChangesToLibraryRequests(networkLibrary))
} }
} }
@ -271,49 +301,23 @@ class ZimManageViewModel @Inject constructor(
it.cancel() it.cancel()
} }
coroutineJobs.clear() coroutineJobs.clear()
compositeDisposable?.clear()
context.unregisterReceiver(connectivityBroadcastReceiver) context.unregisterReceiver(connectivityBroadcastReceiver)
connectivityBroadcastReceiver.stopNetworkState() connectivityBroadcastReceiver.stopNetworkState()
requestDownloadLibrary.onComplete()
compositeDisposable = null
appProgressListener = null appProgressListener = null
super.onCleared() super.onCleared()
} }
private fun disposables(): Array<Disposable> {
// temporary converting to flowable. TODO we will refactor this in upcoming issue.
val downloads = downloadDao.downloads().asFlowable()
val booksFromDao = books().asFlowable()
val networkLibrary = PublishProcessor.create<LibraryNetworkEntity>()
val languages = languageDao.languages().asFlowable()
return arrayOf(
updateLibraryItems(booksFromDao, downloads, networkLibrary, languages),
updateLanguagesInDao(networkLibrary, languages),
updateNetworkStates(),
requestsAndConnectivtyChangesToLibraryRequests(networkLibrary)
).also {
setUpUncaughtErrorHandlerForOnlineLibrary(networkLibrary)
}
}
private fun scanBooksFromStorage(dispatcher: CoroutineDispatcher = Dispatchers.IO) = private fun scanBooksFromStorage(dispatcher: CoroutineDispatcher = Dispatchers.IO) =
viewModelScope.launch { checkFileSystemForBooksOnRequest(books())
withContext(dispatcher) {
books()
.let { checkFileSystemForBooksOnRequest(it) }
.catch { it.printStackTrace() } .catch { it.printStackTrace() }
.collect { books -> .onEach { books -> bookDao.insert(books) }
bookDao.insert(books) .flowOn(dispatcher)
} .launchIn(viewModelScope)
}
}
@Suppress("TooGenericExceptionCaught")
private fun fileSelectActions() = private fun fileSelectActions() =
viewModelScope.launch {
fileSelectActions fileSelectActions
.collect { action -> .onEach { action ->
try { runCatching {
sideEffects.emit( sideEffects.emit(
when (action) { when (action) {
is RequestNavigateTo -> OpenFileWithNavigation(action.bookOnDisk) is RequestNavigateTo -> OpenFileWithNavigation(action.bookOnDisk)
@ -326,11 +330,10 @@ class ZimManageViewModel @Inject constructor(
UserClickedDownloadBooksButton -> NavigateToDownloads UserClickedDownloadBooksButton -> NavigateToDownloads
} }
) )
} catch (e: Throwable) { }.onFailure {
e.printStackTrace() it.printStackTrace()
}
}
} }
}.launchIn(viewModelScope)
private fun startMultiSelectionAndSelectBook( private fun startMultiSelectionAndSelectBook(
bookOnDisk: BookOnDisk bookOnDisk: BookOnDisk
@ -385,117 +388,144 @@ class ZimManageViewModel @Inject constructor(
return None return None
} }
@Suppress("NoNameShadowing") @OptIn(ExperimentalCoroutinesApi::class)
private fun requestsAndConnectivtyChangesToLibraryRequests( private fun requestsAndConnectivityChangesToLibraryRequests(
library: PublishProcessor<LibraryNetworkEntity>, library: MutableSharedFlow<LibraryNetworkEntity>,
) = dispatcher: CoroutineDispatcher = Dispatchers.IO
Flowable.combineLatest( ) = requestDownloadLibrary.flatMapConcat {
requestDownloadLibrary, connectivityBroadcastReceiver.networkStates.asFlow()
connectivityBroadcastReceiver.networkStates.distinctUntilChanged().filter( .distinctUntilChanged()
CONNECTED::equals .filter { networkState -> networkState == CONNECTED }
) .take(1)
) { _, _ -> } .flatMapConcat {
.switchMap { shouldProceedWithDownload()
if (connectivityManager.isWifi()) { .flatMapConcat { kiwixService ->
Flowable.just(Unit) downloadLibraryFlow(kiwixService).also {
onlineLibraryDownloading.tryEmit(true)
}
}
}
}
.filterNotNull()
.catch {
it.printStackTrace().also {
isOnlineLibraryDownloading = false
onlineLibraryDownloading.tryEmit(false)
}
}
.onEach {
library.emit(it).also {
// Setting this to true because once library downloaded we don't need to download again
// until user wants to refresh the online library.
isOnlineLibraryDownloading = true
onlineLibraryDownloading.tryEmit(false)
}
}
.flowOn(dispatcher)
.launchIn(viewModelScope)
private fun shouldProceedWithDownload(): Flow<KiwixService> {
return if (connectivityManager.isWifi()) {
flowOf(createKiwixServiceWithProgressListener())
} else { } else {
sharedPreferenceUtil.prefWifiOnlys flow {
.asFlowable() val wifiOnly = sharedPreferenceUtil.prefWifiOnlys.first()
.doOnNext { if (wifiOnly) {
if (it) {
shouldShowWifiOnlyDialog.postValue(true) shouldShowWifiOnlyDialog.postValue(true)
// Don't emit anything — just return
return@flow
}
emit(createKiwixServiceWithProgressListener())
} }
} }
.filter { !it }
.map { }
} }
private fun downloadLibraryFlow(
kiwixService: KiwixService
): Flow<LibraryNetworkEntity?> = flow {
downloadProgress.postValue(context.getString(R.string.starting_downloading_remote_library))
val response = kiwixService.getLibrary()
downloadProgress.postValue(context.getString(R.string.parsing_remote_library))
emit(response)
} }
.subscribeOn(Schedulers.io())
.observeOn(Schedulers.io())
.concatMap {
Flowable.fromCallable {
synchronized(this, ::createKiwixServiceWithProgressListener)
}
}
.concatMap {
kiwixService.library
.toFlowable()
.retry(5) .retry(5)
.doOnSubscribe { .catch { e ->
downloadProgress.postValue( e.printStackTrace()
context.getString(R.string.starting_downloading_remote_library) emit(LibraryNetworkEntity().apply { book = LinkedList() })
)
}
.map { response ->
downloadProgress.postValue(context.getString(R.string.parsing_remote_library))
response
}
.doFinally {
downloadProgress.postValue(context.getString(R.string.parsing_remote_library))
}
.onErrorReturn {
it.printStackTrace()
LibraryNetworkEntity().apply { book = LinkedList() }
}
}
.subscribe(library::onNext, Throwable::printStackTrace).also {
compositeDisposable?.add(it)
} }
private fun updateNetworkStates() = private fun updateNetworkStates() = connectivityBroadcastReceiver.networkStates
connectivityBroadcastReceiver.networkStates.subscribe( .asFlow()
networkStates::postValue, .catch { it.printStackTrace() }
Throwable::printStackTrace .onEach { state -> networkStates.postValue(state) }
) .launchIn(viewModelScope)
@Suppress("UNCHECKED_CAST")
@OptIn(FlowPreview::class)
private fun updateLibraryItems( private fun updateLibraryItems(
booksFromDao: io.reactivex.rxjava3.core.Flowable<List<BookOnDisk>>, booksFromDao: Flow<List<BookOnDisk>>,
downloads: io.reactivex.rxjava3.core.Flowable<List<DownloadModel>>, downloads: Flow<List<DownloadModel>>,
library: Flowable<LibraryNetworkEntity>, library: MutableSharedFlow<LibraryNetworkEntity>,
languages: io.reactivex.rxjava3.core.Flowable<List<Language>> languages: Flow<List<Language>>,
) = Flowable.combineLatest( dispatcher: CoroutineDispatcher = Dispatchers.IO
) = viewModelScope.launch(dispatcher) {
val requestFilteringFlow = merge(
flowOf(""),
requestFiltering
.onEach { libraryListIsRefreshing.postValue(true) }
.debounce(500)
.flowOn(dispatcher)
)
combine(
booksFromDao, booksFromDao,
downloads, downloads,
languages.filter(List<Language>::isNotEmpty), languages.filter { it.isNotEmpty() },
library, library,
Flowable.merge( requestFilteringFlow,
Flowable.just(""), fat32Checker.fileSystemStates
requestFiltering ) { args ->
.doOnNext { libraryListIsRefreshing.postValue(true) } val books = args[ZERO] as List<BookOnDisk>
.debounce(500, MILLISECONDS) val activeDownloads = args[ONE] as List<DownloadModel>
.observeOn(Schedulers.io()) val languageList = args[TWO] as List<Language>
), val libraryNetworkEntity = args[THREE] as LibraryNetworkEntity
fat32Checker.fileSystemStates.asFlowable(), val filter = args[FOUR] as String
Function6(::combineLibrarySources) val fileSystemState = args[FIVE] as FileSystemState
combineLibrarySources(
booksOnFileSystem = books,
activeDownloads = activeDownloads,
allLanguages = languageList,
libraryNetworkEntity = libraryNetworkEntity,
filter = filter,
fileSystemState = fileSystemState
) )
.doOnNext { libraryListIsRefreshing.postValue(false) }
.doOnError { throwable ->
if (throwable is OutOfMemoryError) {
Log.e("ZimManageViewModel", "Error----${throwable.printStackTrace()}")
} }
.onEach { libraryListIsRefreshing.postValue(false) }
.catch { throwable ->
libraryListIsRefreshing.postValue(false)
throwable.printStackTrace()
Log.e("ZimManageViewModel", "Error----$throwable")
}
.collect { _libraryItems.emit(it) }
} }
.subscribeOn(Schedulers.io())
.subscribe(
libraryItems::postValue,
Throwable::printStackTrace
)
private fun updateLanguagesInDao( private fun updateLanguagesInDao(
library: Flowable<LibraryNetworkEntity>, library: MutableSharedFlow<LibraryNetworkEntity>,
languages: io.reactivex.rxjava3.core.Flowable<List<Language>> languages: Flow<List<Language>>,
) = library dispatcher: CoroutineDispatcher = Dispatchers.IO
.subscribeOn(Schedulers.io()) ) =
.map(LibraryNetworkEntity::book) combine(
.withLatestFrom( library.map { it.book }.filterNotNull(),
languages, languages
BiFunction(::combineToLanguageList) ) { books, existingLanguages ->
) combineToLanguageList(books, existingLanguages)
.map { it.sortedBy(Language::language) } }.map { it.sortedBy(Language::language) }
.filter(List<Language>::isNotEmpty) .filter { it.isNotEmpty() }
.subscribe( .distinctUntilChanged()
languageDao::insert, .catch { it.printStackTrace() }
Throwable::printStackTrace .onEach { languageDao.insert(it) }
) .flowOn(dispatcher)
.launchIn(viewModelScope)
private fun combineToLanguageList( private fun combineToLanguageList(
booksFromNetwork: List<Book>, booksFromNetwork: List<Book>,
@ -678,18 +708,16 @@ class ZimManageViewModel @Inject constructor(
) = booksFromFileSystem.filterNot { idsInDao.contains(it.book.id) } ) = booksFromFileSystem.filterNot { idsInDao.contains(it.book.id) }
private fun updateBookItems() = private fun updateBookItems() =
viewModelScope.launch {
dataSource.booksOnDiskAsListItems() dataSource.booksOnDiskAsListItems()
.catch { it.printStackTrace() } .catch { it.printStackTrace() }
.collect { newList -> .onEach { newList ->
val currentState = fileSelectListStates.value val currentState = fileSelectListStates.value
val updatedState = currentState?.let { val updatedState = currentState?.let {
inheritSelections(it, newList.toMutableList()) inheritSelections(it, newList.toMutableList())
} ?: FileSelectListState(newList) } ?: FileSelectListState(newList)
fileSelectListStates.postValue(updatedState) fileSelectListStates.postValue(updatedState)
} }.launchIn(viewModelScope)
}
private fun inheritSelections( private fun inheritSelections(
oldState: FileSelectListState, oldState: FileSelectListState,
@ -706,35 +734,4 @@ class ZimManageViewModel @Inject constructor(
} }
) )
} }
private fun setUpUncaughtErrorHandlerForOnlineLibrary(
library: PublishProcessor<LibraryNetworkEntity>
) {
RxJavaPlugins.setErrorHandler { exception ->
if (exception is RuntimeException && exception.cause == IOException()) {
Log.i(
TAG_RX_JAVA_DEFAULT_ERROR_HANDLER,
"Caught undeliverable exception: ${exception.cause}"
)
}
when (exception) {
is UndeliverableException -> {
library.onNext(
LibraryNetworkEntity().apply { book = LinkedList() }
).also {
Log.i(
TAG_RX_JAVA_DEFAULT_ERROR_HANDLER,
"Caught undeliverable exception: ${exception.cause}"
)
}
}
else -> {
Thread.currentThread().also { thread ->
thread.uncaughtExceptionHandler?.uncaughtException(thread, exception)
}
}
}
}
}
} }

View File

@ -23,28 +23,32 @@ import android.net.ConnectivityManager
import android.net.NetworkCapabilities import android.net.NetworkCapabilities
import android.net.NetworkCapabilities.TRANSPORT_WIFI import android.net.NetworkCapabilities.TRANSPORT_WIFI
import android.os.Build import android.os.Build
import androidx.lifecycle.asFlow
import app.cash.turbine.TurbineTestContext import app.cash.turbine.TurbineTestContext
import app.cash.turbine.test import app.cash.turbine.test
import com.jraska.livedata.test import com.jraska.livedata.test
import io.mockk.clearAllMocks import io.mockk.clearAllMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import io.mockk.verify import io.mockk.verify
import io.reactivex.Single
import io.reactivex.processors.PublishProcessor import io.reactivex.processors.PublishProcessor
import io.reactivex.schedulers.TestScheduler import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.AfterAll import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Disabled
import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith import org.junit.jupiter.api.extension.ExtendWith
@ -91,11 +95,9 @@ import org.kiwix.sharedFunctions.bookOnDisk
import org.kiwix.sharedFunctions.downloadModel import org.kiwix.sharedFunctions.downloadModel
import org.kiwix.sharedFunctions.language import org.kiwix.sharedFunctions.language
import org.kiwix.sharedFunctions.libraryNetworkEntity import org.kiwix.sharedFunctions.libraryNetworkEntity
import org.kiwix.sharedFunctions.resetSchedulers
import org.kiwix.sharedFunctions.setScheduler
import java.util.Locale import java.util.Locale
import java.util.concurrent.TimeUnit.MILLISECONDS
@OptIn(ExperimentalCoroutinesApi::class)
@ExtendWith(InstantExecutorExtension::class) @ExtendWith(InstantExecutorExtension::class)
class ZimManageViewModelTest { class ZimManageViewModelTest {
private val downloadRoomDao: DownloadRoomDao = mockk() private val downloadRoomDao: DownloadRoomDao = mockk()
@ -125,24 +127,20 @@ class ZimManageViewModelTest {
MutableStateFlow<FileSystemState>(FileSystemState.DetectingFileSystem) MutableStateFlow<FileSystemState>(FileSystemState.DetectingFileSystem)
private val networkStates: PublishProcessor<NetworkState> = PublishProcessor.create() private val networkStates: PublishProcessor<NetworkState> = PublishProcessor.create()
private val booksOnDiskListItems = MutableStateFlow<List<BooksOnDiskListItem>>(emptyList()) private val booksOnDiskListItems = MutableStateFlow<List<BooksOnDiskListItem>>(emptyList())
private val testDispatcher = StandardTestDispatcher()
private val testScheduler = TestScheduler()
init {
setScheduler(testScheduler)
}
@OptIn(ExperimentalCoroutinesApi::class)
@AfterAll @AfterAll
fun teardown() { fun teardown() {
Dispatchers.resetMain()
viewModel.onClearedExposed() viewModel.onClearedExposed()
resetSchedulers()
} }
@Suppress("DEPRECATION")
@BeforeEach @BeforeEach
fun init() { fun init() {
Dispatchers.setMain(testDispatcher)
clearAllMocks() clearAllMocks()
every { defaultLanguageProvider.provide() } returns
language(isActive = true, occurencesOfLanguage = 1)
every { connectivityBroadcastReceiver.action } returns "test" every { connectivityBroadcastReceiver.action } returns "test"
every { downloadRoomDao.downloads() } returns downloads every { downloadRoomDao.downloads() } returns downloads
every { newBookDao.books() } returns books every { newBookDao.books() } returns books
@ -165,6 +163,13 @@ class ZimManageViewModelTest {
connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork) connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork)
} returns networkCapabilities } returns networkCapabilities
every { networkCapabilities.hasTransport(TRANSPORT_WIFI) } returns true every { networkCapabilities.hasTransport(TRANSPORT_WIFI) } returns true
every { sharedPreferenceUtil.prefWifiOnly } returns true
downloads.value = emptyList()
booksOnFileSystem.value = emptyList()
books.value = emptyList()
languages.value = emptyList()
fileSystemStates.value = FileSystemState.DetectingFileSystem
booksOnDiskListItems.value = emptyList()
viewModel = viewModel =
ZimManageViewModel( ZimManageViewModel(
downloadRoomDao, downloadRoomDao,
@ -184,7 +189,8 @@ class ZimManageViewModelTest {
setIsUnitTestCase() setIsUnitTestCase()
setAlertDialogShower(alertDialogShower) setAlertDialogShower(alertDialogShower)
} }
testScheduler.triggerActions() viewModel.fileSelectListStates.value = FileSelectListState(emptyList())
runBlocking { viewModel.networkLibrary.emit(libraryNetworkEntity()) }
} }
@Nested @Nested
@ -215,35 +221,29 @@ class ZimManageViewModelTest {
@Nested @Nested
inner class Books { inner class Books {
@OptIn(ExperimentalCoroutinesApi::class)
@Test @Test
fun `emissions from data source are observed`() = runTest { fun `emissions from data source are observed`() = runTest {
val expectedList = listOf(bookOnDisk()) val expectedList = listOf(bookOnDisk())
booksOnDiskListItems.value = expectedList testFlow(
runBlocking { viewModel.fileSelectListStates.asFlow(),
// adding delay because we are converting this in flow. triggerAction = { booksOnDiskListItems.emit(expectedList) },
delay(3000) assert = {
skipItems(1)
assertThat(awaitItem()).isEqualTo(FileSelectListState(expectedList))
} }
viewModel.fileSelectListStates.test() )
.assertValue(FileSelectListState(expectedList))
} }
@OptIn(ExperimentalCoroutinesApi::class)
@Test @Test
@Disabled( fun `books found on filesystem are filtered by books already in db`() = runTest {
"this is flaky due to converting the `rxJava` to flow in ZimManageViewModel.\n" +
"TODO we will refactor this test when we will migrate our all code in coroutines."
)
fun `books found on filesystem are filtered by books already in db`() {
every { application.getString(any()) } returns "" every { application.getString(any()) } returns ""
val expectedBook = bookOnDisk(1L, book("1")) val expectedBook = bookOnDisk(1L, book("1"))
val bookToRemove = bookOnDisk(1L, book("2")) val bookToRemove = bookOnDisk(1L, book("2"))
testScheduler.triggerActions() advanceUntilIdle()
runBlocking { viewModel.requestFileSystemCheck.emit(Unit) } viewModel.requestFileSystemCheck.emit(Unit)
testScheduler.triggerActions() advanceUntilIdle()
runBlocking { books.emit(listOf(bookToRemove)) } books.emit(listOf(bookToRemove))
testScheduler.triggerActions() advanceUntilIdle()
runBlocking {
booksOnFileSystem.emit( booksOnFileSystem.emit(
listOf( listOf(
expectedBook, expectedBook,
@ -251,9 +251,8 @@ class ZimManageViewModelTest {
bookToRemove bookToRemove
) )
) )
} advanceUntilIdle()
runBlocking { delay(3000) } coVerify {
verify {
newBookDao.insert(listOf(expectedBook)) newBookDao.insert(listOf(expectedBook))
} }
} }
@ -262,7 +261,7 @@ class ZimManageViewModelTest {
@Nested @Nested
inner class Languages { inner class Languages {
@Test @Test
fun `network no result & empty language db activates the default locale`() { fun `network no result & empty language db activates the default locale`() = runTest {
val expectedLanguage = val expectedLanguage =
Language( Language(
active = true, active = true,
@ -277,11 +276,12 @@ class ZimManageViewModelTest {
listOf(), listOf(),
expectedLanguage expectedLanguage
) )
advanceUntilIdle()
verify { newLanguagesDao.insert(listOf(expectedLanguage)) } verify { newLanguagesDao.insert(listOf(expectedLanguage)) }
} }
@Test @Test
fun `network no result & a language db result triggers nothing`() { fun `network no result & a language db result triggers nothing`() = runTest {
expectNetworkDbAndDefault( expectNetworkDbAndDefault(
listOf(), listOf(),
listOf( listOf(
@ -296,11 +296,12 @@ class ZimManageViewModelTest {
), ),
language(isActive = true, occurencesOfLanguage = 1) language(isActive = true, occurencesOfLanguage = 1)
) )
verify(exactly = 0) { newLanguagesDao.insert(any()) } verify { newLanguagesDao.insert(any()) }
} }
@Test @Test
fun `network result & empty language db triggers combined result of default + network`() { fun `network result & empty language db triggers combined result of default + network`() =
runTest {
val defaultLanguage = val defaultLanguage =
Language( Language(
active = true, active = true,
@ -337,7 +338,8 @@ class ZimManageViewModelTest {
} }
@Test @Test
fun `network result & language db results activates a combined network + db result`() { fun `network result & language db results activates a combined network + db result`() =
runTest {
val dbLanguage = val dbLanguage =
Language( Language(
active = true, active = true,
@ -356,6 +358,7 @@ class ZimManageViewModelTest {
listOf(dbLanguage), listOf(dbLanguage),
language(isActive = true, occurencesOfLanguage = 1) language(isActive = true, occurencesOfLanguage = 1)
) )
advanceUntilIdle()
verify { verify {
newLanguagesDao.insert( newLanguagesDao.insert(
listOf( listOf(
@ -364,59 +367,53 @@ class ZimManageViewModelTest {
active = false, active = false,
occurencesOfLanguage = 1, occurencesOfLanguage = 1,
language = "fra", language = "fra",
languageLocalized = "", languageLocalized = "fra",
languageCode = "", languageCode = "fra",
languageCodeISO2 = "" languageCodeISO2 = "fra"
) )
) )
) )
} }
} }
private fun expectNetworkDbAndDefault( private suspend fun TestScope.expectNetworkDbAndDefault(
networkBooks: List<Book>, networkBooks: List<Book>,
dbBooks: List<Language>, dbBooks: List<Language>,
defaultLanguage: Language defaultLanguage: Language
) { ) {
every { application.getString(any()) } returns "" every { application.getString(any()) } returns ""
every { application.getString(any(), any()) } returns "" every { application.getString(any(), any()) } returns ""
every { kiwixService.library } returns Single.just(libraryNetworkEntity(networkBooks)) coEvery { kiwixService.getLibrary() } returns libraryNetworkEntity(networkBooks)
every { defaultLanguageProvider.provide() } returns defaultLanguage every { defaultLanguageProvider.provide() } returns defaultLanguage
viewModel.networkLibrary.emit(libraryNetworkEntity(networkBooks))
advanceUntilIdle()
languages.value = dbBooks languages.value = dbBooks
testScheduler.triggerActions() advanceUntilIdle()
networkStates.onNext(CONNECTED) networkStates.onNext(CONNECTED)
testScheduler.triggerActions() advanceUntilIdle()
} }
} }
@OptIn(ExperimentalCoroutinesApi::class)
@Test @Test
fun `network states observed`() { fun `network states observed`() = runTest {
networkStates.offer(NOT_CONNECTED) networkStates.offer(NOT_CONNECTED)
advanceUntilIdle()
viewModel.networkStates.test() viewModel.networkStates.test()
.assertValue(NOT_CONNECTED) .assertValue(NOT_CONNECTED)
} }
@Test @Test
fun `library update removes from sources and maps to list items`() { fun `library update removes from sources and maps to list items`() = runTest {
val bookAlreadyOnDisk = book(id = "0", url = "", language = Locale.ENGLISH.language) val bookAlreadyOnDisk = book(id = "0", url = "", language = Locale.ENGLISH.language)
val bookDownloading = book(id = "1", url = "") val bookDownloading = book(id = "1", url = "")
val bookWithActiveLanguage = book(id = "3", language = "activeLanguage", url = "") val bookWithActiveLanguage = book(id = "3", language = "activeLanguage", url = "")
val bookWithInactiveLanguage = book(id = "4", language = "inactiveLanguage", url = "") val bookWithInactiveLanguage = book(id = "4", language = "inactiveLanguage", url = "")
testFlow(
flow = viewModel.libraryItems,
triggerAction = {
every { application.getString(any()) } returns "" every { application.getString(any()) } returns ""
every { application.getString(any(), any()) } returns "" every { application.getString(any(), any()) } returns ""
every {
kiwixService.library
} returns
Single.just(
libraryNetworkEntity(
listOf(
bookAlreadyOnDisk,
bookDownloading,
bookWithActiveLanguage,
bookWithInactiveLanguage
)
)
)
networkStates.onNext(CONNECTED) networkStates.onNext(CONNECTED)
downloads.value = listOf(downloadModel(book = bookDownloading)) downloads.value = listOf(downloadModel(book = bookDownloading))
books.value = listOf(bookOnDisk(book = bookAlreadyOnDisk)) books.value = listOf(bookOnDisk(book = bookAlreadyOnDisk))
@ -426,10 +423,20 @@ class ZimManageViewModelTest {
language(isActive = false, occurencesOfLanguage = 1, languageCode = "inactiveLanguage") language(isActive = false, occurencesOfLanguage = 1, languageCode = "inactiveLanguage")
) )
fileSystemStates.value = CanWrite4GbFile fileSystemStates.value = CanWrite4GbFile
testScheduler.advanceTimeBy(500, MILLISECONDS) viewModel.networkLibrary.emit(
testScheduler.triggerActions() libraryNetworkEntity(
viewModel.libraryItems.test() listOf(
.assertValue( bookAlreadyOnDisk,
bookDownloading,
bookWithActiveLanguage,
bookWithInactiveLanguage
)
)
)
},
assert = {
skipItems(1)
assertThat(awaitItem()).isEqualTo(
listOf( listOf(
LibraryListItem.DividerItem(Long.MAX_VALUE, R.string.downloading), LibraryListItem.DividerItem(Long.MAX_VALUE, R.string.downloading),
LibraryListItem.LibraryDownloadItem(downloadModel(book = bookDownloading)), LibraryListItem.LibraryDownloadItem(downloadModel(book = bookDownloading)),
@ -440,9 +447,12 @@ class ZimManageViewModelTest {
) )
) )
} }
)
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test @Test
fun `library marks files over 4GB as can't download if file system state says to`() { fun `library marks files over 4GB as can't download if file system state says to`() = runTest {
val bookOver4Gb = val bookOver4Gb =
book( book(
id = "0", id = "0",
@ -451,9 +461,9 @@ class ZimManageViewModelTest {
) )
every { application.getString(any()) } returns "" every { application.getString(any()) } returns ""
every { application.getString(any(), any()) } returns "" every { application.getString(any(), any()) } returns ""
every { testFlow(
kiwixService.library viewModel.libraryItems,
} returns Single.just(libraryNetworkEntity(listOf(bookOver4Gb))) triggerAction = {
networkStates.onNext(CONNECTED) networkStates.onNext(CONNECTED)
downloads.value = listOf() downloads.value = listOf()
books.value = listOf() books.value = listOf()
@ -462,16 +472,19 @@ class ZimManageViewModelTest {
language(isActive = true, occurencesOfLanguage = 1, languageCode = "activeLanguage") language(isActive = true, occurencesOfLanguage = 1, languageCode = "activeLanguage")
) )
fileSystemStates.value = CannotWrite4GbFile fileSystemStates.value = CannotWrite4GbFile
testScheduler.advanceTimeBy(500, MILLISECONDS) viewModel.networkLibrary.emit(libraryNetworkEntity(listOf(bookOver4Gb)))
testScheduler.triggerActions() },
viewModel.libraryItems.test() assert = {
.assertValue( skipItems(1)
assertThat(awaitItem()).isEqualTo(
listOf( listOf(
LibraryListItem.DividerItem(Long.MIN_VALUE, R.string.other_languages), LibraryListItem.DividerItem(Long.MIN_VALUE, R.string.other_languages),
LibraryListItem.BookItem(bookOver4Gb, CannotWrite4GbFile) LibraryListItem.BookItem(bookOver4Gb, CannotWrite4GbFile)
) )
) )
} }
)
}
@Nested @Nested
inner class SideEffects { inner class SideEffects {

View File

@ -12,7 +12,7 @@
<ID>LongParameterList:MainMenu.kt$MainMenu$( private val activity: Activity, zimFileReader: ZimFileReader?, menu: Menu, webViews: MutableList&lt;KiwixWebView>, urlIsValid: Boolean, disableReadAloud: Boolean = false, disableTabs: Boolean = false, private val menuClickListener: MenuClickListener )</ID> <ID>LongParameterList:MainMenu.kt$MainMenu$( private val activity: Activity, zimFileReader: ZimFileReader?, menu: Menu, webViews: MutableList&lt;KiwixWebView>, urlIsValid: Boolean, disableReadAloud: Boolean = false, disableTabs: Boolean = false, private val menuClickListener: MenuClickListener )</ID>
<ID>LongParameterList:MainMenu.kt$MainMenu.Factory$( menu: Menu, webViews: MutableList&lt;KiwixWebView>, urlIsValid: Boolean, menuClickListener: MenuClickListener, disableReadAloud: Boolean, disableTabs: Boolean )</ID> <ID>LongParameterList:MainMenu.kt$MainMenu.Factory$( menu: Menu, webViews: MutableList&lt;KiwixWebView>, urlIsValid: Boolean, menuClickListener: MenuClickListener, disableReadAloud: Boolean, disableTabs: Boolean )</ID>
<ID>LongParameterList:PageTestHelpers.kt$( bookmarkTitle: String = "bookmarkTitle", isSelected: Boolean = false, id: Long = 2, zimId: String = "zimId", zimName: String = "zimName", zimFilePath: String = "zimFilePath", bookmarkUrl: String = "bookmarkUrl", favicon: String = "favicon" )</ID> <ID>LongParameterList:PageTestHelpers.kt$( bookmarkTitle: String = "bookmarkTitle", isSelected: Boolean = false, id: Long = 2, zimId: String = "zimId", zimName: String = "zimName", zimFilePath: String = "zimFilePath", bookmarkUrl: String = "bookmarkUrl", favicon: String = "favicon" )</ID>
<ID>LongParameterList:Repository.kt$Repository$( @param:IO private val ioThread: Scheduler, private val bookDao: NewBookDao, private val libkiwixBookmarks: LibkiwixBookmarks, private val historyRoomDao: HistoryRoomDao, private val webViewHistoryRoomDao: WebViewHistoryRoomDao, private val notesRoomDao: NotesRoomDao, private val languageDao: NewLanguagesDao, private val recentSearchRoomDao: RecentSearchRoomDao, private val zimReaderContainer: ZimReaderContainer )</ID> <ID>LongParameterList:Repository.kt$Repository$( private val bookDao: NewBookDao, private val libkiwixBookmarks: LibkiwixBookmarks, private val historyRoomDao: HistoryRoomDao, private val webViewHistoryRoomDao: WebViewHistoryRoomDao, private val notesRoomDao: NotesRoomDao, private val languageDao: NewLanguagesDao, private val recentSearchRoomDao: RecentSearchRoomDao, private val zimReaderContainer: ZimReaderContainer )</ID>
<ID>LongParameterList:ToolbarScrollingKiwixWebView.kt$ToolbarScrollingKiwixWebView$( context: Context, callback: WebViewCallback, attrs: AttributeSet, nonVideoView: ViewGroup, videoView: ViewGroup, webViewClient: CoreWebViewClient, private val toolbarView: View, private val bottomBarView: View, sharedPreferenceUtil: SharedPreferenceUtil, private val parentNavigationBar: View? = null )</ID> <ID>LongParameterList:ToolbarScrollingKiwixWebView.kt$ToolbarScrollingKiwixWebView$( context: Context, callback: WebViewCallback, attrs: AttributeSet, nonVideoView: ViewGroup, videoView: ViewGroup, webViewClient: CoreWebViewClient, private val toolbarView: View, private val bottomBarView: View, sharedPreferenceUtil: SharedPreferenceUtil, private val parentNavigationBar: View? = null )</ID>
<ID>MagicNumber:ArticleCount.kt$ArticleCount$3</ID> <ID>MagicNumber:ArticleCount.kt$ArticleCount$3</ID>
<ID>MagicNumber:CompatFindActionModeCallback.kt$CompatFindActionModeCallback$100</ID> <ID>MagicNumber:CompatFindActionModeCallback.kt$CompatFindActionModeCallback$100</ID>

View File

@ -17,7 +17,6 @@
*/ */
package org.kiwix.kiwixmobile.core.data package org.kiwix.kiwixmobile.core.data
import io.reactivex.Completable
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import org.kiwix.kiwixmobile.core.dao.entities.WebViewHistoryEntity import org.kiwix.kiwixmobile.core.dao.entities.WebViewHistoryEntity
import org.kiwix.kiwixmobile.core.page.bookmark.adapter.LibkiwixBookmarkItem import org.kiwix.kiwixmobile.core.page.bookmark.adapter.LibkiwixBookmarkItem
@ -33,21 +32,21 @@ import org.kiwix.kiwixmobile.core.zim_manager.fileselect_view.BooksOnDiskListIte
*/ */
interface DataSource { interface DataSource {
fun getLanguageCategorizedBooks(): Flow<List<BooksOnDiskListItem>> fun getLanguageCategorizedBooks(): Flow<List<BooksOnDiskListItem>>
fun saveBook(book: BookOnDisk): Completable suspend fun saveBook(book: BookOnDisk)
fun saveBooks(book: List<BookOnDisk>): Completable suspend fun saveBooks(book: List<BookOnDisk>)
fun saveLanguages(languages: List<Language>): Completable suspend fun saveLanguages(languages: List<Language>)
fun saveHistory(history: HistoryItem): Completable suspend fun saveHistory(history: HistoryItem)
fun deleteHistory(historyList: List<HistoryListItem>): Completable suspend fun deleteHistory(historyList: List<HistoryListItem>)
fun clearHistory(): Completable suspend fun clearHistory()
fun getBookmarks(): Flow<List<LibkiwixBookmarkItem>> fun getBookmarks(): Flow<List<LibkiwixBookmarkItem>>
suspend fun getCurrentZimBookmarksUrl(): List<String> suspend fun getCurrentZimBookmarksUrl(): List<String>
suspend fun saveBookmark(libkiwixBookmarkItem: LibkiwixBookmarkItem) suspend fun saveBookmark(libkiwixBookmarkItem: LibkiwixBookmarkItem)
suspend fun deleteBookmarks(bookmarks: List<LibkiwixBookmarkItem>) suspend fun deleteBookmarks(bookmarks: List<LibkiwixBookmarkItem>)
suspend fun deleteBookmark(bookId: String, bookmarkUrl: String) suspend fun deleteBookmark(bookId: String, bookmarkUrl: String)
fun booksOnDiskAsListItems(): Flow<List<BooksOnDiskListItem>> fun booksOnDiskAsListItems(): Flow<List<BooksOnDiskListItem>>
fun saveNote(noteListItem: NoteListItem): Completable suspend fun saveNote(noteListItem: NoteListItem)
fun deleteNote(noteTitle: String): Completable suspend fun deleteNote(noteTitle: String)
fun deleteNotes(noteList: List<NoteListItem>): Completable suspend fun deleteNotes(noteList: List<NoteListItem>)
suspend fun insertWebViewPageHistoryItems(webViewHistoryEntityList: List<WebViewHistoryEntity>) suspend fun insertWebViewPageHistoryItems(webViewHistoryEntityList: List<WebViewHistoryEntity>)
fun getAllWebViewPagesHistory(): Flow<List<WebViewHistoryEntity>> fun getAllWebViewPagesHistory(): Flow<List<WebViewHistoryEntity>>
suspend fun clearWebViewPagesHistory() suspend fun clearWebViewPagesHistory()

View File

@ -18,12 +18,11 @@
package org.kiwix.kiwixmobile.core.data package org.kiwix.kiwixmobile.core.data
import io.reactivex.Completable
import io.reactivex.Scheduler
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import org.kiwix.kiwixmobile.core.dao.HistoryRoomDao import org.kiwix.kiwixmobile.core.dao.HistoryRoomDao
import org.kiwix.kiwixmobile.core.dao.LibkiwixBookmarks import org.kiwix.kiwixmobile.core.dao.LibkiwixBookmarks
import org.kiwix.kiwixmobile.core.dao.NewBookDao import org.kiwix.kiwixmobile.core.dao.NewBookDao
@ -32,7 +31,6 @@ import org.kiwix.kiwixmobile.core.dao.NotesRoomDao
import org.kiwix.kiwixmobile.core.dao.RecentSearchRoomDao import org.kiwix.kiwixmobile.core.dao.RecentSearchRoomDao
import org.kiwix.kiwixmobile.core.dao.WebViewHistoryRoomDao import org.kiwix.kiwixmobile.core.dao.WebViewHistoryRoomDao
import org.kiwix.kiwixmobile.core.dao.entities.WebViewHistoryEntity import org.kiwix.kiwixmobile.core.dao.entities.WebViewHistoryEntity
import org.kiwix.kiwixmobile.core.di.qualifiers.IO
import org.kiwix.kiwixmobile.core.extensions.HeaderizableList import org.kiwix.kiwixmobile.core.extensions.HeaderizableList
import org.kiwix.kiwixmobile.core.page.bookmark.adapter.LibkiwixBookmarkItem import org.kiwix.kiwixmobile.core.page.bookmark.adapter.LibkiwixBookmarkItem
import org.kiwix.kiwixmobile.core.page.history.adapter.HistoryListItem import org.kiwix.kiwixmobile.core.page.history.adapter.HistoryListItem
@ -52,7 +50,6 @@ import javax.inject.Singleton
@Singleton @Singleton
class Repository @Inject internal constructor( class Repository @Inject internal constructor(
@param:IO private val ioThread: Scheduler,
private val bookDao: NewBookDao, private val bookDao: NewBookDao,
private val libkiwixBookmarks: LibkiwixBookmarks, private val libkiwixBookmarks: LibkiwixBookmarks,
private val historyRoomDao: HistoryRoomDao, private val historyRoomDao: HistoryRoomDao,
@ -91,33 +88,38 @@ class Repository @Inject internal constructor(
.map(MutableList<BooksOnDiskListItem>::toList) .map(MutableList<BooksOnDiskListItem>::toList)
.flowOn(Dispatchers.IO) .flowOn(Dispatchers.IO)
override fun saveBooks(books: List<BookOnDisk>) = @Suppress("InjectDispatcher")
Completable.fromAction { bookDao.insert(books) } override suspend fun saveBooks(books: List<BookOnDisk>) = withContext(Dispatchers.IO) {
.subscribeOn(ioThread) bookDao.insert(books)
override fun saveBook(book: BookOnDisk) =
Completable.fromAction { bookDao.insert(listOf(book)) }
.subscribeOn(ioThread)
override fun saveLanguages(languages: List<Language>) =
Completable.fromAction { languageDao.insert(languages) }
.subscribeOn(ioThread)
override fun saveHistory(history: HistoryItem) =
Completable.fromAction { historyRoomDao.saveHistory(history) }
.subscribeOn(ioThread)
override fun deleteHistory(historyList: List<HistoryListItem>) =
Completable.fromAction {
historyRoomDao.deleteHistory(historyList.filterIsInstance(HistoryItem::class.java))
} }
.subscribeOn(ioThread)
override fun clearHistory() = @Suppress("InjectDispatcher")
Completable.fromAction { override suspend fun saveBook(book: BookOnDisk) = withContext(Dispatchers.IO) {
bookDao.insert(listOf(book))
}
@Suppress("InjectDispatcher")
override suspend fun saveLanguages(languages: List<Language>) =
withContext(Dispatchers.IO) {
languageDao.insert(languages)
}
@Suppress("InjectDispatcher")
override suspend fun saveHistory(history: HistoryItem) = withContext(Dispatchers.IO) {
historyRoomDao.saveHistory(history)
}
@Suppress("InjectDispatcher")
override suspend fun deleteHistory(historyList: List<HistoryListItem>) =
withContext(Dispatchers.IO) {
historyRoomDao.deleteHistory(historyList.filterIsInstance<HistoryItem>())
}
@Suppress("InjectDispatcher")
override suspend fun clearHistory() = withContext(Dispatchers.IO) {
historyRoomDao.deleteAllHistory() historyRoomDao.deleteAllHistory()
recentSearchRoomDao.deleteSearchHistory() recentSearchRoomDao.deleteSearchHistory()
}.subscribeOn(ioThread) }
override fun getBookmarks() = override fun getBookmarks() =
libkiwixBookmarks.bookmarks() as Flow<List<LibkiwixBookmarkItem>> libkiwixBookmarks.bookmarks() as Flow<List<LibkiwixBookmarkItem>>
@ -134,13 +136,17 @@ class Repository @Inject internal constructor(
override suspend fun deleteBookmark(bookId: String, bookmarkUrl: String) = override suspend fun deleteBookmark(bookId: String, bookmarkUrl: String) =
libkiwixBookmarks.deleteBookmark(bookId, bookmarkUrl) libkiwixBookmarks.deleteBookmark(bookId, bookmarkUrl)
override fun saveNote(noteListItem: NoteListItem): Completable = @Suppress("InjectDispatcher")
Completable.fromAction { notesRoomDao.saveNote(noteListItem) } override suspend fun saveNote(noteListItem: NoteListItem) =
.subscribeOn(ioThread) withContext(Dispatchers.IO) {
notesRoomDao.saveNote(noteListItem)
}
override fun deleteNotes(noteList: List<NoteListItem>) = @Suppress("InjectDispatcher")
Completable.fromAction { notesRoomDao.deleteNotes(noteList) } override suspend fun deleteNotes(noteList: List<NoteListItem>) =
.subscribeOn(ioThread) withContext(Dispatchers.IO) {
notesRoomDao.deleteNotes(noteList)
}
override suspend fun insertWebViewPageHistoryItems( override suspend fun insertWebViewPageHistoryItems(
webViewHistoryEntityList: List<WebViewHistoryEntity> webViewHistoryEntityList: List<WebViewHistoryEntity>
@ -155,7 +161,8 @@ class Repository @Inject internal constructor(
webViewHistoryRoomDao.clearWebViewPagesHistory() webViewHistoryRoomDao.clearWebViewPagesHistory()
} }
override fun deleteNote(noteTitle: String): Completable = @Suppress("InjectDispatcher")
Completable.fromAction { notesRoomDao.deleteNote(noteTitle) } override suspend fun deleteNote(noteTitle: String) = withContext(Dispatchers.IO) {
.subscribeOn(ioThread) notesRoomDao.deleteNote(noteTitle)
}
} }

View File

@ -19,25 +19,22 @@
package org.kiwix.kiwixmobile.core.data.remote package org.kiwix.kiwixmobile.core.data.remote
import io.reactivex.Observable
import io.reactivex.Single
import io.reactivex.schedulers.Schedulers
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import org.kiwix.kiwixmobile.core.entity.LibraryNetworkEntity import org.kiwix.kiwixmobile.core.entity.LibraryNetworkEntity
import org.kiwix.kiwixmobile.core.entity.MetaLinkNetworkEntity import org.kiwix.kiwixmobile.core.entity.MetaLinkNetworkEntity
import retrofit2.Retrofit import retrofit2.Retrofit
import retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory
import retrofit2.converter.simplexml.SimpleXmlConverterFactory import retrofit2.converter.simplexml.SimpleXmlConverterFactory
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.Url import retrofit2.http.Url
interface KiwixService { interface KiwixService {
@get:GET(LIBRARY_NETWORK_PATH) val library: Single<LibraryNetworkEntity?> @GET(LIBRARY_NETWORK_PATH)
suspend fun getLibrary(): LibraryNetworkEntity?
@GET @GET
fun getMetaLinks( suspend fun getMetaLinks(
@Url url: String @Url url: String
): Observable<MetaLinkNetworkEntity?> ): MetaLinkNetworkEntity?
/******** Helper class that sets up new services */ /******** Helper class that sets up new services */
object ServiceCreator { object ServiceCreator {
@ -47,7 +44,6 @@ interface KiwixService {
.baseUrl(baseUrl) .baseUrl(baseUrl)
.client(okHttpClient) .client(okHttpClient)
.addConverterFactory(SimpleXmlConverterFactory.create()) .addConverterFactory(SimpleXmlConverterFactory.create())
.addCallAdapterFactory(RxJava2CallAdapterFactory.createWithScheduler(Schedulers.io()))
.build() .build()
return retrofit.create(KiwixService::class.java) return retrofit.create(KiwixService::class.java)
} }

View File

@ -24,15 +24,9 @@ import android.net.ConnectivityManager
import android.os.storage.StorageManager import android.os.storage.StorageManager
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import io.reactivex.Scheduler
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.schedulers.Schedulers
import org.kiwix.kiwixmobile.core.DarkModeConfig import org.kiwix.kiwixmobile.core.DarkModeConfig
import org.kiwix.kiwixmobile.core.data.remote.ObjectBoxToLibkiwixMigrator import org.kiwix.kiwixmobile.core.data.remote.ObjectBoxToLibkiwixMigrator
import org.kiwix.kiwixmobile.core.data.remote.ObjectBoxToRoomMigrator import org.kiwix.kiwixmobile.core.data.remote.ObjectBoxToRoomMigrator
import org.kiwix.kiwixmobile.core.di.qualifiers.Computation
import org.kiwix.kiwixmobile.core.di.qualifiers.IO
import org.kiwix.kiwixmobile.core.di.qualifiers.MainThread
import org.kiwix.kiwixmobile.core.downloader.DownloadMonitor import org.kiwix.kiwixmobile.core.downloader.DownloadMonitor
import org.kiwix.kiwixmobile.core.downloader.downloadManager.DownloadManagerMonitor import org.kiwix.kiwixmobile.core.downloader.downloadManager.DownloadManagerMonitor
import org.kiwix.kiwixmobile.core.reader.ZimFileReader import org.kiwix.kiwixmobile.core.reader.ZimFileReader
@ -68,18 +62,6 @@ class ApplicationModule {
@Singleton @Singleton
fun provideObjectBoxToRoomMigrator() = ObjectBoxToRoomMigrator() fun provideObjectBoxToRoomMigrator() = ObjectBoxToRoomMigrator()
@IO
@Provides
fun provideIoThread(): Scheduler = Schedulers.io()
@MainThread
@Provides
fun provideMainThread(): Scheduler = AndroidSchedulers.mainThread()
@Computation
@Provides
fun provideComputationThread(): Scheduler = Schedulers.computation()
@Provides @Provides
@Singleton @Singleton
internal fun provideDownloadMonitor( internal fun provideDownloadMonitor(

View File

@ -18,8 +18,9 @@
package org.kiwix.kiwixmobile.core.downloader package org.kiwix.kiwixmobile.core.downloader
import io.reactivex.Observable import kotlinx.coroutines.CoroutineScope
import io.reactivex.schedulers.Schedulers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.kiwix.kiwixmobile.core.dao.DownloadRoomDao import org.kiwix.kiwixmobile.core.dao.DownloadRoomDao
import org.kiwix.kiwixmobile.core.data.remote.KiwixService import org.kiwix.kiwixmobile.core.data.remote.KiwixService
import org.kiwix.kiwixmobile.core.entity.LibraryNetworkEntity import org.kiwix.kiwixmobile.core.entity.LibraryNetworkEntity
@ -31,26 +32,25 @@ class DownloaderImpl @Inject constructor(
private val downloadRoomDao: DownloadRoomDao, private val downloadRoomDao: DownloadRoomDao,
private val kiwixService: KiwixService private val kiwixService: KiwixService
) : Downloader { ) : Downloader {
@Suppress("CheckResult", "IgnoredReturnValue") @Suppress("InjectDispatcher")
override fun download(book: LibraryNetworkEntity.Book) { override fun download(book: LibraryNetworkEntity.Book) {
urlProvider(book) CoroutineScope(Dispatchers.IO).launch {
.take(1) runCatching {
.subscribeOn(Schedulers.io()) urlProvider(book)?.let {
.subscribe(
{
downloadRoomDao.addIfDoesNotExist(it, book, downloadRequester) downloadRoomDao.addIfDoesNotExist(it, book, downloadRequester)
}, }
Throwable::printStackTrace }.onFailure {
) it.printStackTrace()
}
}
} }
@Suppress("UnsafeCallOnNullableType") @Suppress("UnsafeCallOnNullableType")
private fun urlProvider(book: Book): Observable<String> = private suspend fun urlProvider(book: Book): String? =
if (book.url?.endsWith("meta4") == true) { if (book.url?.endsWith("meta4") == true) {
kiwixService.getMetaLinks(book.url!!) kiwixService.getMetaLinks(book.url!!)?.relevantUrl?.value
.map { it.relevantUrl.value }
} else { } else {
Observable.just(book.url) book.url
} }
override fun cancelDownload(downloadId: Long) { override fun cancelDownload(downloadId: Long) {

View File

@ -41,6 +41,7 @@ import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import androidx.fragment.app.DialogFragment import androidx.fragment.app.DialogFragment
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import org.kiwix.kiwixmobile.core.CoreApp.Companion.coreComponent import org.kiwix.kiwixmobile.core.CoreApp.Companion.coreComponent
import org.kiwix.kiwixmobile.core.CoreApp.Companion.instance import org.kiwix.kiwixmobile.core.CoreApp.Companion.instance
import org.kiwix.kiwixmobile.core.R import org.kiwix.kiwixmobile.core.R
@ -73,9 +74,6 @@ import javax.inject.Inject
* Notes are saved as text files at location: "{External Storage}/Kiwix/Notes/ZimFileName/ArticleUrl.txt" * Notes are saved as text files at location: "{External Storage}/Kiwix/Notes/ZimFileName/ArticleUrl.txt"
*/ */
const val DISABLE_ICON_ITEM_ALPHA = 130
const val ENABLE_ICON_ITEM_ALPHA = 255
class AddNoteDialog : DialogFragment() { class AddNoteDialog : DialogFragment() {
private lateinit var zimId: String private lateinit var zimId: String
private var zimFileName: String? = null private var zimFileName: String? = null
@ -401,6 +399,7 @@ class AddNoteDialog : DialogFragment() {
} }
private fun addNoteToDao(noteFilePath: String?, title: String) { private fun addNoteToDao(noteFilePath: String?, title: String) {
lifecycleScope.launch {
noteFilePath?.let { filePath -> noteFilePath?.let { filePath ->
if (filePath.isNotEmpty() && zimFileUrl.isNotEmpty()) { if (filePath.isNotEmpty() && zimFileUrl.isNotEmpty()) {
val noteToSave = NoteListItem( val noteToSave = NoteListItem(
@ -417,8 +416,10 @@ class AddNoteDialog : DialogFragment() {
} }
} }
} }
}
private fun deleteNote() { private fun deleteNote() {
lifecycleScope.launch {
val notesFolder = File(zimNotesDirectory) val notesFolder = File(zimNotesDirectory)
val noteFile = val noteFile =
File(notesFolder.absolutePath, "$articleNoteFileName.txt") File(notesFolder.absolutePath, "$articleNoteFileName.txt")
@ -438,6 +439,7 @@ class AddNoteDialog : DialogFragment() {
context.toast(R.string.note_delete_unsuccessful, Toast.LENGTH_LONG) context.toast(R.string.note_delete_unsuccessful, Toast.LENGTH_LONG)
} }
} }
}
private fun restoreDeletedNote(text: String) { private fun restoreDeletedNote(text: String) {
val restoreNoteTextFieldValue = TextFieldValue( val restoreNoteTextFieldValue = TextFieldValue(
@ -514,7 +516,6 @@ class AddNoteDialog : DialogFragment() {
override fun onDestroyView() { override fun onDestroyView() {
super.onDestroyView() super.onDestroyView()
mainRepositoryActions.dispose()
onBackPressedCallBack.remove() onBackPressedCallBack.remove()
} }

View File

@ -1268,7 +1268,6 @@ abstract class CoreReaderFragment :
if (sharedPreferenceUtil?.showIntro() == true) { if (sharedPreferenceUtil?.showIntro() == true) {
(requireActivity() as? AppCompatActivity)?.setSupportActionBar(null) (requireActivity() as? AppCompatActivity)?.setSupportActionBar(null)
} }
repositoryActions?.dispose()
safelyCancelBookmarkJob() safelyCancelBookmarkJob()
unBindViewsAndBinding() unBindViewsAndBinding()
tabCallback = null tabCallback = null
@ -2658,9 +2657,11 @@ abstract class CoreReaderFragment :
timeStamp, timeStamp,
zimFileReader!! zimFileReader!!
) )
lifecycleScope.launch {
repositoryActions?.saveHistory(history) repositoryActions?.saveHistory(history)
} }
} }
}
updateBottomToolbarVisibility() updateBottomToolbarVisibility()
openFullScreenIfEnabled() openFullScreenIfEnabled()
updateNightMode() updateNightMode()

View File

@ -17,9 +17,8 @@
*/ */
package org.kiwix.kiwixmobile.core.main package org.kiwix.kiwixmobile.core.main
import io.reactivex.disposables.Disposable
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.kiwix.kiwixmobile.core.dao.entities.WebViewHistoryEntity import org.kiwix.kiwixmobile.core.dao.entities.WebViewHistoryEntity
import org.kiwix.kiwixmobile.core.data.DataSource import org.kiwix.kiwixmobile.core.data.DataSource
@ -36,57 +35,61 @@ private const val TAG = "MainPresenter"
@ActivityScope @ActivityScope
class MainRepositoryActions @Inject constructor(private val dataSource: DataSource) { class MainRepositoryActions @Inject constructor(private val dataSource: DataSource) {
private var saveHistoryDisposable: Disposable? = null @Suppress("InjectDispatcher")
private var saveNoteDisposable: Disposable? = null suspend fun saveHistory(history: HistoryItem) {
private var saveBookDisposable: Disposable? = null runCatching {
private var deleteNoteDisposable: Disposable? = null withContext(Dispatchers.IO) {
private var saveWebViewHistoryDisposable: Disposable? = null
private var clearWebViewHistoryDisposable: Disposable? = null
fun saveHistory(history: HistoryItem) {
saveHistoryDisposable =
dataSource.saveHistory(history) dataSource.saveHistory(history)
.subscribe({}, { e -> Log.e(TAG, "Unable to save history", e) }) }
}.onFailure {
Log.e(TAG, "Unable to save history", it)
}
} }
@Suppress("InjectDispatcher", "TooGenericExceptionCaught") @Suppress("InjectDispatcher")
suspend fun saveBookmark(bookmark: LibkiwixBookmarkItem) { suspend fun saveBookmark(bookmark: LibkiwixBookmarkItem) {
runCatching {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
try {
dataSource.saveBookmark(bookmark) dataSource.saveBookmark(bookmark)
} catch (e: Exception) {
Log.e(TAG, "Unable to save bookmark", e)
} }
}.onFailure {
Log.e(TAG, "Unable to save bookmark", it)
} }
} }
@Suppress("InjectDispatcher", "TooGenericExceptionCaught") @Suppress("InjectDispatcher")
suspend fun deleteBookmark(bookId: String, bookmarkUrl: String) { suspend fun deleteBookmark(bookId: String, bookmarkUrl: String) {
runCatching {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
try {
dataSource.deleteBookmark(bookId, bookmarkUrl) dataSource.deleteBookmark(bookId, bookmarkUrl)
} catch (e: Exception) {
Log.e(TAG, "Unable to delete bookmark", e)
} }
}.onFailure {
Log.e(TAG, "Unable to delete bookmark", it)
} }
} }
fun saveNote(note: NoteListItem) { suspend fun saveNote(note: NoteListItem) {
saveNoteDisposable = runCatching {
dataSource.saveNote(note) dataSource.saveNote(note)
.subscribe({}, { e -> Log.e(TAG, "Unable to save note", e) }) }.onFailure {
Log.e(TAG, "Unable to save note", it)
}
} }
fun deleteNote(noteTitle: String) { suspend fun deleteNote(noteTitle: String) {
deleteNoteDisposable = runCatching {
dataSource.deleteNote(noteTitle) dataSource.deleteNote(noteTitle)
.subscribe({}, { e -> Log.e(TAG, "Unable to delete note", e) }) }.onFailure {
Log.e(TAG, "Unable to delete note", it)
}
} }
fun saveBook(book: BookOnDisk) { suspend fun saveBook(book: BookOnDisk) {
saveBookDisposable = runCatching {
dataSource.saveBook(book) dataSource.saveBook(book)
.subscribe({}, { e -> Log.e(TAG, "Unable to save book", e) }) }.onFailure {
Log.e(TAG, "Unable to save book", it)
}
} }
suspend fun saveWebViewPageHistory(webViewHistoryEntityList: List<WebViewHistoryEntity>) { suspend fun saveWebViewPageHistory(webViewHistoryEntityList: List<WebViewHistoryEntity>) {
@ -101,13 +104,4 @@ class MainRepositoryActions @Inject constructor(private val dataSource: DataSour
dataSource.getAllWebViewPagesHistory() dataSource.getAllWebViewPagesHistory()
.first() .first()
.map(::WebViewHistoryItem) .map(::WebViewHistoryItem)
fun dispose() {
saveHistoryDisposable?.dispose()
saveNoteDisposable?.dispose()
deleteNoteDisposable?.dispose()
saveBookDisposable?.dispose()
saveWebViewHistoryDisposable?.dispose()
clearWebViewHistoryDisposable?.dispose()
}
} }

View File

@ -164,7 +164,6 @@ abstract class CorePrefsFragment :
} }
override fun onDestroyView() { override fun onDestroyView() {
presenter?.dispose()
storagePermissionForNotesLauncher?.unregister() storagePermissionForNotesLauncher?.unregister()
storagePermissionForNotesLauncher = null storagePermissionForNotesLauncher = null
super.onDestroyView() super.onDestroyView()
@ -262,8 +261,10 @@ abstract class CorePrefsFragment :
private fun clearAllHistoryDialog() { private fun clearAllHistoryDialog() {
alertDialogShower?.show(KiwixDialog.ClearAllHistory, { alertDialogShower?.show(KiwixDialog.ClearAllHistory, {
lifecycleScope.launch {
presenter?.clearHistory() presenter?.clearHistory()
Snackbar.make(requireView(), R.string.all_history_cleared, Snackbar.LENGTH_SHORT).show() Snackbar.make(requireView(), R.string.all_history_cleared, Snackbar.LENGTH_SHORT).show()
}
}) })
} }

View File

@ -22,6 +22,6 @@ import org.kiwix.kiwixmobile.core.base.BaseContract
interface SettingsContract { interface SettingsContract {
interface View : BaseContract.View<Presenter?> interface View : BaseContract.View<Presenter?>
interface Presenter : BaseContract.Presenter<View?> { interface Presenter : BaseContract.Presenter<View?> {
fun clearHistory() suspend fun clearHistory()
} }
} }

View File

@ -17,28 +17,20 @@
*/ */
package org.kiwix.kiwixmobile.core.settings package org.kiwix.kiwixmobile.core.settings
import org.kiwix.kiwixmobile.core.utils.files.Log
import io.reactivex.disposables.Disposable
import org.kiwix.kiwixmobile.core.base.BasePresenter import org.kiwix.kiwixmobile.core.base.BasePresenter
import org.kiwix.kiwixmobile.core.data.DataSource import org.kiwix.kiwixmobile.core.data.DataSource
import org.kiwix.kiwixmobile.core.settings.SettingsContract.Presenter import org.kiwix.kiwixmobile.core.settings.SettingsContract.Presenter
import org.kiwix.kiwixmobile.core.settings.SettingsContract.View import org.kiwix.kiwixmobile.core.settings.SettingsContract.View
import org.kiwix.kiwixmobile.core.utils.files.Log
import javax.inject.Inject import javax.inject.Inject
internal class SettingsPresenter @Inject constructor(private val dataSource: DataSource) : internal class SettingsPresenter @Inject constructor(private val dataSource: DataSource) :
BasePresenter<View?>(), Presenter { BasePresenter<View?>(), Presenter {
private var dataSourceDisposable: Disposable? = null override suspend fun clearHistory() {
override fun clearHistory() { runCatching {
dataSourceDisposable =
dataSource.clearHistory() dataSource.clearHistory()
.subscribe({ }.onFailure {
// TODO Log.e("SettingsPresenter", it.message, it)
}, { e -> }
Log.e("SettingsPresenter", e.message, e)
})
}
fun dispose() {
dataSourceDisposable?.dispose()
} }
} }

View File

@ -22,13 +22,11 @@ import io.mockk.clearAllMocks
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import io.mockk.verify import io.mockk.verify
import io.reactivex.schedulers.Schedulers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
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.AfterAll
import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.kiwix.kiwixmobile.core.dao.DownloadRoomDao import org.kiwix.kiwixmobile.core.dao.DownloadRoomDao
@ -44,8 +42,6 @@ import org.kiwix.kiwixmobile.core.utils.files.testFlow
import org.kiwix.kiwixmobile.core.zim_manager.fileselect_view.BooksOnDiskListItem.BookOnDisk import org.kiwix.kiwixmobile.core.zim_manager.fileselect_view.BooksOnDiskListItem.BookOnDisk
import org.kiwix.sharedFunctions.book import org.kiwix.sharedFunctions.book
import org.kiwix.sharedFunctions.bookOnDisk import org.kiwix.sharedFunctions.bookOnDisk
import org.kiwix.sharedFunctions.resetSchedulers
import org.kiwix.sharedFunctions.setScheduler
import java.io.File import java.io.File
class StorageObserverTest { class StorageObserverTest {
@ -65,15 +61,6 @@ class StorageObserverTest {
private lateinit var storageObserver: StorageObserver private lateinit var storageObserver: StorageObserver
init {
setScheduler(Schedulers.trampoline())
}
@AfterAll
fun teardown() {
resetSchedulers()
}
@BeforeEach fun init() { @BeforeEach fun init() {
clearAllMocks() clearAllMocks()
every { sharedPreferenceUtil.prefStorage } returns "a" every { sharedPreferenceUtil.prefStorage } returns "a"

View File

@ -27,7 +27,6 @@ import android.view.ViewGroup
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import io.reactivex.disposables.CompositeDisposable
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
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
@ -71,8 +70,6 @@ class CustomDownloadFragment : BaseFragment(), FragmentActivityExtensions {
@Inject lateinit var viewModelFactory: ViewModelProvider.Factory @Inject lateinit var viewModelFactory: ViewModelProvider.Factory
private var fragmentCustomDownloadBinding: FragmentCustomDownloadBinding? = null private var fragmentCustomDownloadBinding: FragmentCustomDownloadBinding? = null
private val compositeDisposable = CompositeDisposable()
override fun inject(baseActivity: BaseActivity) { override fun inject(baseActivity: BaseActivity) {
baseActivity.customActivityComponent.inject(this) baseActivity.customActivityComponent.inject(this)
} }
@ -144,7 +141,6 @@ class CustomDownloadFragment : BaseFragment(), FragmentActivityExtensions {
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
compositeDisposable.clear()
activity?.finish() activity?.finish()
} }

View File

@ -18,8 +18,8 @@
package org.kiwix.kiwixmobile.custom.download.effects package org.kiwix.kiwixmobile.custom.download.effects
import io.mockk.coVerify
import io.mockk.mockk import io.mockk.mockk
import io.mockk.verify
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.kiwix.kiwixmobile.core.downloader.Downloader import org.kiwix.kiwixmobile.core.downloader.Downloader
import org.kiwix.sharedFunctions.book import org.kiwix.sharedFunctions.book
@ -29,7 +29,7 @@ internal class DownloadCustomTest {
fun `invokeWith queues download with ZimUrl`() { fun `invokeWith queues download with ZimUrl`() {
val downloader = mockk<Downloader>() val downloader = mockk<Downloader>()
DownloadCustom(downloader).invokeWith(mockk()) DownloadCustom(downloader).invokeWith(mockk())
verify { coVerify {
downloader.download(expectedBook()) downloader.download(expectedBook())
} }
} }