From f772501159f235758940ec5e2ad17637541f7673 Mon Sep 17 00:00:00 2001 From: NerdNumber9 Date: Fri, 12 Apr 2019 06:50:24 -0400 Subject: [PATCH] Library search is now asynchronous Library will now display progress bar while loading More optimizations to library search --- .../ui/library/LibraryCategoryAdapter.kt | 94 ++++++++++++------- .../ui/library/LibraryCategoryView.kt | 57 ++++++++++- .../tachiyomi/ui/library/LibraryController.kt | 11 +++ app/src/main/java/exh/EHSourceHelpers.kt | 11 ++- app/src/main/java/exh/ui/LoadingTools.kt | 55 +++++++++++ .../main/res/layout/library_controller.xml | 7 ++ 6 files changed, 192 insertions(+), 43 deletions(-) create mode 100644 app/src/main/java/exh/ui/LoadingTools.kt diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryCategoryAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryCategoryAdapter.kt index beb4f5afe..2de8331a6 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryCategoryAdapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryCategoryAdapter.kt @@ -7,6 +7,10 @@ import eu.kanade.tachiyomi.data.database.models.Manga import exh.isLewdSource import exh.metadata.sql.tables.SearchMetadataTable import exh.search.SearchEngine +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.asFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.toList import timber.log.Timber import uy.kohesive.injekt.injectLazy @@ -17,15 +21,16 @@ import uy.kohesive.injekt.injectLazy */ class LibraryCategoryAdapter(val view: LibraryCategoryView) : FlexibleAdapter(null, view, true) { - // --> EH + // EXH --> private val db: DatabaseHelper by injectLazy() private val searchEngine = SearchEngine() + private var lastFilterJob: Job? = null // Keep compatibility as searchText field was replaced when we upgraded FlexibleAdapter var searchText get() = getFilter(String::class.java) ?: "" set(value) { setFilter(value) } - // <-- EH + // EXH <-- /** * The list of manga in this category. @@ -37,11 +42,11 @@ class LibraryCategoryAdapter(val view: LibraryCategoryView) : * * @param list the list to set. */ - fun setItems(list: List) { + suspend fun setItems(cScope: CoroutineScope, list: List) { // A copy of manga always unfiltered. mangas = list.toList() - performFilter() + performFilter(cScope) } /** @@ -53,45 +58,66 @@ class LibraryCategoryAdapter(val view: LibraryCategoryView) : return currentItems.indexOfFirst { it.manga.id == manga.id } } - fun performFilter() { - if(searchText.isNotBlank()) { - // EXH --> - try { - val startTime = System.currentTimeMillis() + // EXH --> + // Note that we cannot use FlexibleAdapter's built in filtering system as we cannot cancel it + // (well technically we can cancel it by invoking filterItems again but that doesn't work when + // we want to perform a no-op filter) + suspend fun performFilter(cScope: CoroutineScope) { + lastFilterJob?.cancel() + if(mangas.isNotEmpty() && searchText.isNotBlank()) { + val savedSearchText = searchText - val parsedQuery = searchEngine.parseQuery(searchText) - val sqlQuery = searchEngine.queryToSql(parsedQuery) - val queryResult = db.lowLevel().rawQuery(RawQuery.builder() - .query(sqlQuery.first) - .args(*sqlQuery.second.toTypedArray()) - .build()) + val job = cScope.launch(Dispatchers.IO) { + val newManga = try { + // Prepare filter object + val parsedQuery = searchEngine.parseQuery(savedSearchText) + val sqlQuery = searchEngine.queryToSql(parsedQuery) + val queryResult = db.lowLevel().rawQuery(RawQuery.builder() + .query(sqlQuery.first) + .args(*sqlQuery.second.toTypedArray()) + .build()) - val convertedResult = ArrayList(queryResult.count) - val mangaIdCol = queryResult.getColumnIndex(SearchMetadataTable.COL_MANGA_ID) - queryResult.moveToFirst() - while(queryResult.count > 0 && !queryResult.isAfterLast) { - convertedResult += queryResult.getLong(mangaIdCol) - queryResult.moveToNext() - } + if(!isActive) return@launch // Fail early when cancelled - val out = mangas.filter { - if(isLewdSource(it.manga.source)) { - convertedResult.binarySearch(it.manga.id) >= 0 - } else { - it.filter(searchText) + val convertedResult = LongArray(queryResult.count) + if(convertedResult.isNotEmpty()) { + val mangaIdCol = queryResult.getColumnIndex(SearchMetadataTable.COL_MANGA_ID) + queryResult.moveToFirst() + while (!queryResult.isAfterLast) { + if(!isActive) return@launch // Fail early when cancelled + + convertedResult[queryResult.position] = queryResult.getLong(mangaIdCol) + queryResult.moveToNext() + } } + + if(!isActive) return@launch // Fail early when cancelled + + // Flow the mangas to allow cancellation of this filter operation + mangas.asFlow().filter { item -> + if(isLewdSource(item.manga.source)) { + convertedResult.binarySearch(item.manga.id ?: -1) >= 0 + } else { + item.filter(savedSearchText) + } + }.toList() + } catch (e: Exception) { + // Do not catch cancellations + if(e is CancellationException) throw e + + Timber.w(e, "Could not filter mangas!") + mangas } - Timber.d("===> Took %s milliseconds to filter manga!", System.currentTimeMillis() - startTime) - - updateDataSet(out) - } catch(e: Exception) { - Timber.w(e, "Could not filter mangas!") - updateDataSet(mangas) + withContext(Dispatchers.Main) { + updateDataSet(newManga) + } } - // EXH <-- + lastFilterJob = job + job.join() } else { updateDataSet(mangas) } } + // EXH <-- } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryCategoryView.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryCategoryView.kt index 49b998b68..f9a821c04 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryCategoryView.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryCategoryView.kt @@ -18,7 +18,9 @@ import eu.kanade.tachiyomi.util.inflate import eu.kanade.tachiyomi.util.plusAssign import eu.kanade.tachiyomi.util.toast import eu.kanade.tachiyomi.widget.AutofitRecyclerView +import exh.ui.LoadingHandle import kotlinx.android.synthetic.main.library_category.view.* +import kotlinx.coroutines.* import rx.android.schedulers.AndroidSchedulers import rx.subscriptions.CompositeSubscription import uy.kohesive.injekt.injectLazy @@ -63,6 +65,15 @@ class LibraryCategoryView @JvmOverloads constructor(context: Context, attrs: Att */ private var subscriptions = CompositeSubscription() + // EXH --> + private var initialLoadHandle: LoadingHandle? = null + lateinit var scope: CoroutineScope + + private fun newScope() = object : CoroutineScope { + override val coroutineContext = SupervisorJob() + Dispatchers.Main + } + // EXH <-- + fun onCreate(controller: LibraryController) { this.controller = controller @@ -112,28 +123,62 @@ class LibraryCategoryView @JvmOverloads constructor(context: Context, attrs: Att SelectableAdapter.Mode.SINGLE } + // EXH --> + scope = newScope() + initialLoadHandle = controller.loaderManager.openProgressBar() + // EXH <-- + subscriptions += controller.searchRelay .doOnNext { adapter.searchText = it } .skip(1) .debounce(500, TimeUnit.MILLISECONDS) .observeOn(AndroidSchedulers.mainThread()) - .subscribe { adapter.performFilter() } + .subscribe { + // EXH --> + scope.launch { + val handle = controller.loaderManager.openProgressBar() + try { + // EXH <-- + adapter.performFilter(this) + // EXH --> + } finally { + controller.loaderManager.closeProgressBar(handle) + } + } + // EXH <-- + } subscriptions += controller.libraryMangaRelay - .subscribe { onNextLibraryManga(it) } + .subscribe { + // EXH --> + scope.launch { + try { + // EXH <-- + onNextLibraryManga(this, it) + // EXH --> + } finally { + controller.loaderManager.closeProgressBar(initialLoadHandle) + } + } + // EXH <-- + } subscriptions += controller.selectionRelay .subscribe { onSelectionChanged(it) } } fun onRecycle() { - adapter.setItems(emptyList()) + runBlocking { adapter.setItems(this, emptyList()) } adapter.clearSelection() unsubscribe() } fun unsubscribe() { subscriptions.clear() + // EXH --> + scope.cancel() + controller.loaderManager.closeProgressBar(initialLoadHandle) + // EXH <-- } /** @@ -142,12 +187,14 @@ class LibraryCategoryView @JvmOverloads constructor(context: Context, attrs: Att * * @param event the event received. */ - fun onNextLibraryManga(event: LibraryMangaEvent) { + suspend fun onNextLibraryManga(cScope: CoroutineScope, event: LibraryMangaEvent) { // Get the manga list for this category. val mangaForCategory = event.getMangaForCategory(category).orEmpty() // Update the category with its manga. - adapter.setItems(mangaForCategory) + // EXH --> + adapter.setItems(cScope, mangaForCategory) + // EXH <-- if (adapter.mode == SelectableAdapter.Mode.MULTI) { controller.selectedMangas.forEach { manga -> diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryController.kt index 931fdf06d..8916b9c51 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryController.kt @@ -38,6 +38,7 @@ import eu.kanade.tachiyomi.util.inflate import eu.kanade.tachiyomi.util.toast import exh.favorites.FavoritesIntroDialog import exh.favorites.FavoritesSyncStatus +import exh.ui.LoaderManager import kotlinx.android.synthetic.main.library_controller.* import kotlinx.android.synthetic.main.main_activity.* import rx.Subscription @@ -131,6 +132,7 @@ class LibraryController( private var oldSyncStatus: FavoritesSyncStatus? = null //Favorites private var favoritesSyncSubscription: Subscription? = null + val loaderManager = LoaderManager() // <-- EH init { @@ -169,6 +171,12 @@ class LibraryController( if (selectedMangas.isNotEmpty()) { createActionModeIfNeeded() } + + // EXH --> + loaderManager.loadingChangeListener = { + library_progress.visibility = if(it) View.VISIBLE else View.GONE + } + // EXH <-- } override fun onChangeStarted(handler: ControllerChangeHandler, type: ControllerChangeType) { @@ -185,6 +193,9 @@ class LibraryController( actionMode = null tabsVisibilitySubscription?.unsubscribe() tabsVisibilitySubscription = null + // EXH --> + loaderManager.loadingChangeListener = null + // EXH <-- super.onDestroyView(view) } diff --git a/app/src/main/java/exh/EHSourceHelpers.kt b/app/src/main/java/exh/EHSourceHelpers.kt index 755495465..bb1468069 100755 --- a/app/src/main/java/exh/EHSourceHelpers.kt +++ b/app/src/main/java/exh/EHSourceHelpers.kt @@ -28,8 +28,11 @@ private inline fun delegatedSourceId(): Long { }!!.value.sourceId } -fun isLewdSource(source: Long) = source in 6900..6999 || SourceManager.DELEGATED_SOURCES.filter { +// Used to speed up isLewdSource +private val lewdDelegatedSourceIds = SourceManager.DELEGATED_SOURCES.filter { it.value.newSourceClass in DELEGATED_LEWD_SOURCES -}.any { - it.value.sourceId == source -} +}.map { it.value.sourceId }.sorted() + +// This method MUST be fast! +fun isLewdSource(source: Long) = source in 6900..6999 + || lewdDelegatedSourceIds.binarySearch(source) >= 0 diff --git a/app/src/main/java/exh/ui/LoadingTools.kt b/app/src/main/java/exh/ui/LoadingTools.kt new file mode 100644 index 000000000..1d0f64221 --- /dev/null +++ b/app/src/main/java/exh/ui/LoadingTools.kt @@ -0,0 +1,55 @@ +package exh.ui + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.util.* +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext + +typealias LoadingHandle = String + +/** + * Class used to manage loader UIs + */ +class LoaderManager(parentContext: CoroutineContext = EmptyCoroutineContext): CoroutineScope { + override val coroutineContext = Dispatchers.Main + parentContext + + private val openLoadingHandles = mutableListOf() + var loadingChangeListener: (suspend (newIsLoading: Boolean) -> Unit)? = null + + fun openProgressBar(): LoadingHandle { + val (handle, shouldUpdateLoadingStatus) = synchronized(this) { + val handle = UUID.randomUUID().toString() + openLoadingHandles += handle + handle to (openLoadingHandles.size == 1) + } + + if(shouldUpdateLoadingStatus) { + launch { + updateLoadingStatus(true) + } + } + + return handle + } + + @Synchronized + fun closeProgressBar(handle: LoadingHandle?) { + if(handle == null) return + + val shouldUpdateLoadingStatus = synchronized(this) { + openLoadingHandles.remove(handle) && openLoadingHandles.isEmpty() + } + + if(shouldUpdateLoadingStatus) { + launch { + updateLoadingStatus(false) + } + } + } + + private suspend fun updateLoadingStatus(newStatus: Boolean) { + loadingChangeListener?.invoke(newStatus) + } +} diff --git a/app/src/main/res/layout/library_controller.xml b/app/src/main/res/layout/library_controller.xml index b0bed0ca5..549db7424 100644 --- a/app/src/main/res/layout/library_controller.xml +++ b/app/src/main/res/layout/library_controller.xml @@ -8,6 +8,13 @@ android:layout_height="match_parent" android:id="@+id/library_pager"/> + +