mirror of
https://github.com/mihonapp/mihon.git
synced 2026-10-16 13:48:09 -07:00
Improve performance across screens (#4071)
* Keep screens loaded while they're hidden and rebuild the library faster * Count chapters in SQL for the migration list * Show the extension list sooner and check trust against loaded stores * Cancel the previous tracker search when searching again * Write reader progress less often and keep its work off the main thread * Stop rebuilding the settings search index on every key * Watch updates for widgets only while one is placed The query re-ran on every chapter or manga write even with no widget on the home screen. * Observe download pages and limit how often their progress updates * Throttle the library update notification and write fetch data together
This commit is contained in:
@@ -225,11 +225,10 @@ class SyncChaptersWithSource(
|
|||||||
added = toAdd,
|
added = toAdd,
|
||||||
updated = updatedChapters,
|
updated = updatedChapters,
|
||||||
)
|
)
|
||||||
updateManga.awaitUpdateFetchInterval(manga, timeZone, now, fetchWindow)
|
|
||||||
|
|
||||||
// Set this manga as updated since chapters were changed
|
// Set this manga as updated since chapters were changed
|
||||||
// Note that last_update actually represents last time the chapter list changed at all
|
// Note that last_update actually represents last time the chapter list changed at all
|
||||||
updateManga.awaitUpdateLastUpdate(manga.id)
|
updateManga.awaitUpdateFetchIntervalAndLastUpdate(manga, timeZone, now, fetchWindow)
|
||||||
|
|
||||||
val excludedScanlators = getExcludedScanlators.await(manga.id).toHashSet()
|
val excludedScanlators = getExcludedScanlators.await(manga.id).toHashSet()
|
||||||
|
|
||||||
|
|||||||
@@ -30,11 +30,10 @@ class GetExtensionsByType(
|
|||||||
|
|
||||||
val updates = (loadedUpdates + notLoadedUpdates).sortedWith(byName)
|
val updates = (loadedUpdates + notLoadedUpdates).sortedWith(byName)
|
||||||
|
|
||||||
|
val installedSignatures = (_loaded + _notLoaded).groupBy({ it.pkgName }, { it.signatures })
|
||||||
val available = _available
|
val available = _available
|
||||||
.filter { extension ->
|
.filter { extension ->
|
||||||
(_loaded + _notLoaded).none {
|
installedSignatures[extension.pkgName].orEmpty().none { extension.store.signingKey in it } &&
|
||||||
it.pkgName == extension.pkgName && extension.store.signingKey in it.signatures
|
|
||||||
} &&
|
|
||||||
extension.contentWarning in enabledContentWarnings
|
extension.contentWarning in enabledContentWarnings
|
||||||
}
|
}
|
||||||
.flatMap { ext ->
|
.flatMap { ext ->
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged
|
|||||||
import kotlinx.coroutines.flow.drop
|
import kotlinx.coroutines.flow.drop
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.merge
|
import kotlinx.coroutines.flow.merge
|
||||||
|
import mihon.domain.extension.model.ExtensionStore
|
||||||
import mihon.domain.extension.repository.ExtensionStoreRepository
|
import mihon.domain.extension.repository.ExtensionStoreRepository
|
||||||
import tachiyomi.core.common.preference.getAndSet
|
import tachiyomi.core.common.preference.getAndSet
|
||||||
|
|
||||||
@@ -18,8 +19,8 @@ class TrustExtension(
|
|||||||
private val preferences: SourcePreferences,
|
private val preferences: SourcePreferences,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
suspend fun isTrusted(pkgInfo: PackageInfo, fingerprints: List<String>): Boolean {
|
fun isTrusted(pkgInfo: PackageInfo, fingerprints: List<String>, stores: List<ExtensionStore>): Boolean {
|
||||||
val trustedFingerprints = repository.getAll().map { it.signingKey }.toHashSet()
|
val trustedFingerprints = stores.mapTo(HashSet()) { it.signingKey }
|
||||||
val key = "${pkgInfo.packageName}:${PackageInfoCompat.getLongVersionCode(pkgInfo)}:${fingerprints.last()}"
|
val key = "${pkgInfo.packageName}:${PackageInfoCompat.getLongVersionCode(pkgInfo)}:${fingerprints.last()}"
|
||||||
return trustedFingerprints.any { fingerprints.contains(it) } || key in preferences.trustedExtensions.get()
|
return trustedFingerprints.any { fingerprints.contains(it) } || key in preferences.trustedExtensions.get()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,8 +36,20 @@ class UpdateManga(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun awaitUpdateLastUpdate(mangaId: Long): Boolean {
|
/**
|
||||||
return mangaRepository.update(MangaUpdate(mangaId) { lastUpdate = Clock.System.now() })
|
* Updates the fetch interval and marks the chapter list as changed in a single write.
|
||||||
|
*/
|
||||||
|
suspend fun awaitUpdateFetchIntervalAndLastUpdate(
|
||||||
|
manga: Manga,
|
||||||
|
timeZone: TimeZone,
|
||||||
|
dateTime: LocalDateTime,
|
||||||
|
window: ClosedRange<Instant>?,
|
||||||
|
): Boolean {
|
||||||
|
return mangaRepository.update(
|
||||||
|
fetchInterval.withFetchInterval(manga, dateTime, timeZone, window).apply {
|
||||||
|
lastUpdate = Clock.System.now()
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun awaitUpdateCoverLastModified(mangaId: Long): Boolean {
|
suspend fun awaitUpdateCoverLastModified(mangaId: Long): Boolean {
|
||||||
|
|||||||
+57
-50
@@ -30,6 +30,7 @@ import androidx.compose.runtime.NonRestartableComposable
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.produceState
|
import androidx.compose.runtime.produceState
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
@@ -48,6 +49,7 @@ import cafe.adriel.voyager.navigator.currentOrThrow
|
|||||||
import eu.kanade.presentation.components.UpIcon
|
import eu.kanade.presentation.components.UpIcon
|
||||||
import eu.kanade.presentation.more.settings.Preference
|
import eu.kanade.presentation.more.settings.Preference
|
||||||
import eu.kanade.presentation.util.Screen
|
import eu.kanade.presentation.util.Screen
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
import mihon.icons.materialsymbols.MaterialSymbols
|
import mihon.icons.materialsymbols.MaterialSymbols
|
||||||
import mihon.icons.materialsymbols.rounded.Close
|
import mihon.icons.materialsymbols.rounded.Close
|
||||||
import tachiyomi.i18n.MR
|
import tachiyomi.i18n.MR
|
||||||
@@ -141,7 +143,7 @@ class SettingsSearchScreen : Screen() {
|
|||||||
},
|
},
|
||||||
) { contentPadding ->
|
) { contentPadding ->
|
||||||
SearchResult(
|
SearchResult(
|
||||||
searchKey = textFieldState.text.toString(),
|
searchKey = { textFieldState.text.toString() },
|
||||||
listState = listState,
|
listState = listState,
|
||||||
contentPadding = contentPadding,
|
contentPadding = contentPadding,
|
||||||
) { result ->
|
) { result ->
|
||||||
@@ -154,64 +156,20 @@ class SettingsSearchScreen : Screen() {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SearchResult(
|
private fun SearchResult(
|
||||||
searchKey: String,
|
searchKey: () -> String,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
listState: LazyListState = rememberLazyListState(),
|
listState: LazyListState = rememberLazyListState(),
|
||||||
contentPadding: PaddingValues = PaddingValues(),
|
contentPadding: PaddingValues = PaddingValues(),
|
||||||
onItemClick: (SearchResultItem) -> Unit,
|
onItemClick: (SearchResultItem) -> Unit,
|
||||||
) {
|
) {
|
||||||
if (searchKey.isEmpty()) return
|
|
||||||
|
|
||||||
val isLtr = LocalLayoutDirection.current == LayoutDirection.Ltr
|
val isLtr = LocalLayoutDirection.current == LayoutDirection.Ltr
|
||||||
|
|
||||||
val index = getIndex()
|
val index = getIndex()
|
||||||
val result by produceState<List<SearchResultItem>?>(initialValue = null, searchKey) {
|
// The query is read here rather than in composition, so typing doesn't rebuild the index on every key
|
||||||
value = index.asSequence()
|
val result by produceState<List<SearchResultItem>?>(initialValue = null, index) {
|
||||||
.flatMap { settingsData ->
|
snapshotFlow(searchKey).collectLatest { searchKey ->
|
||||||
fun result(title: String, categoryTitle: String?) = SearchResultItem(
|
value = if (searchKey.isEmpty()) null else search(index, searchKey, isLtr)
|
||||||
route = settingsData.route,
|
|
||||||
title = title,
|
|
||||||
breadcrumbs = getLocalizedBreadcrumb(
|
|
||||||
path = settingsData.title,
|
|
||||||
node = categoryTitle,
|
|
||||||
isLtr = isLtr,
|
|
||||||
),
|
|
||||||
highlightKey = title,
|
|
||||||
)
|
|
||||||
|
|
||||||
fun Preference.PreferenceItem<*, *>.matches(): Boolean {
|
|
||||||
// Don't show info preference
|
|
||||||
if (this is Preference.PreferenceItem.InfoPreference) return false
|
|
||||||
val inTitle = title.contains(searchKey, true)
|
|
||||||
val inSummary = subtitle?.contains(searchKey, true) ?: false
|
|
||||||
return inTitle || inSummary
|
|
||||||
}
|
}
|
||||||
|
|
||||||
settingsData.contents.asSequence()
|
|
||||||
// Only search from enabled prefs and one with valid title
|
|
||||||
.filter { it.visible && it.title.isNotBlank() }
|
|
||||||
.flatMap { p ->
|
|
||||||
when (p) {
|
|
||||||
is Preference.PreferenceGroup -> {
|
|
||||||
// A matching heading leads to the group itself, not to one of its items
|
|
||||||
val heading = if (p.title.contains(searchKey, true)) {
|
|
||||||
sequenceOf(result(p.title, null))
|
|
||||||
} else {
|
|
||||||
emptySequence()
|
|
||||||
}
|
|
||||||
val items = p.preferenceItems.asSequence()
|
|
||||||
.filter { it.visible && it.title.isNotBlank() && it.matches() }
|
|
||||||
.map { result(it.title, p.title) }
|
|
||||||
heading + items
|
|
||||||
}
|
|
||||||
is Preference.PreferenceItem<*, *> -> {
|
|
||||||
if (p.matches()) sequenceOf(result(p.title, null)) else emptySequence()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.take(10) // Just take top 10 result for quicker result
|
|
||||||
.toList()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Crossfade(
|
Crossfade(
|
||||||
@@ -262,6 +220,55 @@ private fun SearchResult(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun search(index: List<SettingsData>, searchKey: String, isLtr: Boolean): List<SearchResultItem> {
|
||||||
|
return index.asSequence()
|
||||||
|
.flatMap { settingsData ->
|
||||||
|
fun result(title: String, categoryTitle: String?) = SearchResultItem(
|
||||||
|
route = settingsData.route,
|
||||||
|
title = title,
|
||||||
|
breadcrumbs = getLocalizedBreadcrumb(
|
||||||
|
path = settingsData.title,
|
||||||
|
node = categoryTitle,
|
||||||
|
isLtr = isLtr,
|
||||||
|
),
|
||||||
|
highlightKey = title,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun Preference.PreferenceItem<*, *>.matches(): Boolean {
|
||||||
|
// Don't show info preference
|
||||||
|
if (this is Preference.PreferenceItem.InfoPreference) return false
|
||||||
|
val inTitle = title.contains(searchKey, true)
|
||||||
|
val inSummary = subtitle?.contains(searchKey, true) ?: false
|
||||||
|
return inTitle || inSummary
|
||||||
|
}
|
||||||
|
|
||||||
|
settingsData.contents.asSequence()
|
||||||
|
// Only search from enabled prefs and one with valid title
|
||||||
|
.filter { it.visible && it.title.isNotBlank() }
|
||||||
|
.flatMap { p ->
|
||||||
|
when (p) {
|
||||||
|
is Preference.PreferenceGroup -> {
|
||||||
|
// A matching heading leads to the group itself, not to one of its items
|
||||||
|
val heading = if (p.title.contains(searchKey, true)) {
|
||||||
|
sequenceOf(result(p.title, null))
|
||||||
|
} else {
|
||||||
|
emptySequence()
|
||||||
|
}
|
||||||
|
val items = p.preferenceItems.asSequence()
|
||||||
|
.filter { it.visible && it.title.isNotBlank() && it.matches() }
|
||||||
|
.map { result(it.title, p.title) }
|
||||||
|
heading + items
|
||||||
|
}
|
||||||
|
is Preference.PreferenceItem<*, *> -> {
|
||||||
|
if (p.matches()) sequenceOf(result(p.title, null)) else emptySequence()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.take(10) // Just take top 10 result for quicker result
|
||||||
|
.toList()
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@NonRestartableComposable
|
@NonRestartableComposable
|
||||||
private fun getIndex() = settingScreens
|
private fun getIndex() = settingScreens
|
||||||
|
|||||||
@@ -211,6 +211,27 @@ class DownloadCache(
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the amount of downloaded chapters for each of the given manga, keyed by manga id.
|
||||||
|
*
|
||||||
|
* @param manga the manga to check.
|
||||||
|
*/
|
||||||
|
fun getDownloadCounts(manga: List<Manga>): Map<Long, Int> {
|
||||||
|
renewCache()
|
||||||
|
|
||||||
|
val sourceDirs = rootDownloadsDir.sourceDirs
|
||||||
|
return manga.associate {
|
||||||
|
val mangaDirs = sourceDirs[it.source]?.mangaDirs
|
||||||
|
// Building the directory name is the costly part, so it's skipped for sources without downloads
|
||||||
|
val mangaDir = if (mangaDirs.isNullOrEmpty()) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
mangaDirs[provider.getMangaDirName(it.title)]
|
||||||
|
}
|
||||||
|
it.id to (mangaDir?.chapterDirs?.size ?: 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adds a chapter that has just been download to this cache.
|
* Adds a chapter that has just been download to this cache.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -246,6 +246,15 @@ class DownloadManager(
|
|||||||
return cache.getDownloadCount(manga)
|
return cache.getDownloadCount(manga)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the amount of downloaded chapters for each of the given manga, keyed by manga id.
|
||||||
|
*
|
||||||
|
* @param manga the manga to check.
|
||||||
|
*/
|
||||||
|
fun getDownloadCounts(manga: List<Manga>): Map<Long, Int> {
|
||||||
|
return cache.getDownloadCounts(manga)
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun cancelQueuedDownloads(downloads: List<Download>) {
|
suspend fun cancelQueuedDownloads(downloads: List<Download>) {
|
||||||
downloader.dequeue(downloads.map { it.chapter })
|
downloader.dequeue(downloads.map { it.chapter })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.data.download
|
|||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
|
import android.os.SystemClock
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import dev.zacsweers.metro.Inject
|
import dev.zacsweers.metro.Inject
|
||||||
import eu.kanade.tachiyomi.R
|
import eu.kanade.tachiyomi.R
|
||||||
@@ -62,14 +63,23 @@ class DownloadNotifier(
|
|||||||
context.cancelNotification(Notifications.ID_DOWNLOAD_CHAPTER_PAUSED)
|
context.cancelNotification(Notifications.ID_DOWNLOAD_CHAPTER_PAUSED)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var lastProgressNotificationTime = 0L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Called when download progress changes.
|
* Called when download progress changes.
|
||||||
*
|
*
|
||||||
|
* Called for every downloaded page from parallel downloads, so it shares the builder under a lock and skips
|
||||||
|
* updates that come faster than the system would show them anyway.
|
||||||
|
*
|
||||||
* @param download download object containing download information.
|
* @param download download object containing download information.
|
||||||
*/
|
*/
|
||||||
// Pages report their progress from several threads, and they share the builder
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun onProgressChange(download: Download) {
|
fun onProgressChange(download: Download) {
|
||||||
|
val now = SystemClock.elapsedRealtime()
|
||||||
|
val isComplete = download.downloadedImages == download.pages?.size
|
||||||
|
if (!isComplete && now - lastProgressNotificationTime < PROGRESS_NOTIFICATION_INTERVAL_MS) return
|
||||||
|
lastProgressNotificationTime = now
|
||||||
|
|
||||||
with(progressNotificationBuilder) {
|
with(progressNotificationBuilder) {
|
||||||
if (!isDownloading) {
|
if (!isDownloading) {
|
||||||
context.cancelNotification(Notifications.ID_DOWNLOAD_CHAPTER_PAUSED)
|
context.cancelNotification(Notifications.ID_DOWNLOAD_CHAPTER_PAUSED)
|
||||||
@@ -156,6 +166,7 @@ class DownloadNotifier(
|
|||||||
/**
|
/**
|
||||||
* Resets the state once downloads are completed.
|
* Resets the state once downloads are completed.
|
||||||
*/
|
*/
|
||||||
|
@Synchronized
|
||||||
fun onComplete() {
|
fun onComplete() {
|
||||||
dismissProgress()
|
dismissProgress()
|
||||||
|
|
||||||
@@ -229,3 +240,5 @@ class DownloadNotifier(
|
|||||||
isDownloading = false
|
isDownloading = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private const val PROGRESS_NOTIFICATION_INTERVAL_MS = 500L
|
||||||
|
|||||||
@@ -19,11 +19,14 @@ data class Download(
|
|||||||
val chapter: Chapter,
|
val chapter: Chapter,
|
||||||
) {
|
) {
|
||||||
@Transient
|
@Transient
|
||||||
private val pagesFlow = MutableStateFlow<List<Page>?>(null)
|
private val _pagesFlow = MutableStateFlow<List<Page>?>(null)
|
||||||
|
|
||||||
|
@Transient
|
||||||
|
val pagesFlow = _pagesFlow.asStateFlow()
|
||||||
var pages: List<Page>?
|
var pages: List<Page>?
|
||||||
get() = pagesFlow.value
|
get() = _pagesFlow.value
|
||||||
set(pages) {
|
set(pages) {
|
||||||
pagesFlow.value = pages
|
_pagesFlow.value = pages
|
||||||
}
|
}
|
||||||
|
|
||||||
val totalProgress: Int
|
val totalProgress: Int
|
||||||
@@ -44,7 +47,7 @@ data class Download(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Transient
|
@Transient
|
||||||
val progressFlow = pagesFlow
|
val progressFlow = _pagesFlow
|
||||||
.flatMapLatest { pages ->
|
.flatMapLatest { pages ->
|
||||||
if (pages == null) {
|
if (pages == null) {
|
||||||
flowOf(0)
|
flowOf(0)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import android.content.Intent
|
|||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.os.SystemClock
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.app.NotificationManagerCompat
|
import androidx.core.app.NotificationManagerCompat
|
||||||
import coil3.asDrawable
|
import coil3.asDrawable
|
||||||
@@ -85,14 +86,24 @@ class LibraryUpdateNotifier(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var lastProgressNotificationTime = 0L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shows the notification containing the currently updating manga and the progress.
|
* Shows the notification containing the currently updating manga and the progress.
|
||||||
*
|
*
|
||||||
|
* Called from several update coroutines at once, so it shares the builder under a lock and skips updates that
|
||||||
|
* come faster than the system would show them anyway.
|
||||||
|
*
|
||||||
* @param manga the manga that are being updated.
|
* @param manga the manga that are being updated.
|
||||||
* @param current the current progress.
|
* @param current the current progress.
|
||||||
* @param total the total progress.
|
* @param total the total progress.
|
||||||
*/
|
*/
|
||||||
|
@Synchronized
|
||||||
fun showProgressNotification(manga: List<Manga>, current: Int, total: Int) {
|
fun showProgressNotification(manga: List<Manga>, current: Int, total: Int) {
|
||||||
|
val now = SystemClock.elapsedRealtime()
|
||||||
|
if (current < total && now - lastProgressNotificationTime < NOTIF_PROGRESS_INTERVAL_MS) return
|
||||||
|
lastProgressNotificationTime = now
|
||||||
|
|
||||||
progressNotificationBuilder
|
progressNotificationBuilder
|
||||||
.setContentTitle(
|
.setContentTitle(
|
||||||
context.stringResource(
|
context.stringResource(
|
||||||
@@ -388,3 +399,4 @@ private const val NOTIF_MAX_CHAPTERS = 5
|
|||||||
private const val NOTIF_TITLE_MAX_LEN = 45
|
private const val NOTIF_TITLE_MAX_LEN = 45
|
||||||
private const val NOTIF_ICON_SIZE = 192
|
private const val NOTIF_ICON_SIZE = 192
|
||||||
private const val MANGA_PER_SOURCE_QUEUE_WARNING_THRESHOLD = 60
|
private const val MANGA_PER_SOURCE_QUEUE_WARNING_THRESHOLD = 60
|
||||||
|
private const val NOTIF_PROGRESS_INTERVAL_MS = 500L
|
||||||
|
|||||||
@@ -367,7 +367,7 @@ internal object ExtensionLoader {
|
|||||||
if (signatures.isEmpty()) {
|
if (signatures.isEmpty()) {
|
||||||
logcat(LogPriority.WARN) { "Package $pkgName isn't signed" }
|
logcat(LogPriority.WARN) { "Package $pkgName isn't signed" }
|
||||||
return notLoaded(Extension.NotLoaded.Reason.Unsigned, libVersion)
|
return notLoaded(Extension.NotLoaded.Reason.Unsigned, libVersion)
|
||||||
} else if (!trustExtension.isTrusted(pkgInfo, signatures)) {
|
} else if (!trustExtension.isTrusted(pkgInfo, signatures, stores)) {
|
||||||
logcat(LogPriority.WARN) { "Extension $pkgName isn't trusted" }
|
logcat(LogPriority.WARN) { "Extension $pkgName isn't trusted" }
|
||||||
return notLoaded(Extension.NotLoaded.Reason.Untrusted(signatures.last()), libVersion)
|
return notLoaded(Extension.NotLoaded.Reason.Untrusted(signatures.last()), libVersion)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import kotlinx.coroutines.flow.takeWhile
|
|||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import tachiyomi.i18n.MR
|
import tachiyomi.i18n.MR
|
||||||
|
import kotlin.time.Duration
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
@@ -69,7 +70,8 @@ class ExtensionsViewModel(
|
|||||||
@Suppress("LocalVariableName")
|
@Suppress("LocalVariableName")
|
||||||
private val items = combine(
|
private val items = combine(
|
||||||
searchQuery
|
searchQuery
|
||||||
.debounce(0.25.seconds)
|
// No query is the common case on every open, so don't delay it
|
||||||
|
.debounce { if (it.isNullOrEmpty()) Duration.ZERO else 0.25.seconds }
|
||||||
.map { searchQueryPredicate(it ?: "") },
|
.map { searchQueryPredicate(it ?: "") },
|
||||||
currentDownloads,
|
currentDownloads,
|
||||||
getExtensions.subscribe(),
|
getExtensions.subscribe(),
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import eu.kanade.tachiyomi.databinding.DownloadListBinding
|
|||||||
import eu.kanade.tachiyomi.source.model.Page
|
import eu.kanade.tachiyomi.source.model.Page
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.WhileSubscribed
|
import kotlinx.coroutines.flow.WhileSubscribed
|
||||||
@@ -22,6 +21,8 @@ import kotlinx.coroutines.flow.collectLatest
|
|||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.debounce
|
import kotlinx.coroutines.flow.debounce
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
@@ -213,11 +214,8 @@ class DownloadQueueViewModel(
|
|||||||
*/
|
*/
|
||||||
private fun launchProgressJob(download: Download) {
|
private fun launchProgressJob(download: Download) {
|
||||||
val job = viewModelScope.launch {
|
val job = viewModelScope.launch {
|
||||||
while (download.pages == null) {
|
val pages = download.pagesFlow.filterNotNull().first()
|
||||||
delay(50.milliseconds)
|
val progressFlows = pages.map(Page::progressFlow)
|
||||||
}
|
|
||||||
|
|
||||||
val progressFlows = download.pages!!.map(Page::progressFlow)
|
|
||||||
combine(progressFlows, Array<Int>::sum)
|
combine(progressFlows, Array<Int>::sum)
|
||||||
.distinctUntilChanged()
|
.distinctUntilChanged()
|
||||||
.debounce(50.milliseconds)
|
.debounce(50.milliseconds)
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import kotlinx.coroutines.flow.Flow
|
|||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.WhileSubscribed
|
|
||||||
import kotlinx.coroutines.flow.catch
|
import kotlinx.coroutines.flow.catch
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
@@ -32,10 +31,12 @@ import kotlinx.coroutines.flow.receiveAsFlow
|
|||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.plus
|
||||||
import logcat.LogPriority
|
import logcat.LogPriority
|
||||||
import mihon.core.metro.AppCoroutineScope
|
import mihon.core.metro.AppCoroutineScope
|
||||||
import tachiyomi.core.common.preference.CheckboxState
|
import tachiyomi.core.common.preference.CheckboxState
|
||||||
import tachiyomi.core.common.preference.mapAsCheckboxState
|
import tachiyomi.core.common.preference.mapAsCheckboxState
|
||||||
|
import tachiyomi.core.common.util.lang.SubscriberGate
|
||||||
import tachiyomi.core.common.util.system.logcat
|
import tachiyomi.core.common.util.system.logcat
|
||||||
import tachiyomi.domain.category.interactor.GetCategories
|
import tachiyomi.domain.category.interactor.GetCategories
|
||||||
import tachiyomi.domain.category.interactor.SetMangaCategories
|
import tachiyomi.domain.category.interactor.SetMangaCategories
|
||||||
@@ -71,6 +72,8 @@ class HistoryViewModel(
|
|||||||
private val sourceManager: SourceManager,
|
private val sourceManager: SourceManager,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val subscriberGate = SubscriberGate()
|
||||||
|
|
||||||
val snackbarHostState: SnackbarHostState = SnackbarHostState()
|
val snackbarHostState: SnackbarHostState = SnackbarHostState()
|
||||||
|
|
||||||
private val _events: Channel<Event> = Channel(Channel.UNLIMITED)
|
private val _events: Channel<Event> = Channel(Channel.UNLIMITED)
|
||||||
@@ -91,7 +94,7 @@ class HistoryViewModel(
|
|||||||
.map { it.toHistoryUiModels() }
|
.map { it.toHistoryUiModels() }
|
||||||
.flowOn(Dispatchers.Default)
|
.flowOn(Dispatchers.Default)
|
||||||
}
|
}
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), emptyList())
|
.stateIn(viewModelScope + subscriberGate, SharingStarted.Eagerly, emptyList())
|
||||||
|
|
||||||
val state: StateFlow<State> = combine(
|
val state: StateFlow<State> = combine(
|
||||||
searchQuery,
|
searchQuery,
|
||||||
@@ -100,7 +103,7 @@ class HistoryViewModel(
|
|||||||
) { searchQuery, history, dialog ->
|
) { searchQuery, history, dialog ->
|
||||||
State(searchQuery = searchQuery, list = history, dialog = dialog)
|
State(searchQuery = searchQuery, list = history, dialog = dialog)
|
||||||
}
|
}
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), State())
|
.stateIn(viewModelScope + subscriberGate, subscriberGate.whileSubscribed(5.seconds), State())
|
||||||
|
|
||||||
private fun List<HistoryWithRelations>.toHistoryUiModels(): List<HistoryUiModel> {
|
private fun List<HistoryWithRelations>.toHistoryUiModels(): List<HistoryUiModel> {
|
||||||
return map { HistoryUiModel.Item(it) }
|
return map { HistoryUiModel.Item(it) }
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package eu.kanade.tachiyomi.ui.library
|
package eu.kanade.tachiyomi.ui.library
|
||||||
|
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.activity.compose.ReportDrawnWhen
|
||||||
import androidx.compose.animation.graphics.res.animatedVectorResource
|
import androidx.compose.animation.graphics.res.animatedVectorResource
|
||||||
import androidx.compose.animation.graphics.res.rememberAnimatedVectorPainter
|
import androidx.compose.animation.graphics.res.rememberAnimatedVectorPainter
|
||||||
import androidx.compose.animation.graphics.vector.AnimatedImageVector
|
import androidx.compose.animation.graphics.vector.AnimatedImageVector
|
||||||
@@ -270,6 +271,8 @@ data object LibraryTab : Tab {
|
|||||||
(context as? MainActivity)?.ready = true
|
(context as? MainActivity)?.ready = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Lets startup be measured up to the library, not just its loading screen
|
||||||
|
ReportDrawnWhen { !state.isLoading }
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
launch { queryEvent.receiveAsFlow().collect(viewModel::search) }
|
launch { queryEvent.receiveAsFlow().collect(viewModel::search) }
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ import kotlinx.coroutines.flow.Flow
|
|||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.WhileSubscribed
|
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.debounce
|
import kotlinx.coroutines.flow.debounce
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
@@ -44,6 +43,7 @@ import kotlinx.coroutines.flow.map
|
|||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.plus
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import mihon.core.common.utils.mutate
|
import mihon.core.common.utils.mutate
|
||||||
import mihon.core.metro.AppCoroutineScope
|
import mihon.core.metro.AppCoroutineScope
|
||||||
@@ -51,7 +51,9 @@ import mihon.domain.library.model.search.QueryNode
|
|||||||
import mihon.feature.library.matches
|
import mihon.feature.library.matches
|
||||||
import tachiyomi.core.common.preference.CheckboxState
|
import tachiyomi.core.common.preference.CheckboxState
|
||||||
import tachiyomi.core.common.preference.TriState
|
import tachiyomi.core.common.preference.TriState
|
||||||
|
import tachiyomi.core.common.util.lang.SubscriberGate
|
||||||
import tachiyomi.core.common.util.lang.compareToWithCollator
|
import tachiyomi.core.common.util.lang.compareToWithCollator
|
||||||
|
import tachiyomi.core.common.util.lang.pausedWhileUnsubscribed
|
||||||
import tachiyomi.domain.category.interactor.GetCategories
|
import tachiyomi.domain.category.interactor.GetCategories
|
||||||
import tachiyomi.domain.category.interactor.SetMangaCategories
|
import tachiyomi.domain.category.interactor.SetMangaCategories
|
||||||
import tachiyomi.domain.category.model.Category
|
import tachiyomi.domain.category.model.Category
|
||||||
@@ -98,6 +100,8 @@ class LibraryViewModel(
|
|||||||
private val trackerManager: TrackerManager,
|
private val trackerManager: TrackerManager,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val subscriberGate = SubscriberGate()
|
||||||
|
|
||||||
private val searchQuery = MutableStateFlow<String?>(null)
|
private val searchQuery = MutableStateFlow<String?>(null)
|
||||||
|
|
||||||
private val selection = MutableStateFlow(emptySet</* Manga */ Long>())
|
private val selection = MutableStateFlow(emptySet</* Manga */ Long>())
|
||||||
@@ -140,7 +144,7 @@ class LibraryViewModel(
|
|||||||
.flatMapLatest { needsTracks -> if (needsTracks) getTracksPerManga.subscribe() else flowOf(emptyMap()) }
|
.flatMapLatest { needsTracks -> if (needsTracks) getTracksPerManga.subscribe() else flowOf(emptyMap()) }
|
||||||
|
|
||||||
// Shared separately so search, selection and dialog changes still reach [state] before the
|
// Shared separately so search, selection and dialog changes still reach [state] before the
|
||||||
// first query result, and so returning to the tab doesn't flash empty while it restarts.
|
// first query result
|
||||||
private val library = combine(
|
private val library = combine(
|
||||||
searchQuery.debounce(0.25.seconds),
|
searchQuery.debounce(0.25.seconds),
|
||||||
getCategories.subscribe(),
|
getCategories.subscribe(),
|
||||||
@@ -179,7 +183,7 @@ class LibraryViewModel(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
.flowOn(Dispatchers.Default)
|
.flowOn(Dispatchers.Default)
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), null)
|
.stateIn(viewModelScope + subscriberGate, SharingStarted.Eagerly, null)
|
||||||
|
|
||||||
val state: StateFlow<State> = combine(
|
val state: StateFlow<State> = combine(
|
||||||
library,
|
library,
|
||||||
@@ -200,7 +204,7 @@ class LibraryViewModel(
|
|||||||
groupedFavorites = library?.groupedFavorites.orEmpty(),
|
groupedFavorites = library?.groupedFavorites.orEmpty(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), State())
|
.stateIn(viewModelScope + subscriberGate, subscriberGate.whileSubscribed(5.seconds), State())
|
||||||
|
|
||||||
private data class DisplayPreferences(
|
private data class DisplayPreferences(
|
||||||
val showCategoryTabs: Boolean,
|
val showCategoryTabs: Boolean,
|
||||||
@@ -302,10 +306,9 @@ class LibraryViewModel(
|
|||||||
trackMap: Map<Long, List<Track>>,
|
trackMap: Map<Long, List<Track>>,
|
||||||
loggedInTrackerIds: Set<Long>,
|
loggedInTrackerIds: Set<Long>,
|
||||||
): Map<Category, List</* LibraryItem */ Long>> {
|
): Map<Category, List</* LibraryItem */ Long>> {
|
||||||
|
// The collator already ignores case, and lowercasing on every comparison is slow on large libraries
|
||||||
val sortAlphabetically: (LibraryItem, LibraryItem) -> Int = { manga1, manga2 ->
|
val sortAlphabetically: (LibraryItem, LibraryItem) -> Int = { manga1, manga2 ->
|
||||||
val title1 = manga1.libraryManga.manga.title.lowercase()
|
manga1.libraryManga.manga.title.compareToWithCollator(manga2.libraryManga.manga.title)
|
||||||
val title2 = manga2.libraryManga.manga.title.lowercase()
|
|
||||||
title1.compareToWithCollator(title2)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val defaultTrackerScoreSortValue = -1.0
|
val defaultTrackerScoreSortValue = -1.0
|
||||||
@@ -415,14 +418,15 @@ class LibraryViewModel(
|
|||||||
return combine(
|
return combine(
|
||||||
getLibraryManga.subscribe(),
|
getLibraryManga.subscribe(),
|
||||||
getLibraryItemPreferencesFlow(),
|
getLibraryItemPreferencesFlow(),
|
||||||
downloadCache.changes,
|
downloadCache.changes.pausedWhileUnsubscribed(),
|
||||||
) { libraryManga, preferences, _ ->
|
) { libraryManga, preferences, _ ->
|
||||||
val sources = libraryManga
|
val sources = libraryManga
|
||||||
.mapTo(mutableSetOf()) { it.manga.source }
|
.mapTo(mutableSetOf()) { it.manga.source }
|
||||||
.associateWith { sourceManager.getOrStub(it) }
|
.associateWith { sourceManager.getOrStub(it) }
|
||||||
|
val downloadCounts = downloadManager.getDownloadCounts(libraryManga.map { it.manga })
|
||||||
libraryManga.map { manga ->
|
libraryManga.map { manga ->
|
||||||
val source = sources.getValue(manga.manga.source)
|
val source = sources.getValue(manga.manga.source)
|
||||||
val downloadCount = downloadManager.getDownloadCount(manga.manga)
|
val downloadCount = downloadCounts.getValue(manga.id)
|
||||||
LibraryItem(
|
LibraryItem(
|
||||||
libraryManga = manga,
|
libraryManga = manga,
|
||||||
downloadCount = downloadCount,
|
downloadCount = downloadCount,
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.WhileSubscribed
|
|
||||||
import kotlinx.coroutines.flow.catch
|
import kotlinx.coroutines.flow.catch
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
@@ -65,9 +64,11 @@ import kotlinx.coroutines.flow.flowOn
|
|||||||
import kotlinx.coroutines.flow.merge
|
import kotlinx.coroutines.flow.merge
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.flow.onStart
|
import kotlinx.coroutines.flow.onStart
|
||||||
|
import kotlinx.coroutines.flow.sample
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.plus
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import logcat.LogPriority
|
import logcat.LogPriority
|
||||||
import mihon.core.metro.AppCoroutineScope
|
import mihon.core.metro.AppCoroutineScope
|
||||||
@@ -77,6 +78,8 @@ import tachiyomi.core.common.i18n.stringResource
|
|||||||
import tachiyomi.core.common.preference.CheckboxState
|
import tachiyomi.core.common.preference.CheckboxState
|
||||||
import tachiyomi.core.common.preference.TriState
|
import tachiyomi.core.common.preference.TriState
|
||||||
import tachiyomi.core.common.preference.mapAsCheckboxState
|
import tachiyomi.core.common.preference.mapAsCheckboxState
|
||||||
|
import tachiyomi.core.common.util.lang.SubscriberGate
|
||||||
|
import tachiyomi.core.common.util.lang.pausedWhileUnsubscribed
|
||||||
import tachiyomi.core.common.util.system.logcat
|
import tachiyomi.core.common.util.system.logcat
|
||||||
import tachiyomi.domain.category.interactor.GetCategories
|
import tachiyomi.domain.category.interactor.GetCategories
|
||||||
import tachiyomi.domain.category.interactor.SetMangaCategories
|
import tachiyomi.domain.category.interactor.SetMangaCategories
|
||||||
@@ -100,6 +103,7 @@ import tachiyomi.domain.track.interactor.GetTracks
|
|||||||
import tachiyomi.i18n.MR
|
import tachiyomi.i18n.MR
|
||||||
import tachiyomi.source.local.isLocal
|
import tachiyomi.source.local.isLocal
|
||||||
import kotlin.math.floor
|
import kotlin.math.floor
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
@AssistedInject
|
@AssistedInject
|
||||||
@@ -136,6 +140,8 @@ class MangaViewModel(
|
|||||||
private val coverCache: CoverCache,
|
private val coverCache: CoverCache,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val subscriberGate = SubscriberGate()
|
||||||
|
|
||||||
@AssistedFactory
|
@AssistedFactory
|
||||||
@ManualViewModelAssistedFactoryKey
|
@ManualViewModelAssistedFactoryKey
|
||||||
@ContributesIntoMap(AppScope::class)
|
@ContributesIntoMap(AppScope::class)
|
||||||
@@ -206,8 +212,8 @@ class MangaViewModel(
|
|||||||
emitAll(getMangaAndChapters.subscribe(mangaId, applyScanlatorFilter = true))
|
emitAll(getMangaAndChapters.subscribe(mangaId, applyScanlatorFilter = true))
|
||||||
}
|
}
|
||||||
.distinctUntilChanged(),
|
.distinctUntilChanged(),
|
||||||
downloadCache.changes,
|
downloadCache.changes.pausedWhileUnsubscribed(),
|
||||||
downloadManager.queueState,
|
downloadManager.queueState.pausedWhileUnsubscribed(),
|
||||||
) { (manga, chapters), _, _ ->
|
) { (manga, chapters), _, _ ->
|
||||||
MangaAndChapters(
|
MangaAndChapters(
|
||||||
manga = manga,
|
manga = manga,
|
||||||
@@ -242,7 +248,7 @@ class MangaViewModel(
|
|||||||
mangaAndChapters,
|
mangaAndChapters,
|
||||||
scanlators,
|
scanlators,
|
||||||
trackers,
|
trackers,
|
||||||
combine(selectedChapterIds, queuedDownloadStates, ::Pair),
|
combine(selectedChapterIds, queuedDownloadStates.pausedWhileUnsubscribed(), ::Pair),
|
||||||
combine(dialog, isRefreshingData, ::Pair),
|
combine(dialog, isRefreshingData, ::Pair),
|
||||||
) {
|
) {
|
||||||
mangaAndChapters,
|
mangaAndChapters,
|
||||||
@@ -281,12 +287,16 @@ class MangaViewModel(
|
|||||||
it.isAnySelected
|
it.isAnySelected
|
||||||
}
|
}
|
||||||
.flowOn(Dispatchers.IO)
|
.flowOn(Dispatchers.IO)
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), State.Loading)
|
.stateIn(viewModelScope + subscriberGate, subscriberGate.whileSubscribed(5.seconds), State.Loading)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
// Progress ticks often, and each one copies the map of download states
|
// Progress ticks often, and each one copies the map of download states
|
||||||
viewModelScope.launch(Dispatchers.Default) {
|
viewModelScope.launch(Dispatchers.Default) {
|
||||||
merge(downloadManager.statusFlow(), downloadManager.progressFlow())
|
merge(
|
||||||
|
downloadManager.statusFlow(),
|
||||||
|
// Each tick rebuilds the shown chapter items, so cap how often that happens
|
||||||
|
downloadManager.progressFlow().sample(200.milliseconds),
|
||||||
|
)
|
||||||
.filter { it.manga.id == mangaId }
|
.filter { it.manga.id == mangaId }
|
||||||
.catch { logcat(LogPriority.ERROR, it) }
|
.catch { logcat(LogPriority.ERROR, it) }
|
||||||
.collect(::updateDownloadState)
|
.collect(::updateDownloadState)
|
||||||
|
|||||||
@@ -59,8 +59,10 @@ import eu.kanade.tachiyomi.util.lang.convertEpochMillisZone
|
|||||||
import eu.kanade.tachiyomi.util.system.copyToClipboard
|
import eu.kanade.tachiyomi.util.system.copyToClipboard
|
||||||
import eu.kanade.tachiyomi.util.system.openInBrowser
|
import eu.kanade.tachiyomi.util.system.openInBrowser
|
||||||
import eu.kanade.tachiyomi.util.system.toast
|
import eu.kanade.tachiyomi.util.system.toast
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.catch
|
import kotlinx.coroutines.flow.catch
|
||||||
@@ -777,6 +779,8 @@ data class TrackerSearchScreen(
|
|||||||
|
|
||||||
private val tracker = trackerManager.get(trackerId)!!
|
private val tracker = trackerManager.get(trackerId)!!
|
||||||
|
|
||||||
|
private var searchJob: Job? = null
|
||||||
|
|
||||||
val supportsPrivateTracking = tracker.supportsPrivateTracking
|
val supportsPrivateTracking = tracker.supportsPrivateTracking
|
||||||
|
|
||||||
init {
|
init {
|
||||||
@@ -787,7 +791,8 @@ data class TrackerSearchScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun trackingSearch(query: String) {
|
fun trackingSearch(query: String) {
|
||||||
viewModelScope.launch {
|
searchJob?.cancel()
|
||||||
|
searchJob = viewModelScope.launch {
|
||||||
// To show loading state
|
// To show loading state
|
||||||
state.update { it.copy(queryResult = null, selected = null) }
|
state.update { it.copy(queryResult = null, selected = null) }
|
||||||
|
|
||||||
@@ -796,6 +801,7 @@ data class TrackerSearchScreen(
|
|||||||
val results = tracker.search(query)
|
val results = tracker.search(query)
|
||||||
Result.success(results)
|
Result.success(results)
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
|
if (e is CancellationException) throw e
|
||||||
Result.failure(e)
|
Result.failure(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
@@ -95,6 +96,7 @@ import tachiyomi.domain.source.service.SourceManager
|
|||||||
import tachiyomi.source.local.image.LocalCoverManager
|
import tachiyomi.source.local.image.LocalCoverManager
|
||||||
import tachiyomi.source.local.isLocal
|
import tachiyomi.source.local.isLocal
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -197,6 +199,11 @@ class ReaderViewModel(
|
|||||||
// Serializes the progress writes, which outlive the reader, so they are applied in order
|
// Serializes the progress writes, which outlive the reader, so they are applied in order
|
||||||
private val progressWrites = Mutex()
|
private val progressWrites = Mutex()
|
||||||
|
|
||||||
|
// Set on Main, flushed from the chapter load too
|
||||||
|
@Volatile
|
||||||
|
private var pendingProgressChapter: ReaderChapter? = null
|
||||||
|
private var pendingProgressSave: Job? = null
|
||||||
|
|
||||||
private var chapterToDownload: Download? = null
|
private var chapterToDownload: Download? = null
|
||||||
|
|
||||||
private var unfilteredChapterList: List<Chapter>? = null
|
private var unfilteredChapterList: List<Chapter>? = null
|
||||||
@@ -540,10 +547,8 @@ class ReaderViewModel(
|
|||||||
val selectedChapter = page.chapter
|
val selectedChapter = page.chapter
|
||||||
val pages = selectedChapter.pages ?: return
|
val pages = selectedChapter.pages ?: return
|
||||||
|
|
||||||
// Save last page read and mark as read if needed. The database calls are main-safe.
|
// Save last page read and mark as read if needed
|
||||||
appScope.launch(Dispatchers.Main.immediate) {
|
|
||||||
updateChapterProgress(selectedChapter, page)
|
updateChapterProgress(selectedChapter, page)
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedChapter != getCurrentChapter()) {
|
if (selectedChapter != getCurrentChapter()) {
|
||||||
logcat { "Setting ${selectedChapter.chapter.url} as active" }
|
logcat { "Setting ${selectedChapter.chapter.url} as active" }
|
||||||
@@ -639,8 +644,11 @@ class ReaderViewModel(
|
|||||||
/**
|
/**
|
||||||
* Saves the chapter progress (last read page and whether it's read)
|
* Saves the chapter progress (last read page and whether it's read)
|
||||||
* if incognito mode isn't on.
|
* if incognito mode isn't on.
|
||||||
|
*
|
||||||
|
* Completing a chapter is saved right away. Other page changes are saved once the reader settles on a page, or
|
||||||
|
* when [saveChapterProgress] is called, so flipping through pages doesn't write to the database on every page.
|
||||||
*/
|
*/
|
||||||
private suspend fun updateChapterProgress(readerChapter: ReaderChapter, page: Page) {
|
private fun updateChapterProgress(readerChapter: ReaderChapter, page: Page) {
|
||||||
val pageIndex = page.index
|
val pageIndex = page.index
|
||||||
|
|
||||||
mutableState.update {
|
mutableState.update {
|
||||||
@@ -649,22 +657,49 @@ class ReaderViewModel(
|
|||||||
readerChapter.requestedPage = pageIndex
|
readerChapter.requestedPage = pageIndex
|
||||||
chapterPageIndex = pageIndex
|
chapterPageIndex = pageIndex
|
||||||
|
|
||||||
if (!incognitoMode && page.status !is Page.State.Error) {
|
if (incognitoMode || page.status is Page.State.Error) return
|
||||||
val read = readerChapter.chapter.read || readerChapter.pages?.lastIndex == pageIndex
|
|
||||||
readerChapter.update { it.copy(lastPageRead = pageIndex, read = read) }
|
|
||||||
val isLastPage = readerChapter.pages?.lastIndex == pageIndex
|
|
||||||
|
|
||||||
// Taken before the first suspension, and the mutex is fair, so the writes land in the order the pages
|
val read = readerChapter.chapter.read || readerChapter.pages?.lastIndex == pageIndex
|
||||||
// were selected even when one of them suspends longer
|
// A page change in another chapter would replace that chapter's pending save
|
||||||
progressWrites.withLock {
|
if (pendingProgressChapter != readerChapter) saveChapterProgress()
|
||||||
if (isLastPage) {
|
readerChapter.update { it.copy(lastPageRead = pageIndex, read = read) }
|
||||||
updateChapterProgressOnComplete(readerChapter)
|
pendingProgressSave?.cancel()
|
||||||
|
|
||||||
|
if (readerChapter.pages?.lastIndex == pageIndex) {
|
||||||
|
pendingProgressChapter = null
|
||||||
|
writeChapterProgress(readerChapter, onComplete = true)
|
||||||
|
} else {
|
||||||
|
pendingProgressChapter = readerChapter
|
||||||
|
pendingProgressSave = viewModelScope.launch {
|
||||||
|
delay(PROGRESS_SAVE_DELAY)
|
||||||
|
saveChapterProgress()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes the progress of the chapter whose page change hasn't been saved yet.
|
||||||
|
*/
|
||||||
|
private fun saveChapterProgress() {
|
||||||
|
val readerChapter = pendingProgressChapter ?: return
|
||||||
|
pendingProgressChapter = null
|
||||||
|
writeChapterProgress(readerChapter, onComplete = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes the progress of [readerChapter] on the app scope, so it's saved even when the reader is closing.
|
||||||
|
*/
|
||||||
|
private fun writeChapterProgress(readerChapter: ReaderChapter, onComplete: Boolean) {
|
||||||
|
// Immediate and through the mutex, which is fair, so the writes land in the order they were made
|
||||||
|
appScope.launch(Dispatchers.Main.immediate) {
|
||||||
|
progressWrites.withLock {
|
||||||
|
if (onComplete) updateChapterProgressOnComplete(readerChapter)
|
||||||
|
|
||||||
|
val chapter = readerChapter.chapter
|
||||||
updateChapter.await(
|
updateChapter.await(
|
||||||
ChapterUpdate(readerChapter.chapter.id) {
|
ChapterUpdate(chapter.id) {
|
||||||
this.read = read
|
read = chapter.read
|
||||||
lastPageRead = pageIndex
|
lastPageRead = chapter.lastPageRead
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -710,6 +745,7 @@ class ReaderViewModel(
|
|||||||
* Saves the chapter last read history if incognito mode isn't on.
|
* Saves the chapter last read history if incognito mode isn't on.
|
||||||
*/
|
*/
|
||||||
private suspend fun updateHistory() {
|
private suspend fun updateHistory() {
|
||||||
|
saveChapterProgress()
|
||||||
getCurrentChapter()?.let { readerChapter ->
|
getCurrentChapter()?.let { readerChapter ->
|
||||||
if (incognitoMode) return@let
|
if (incognitoMode) return@let
|
||||||
|
|
||||||
@@ -1109,3 +1145,5 @@ class ReaderViewModel(
|
|||||||
data class CopyImage(val uri: Uri) : Event
|
data class CopyImage(val uri: Uri) : Event
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val PROGRESS_SAVE_DELAY = 2.seconds
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ import kotlinx.coroutines.flow.Flow
|
|||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.WhileSubscribed
|
|
||||||
import kotlinx.coroutines.flow.catch
|
import kotlinx.coroutines.flow.catch
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
@@ -40,15 +39,19 @@ import kotlinx.coroutines.flow.flowOn
|
|||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.merge
|
import kotlinx.coroutines.flow.merge
|
||||||
import kotlinx.coroutines.flow.receiveAsFlow
|
import kotlinx.coroutines.flow.receiveAsFlow
|
||||||
|
import kotlinx.coroutines.flow.sample
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.plus
|
||||||
import kotlinx.datetime.DateTimeUnit
|
import kotlinx.datetime.DateTimeUnit
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.minus
|
import kotlinx.datetime.minus
|
||||||
import logcat.LogPriority
|
import logcat.LogPriority
|
||||||
import mihon.core.metro.AppCoroutineScope
|
import mihon.core.metro.AppCoroutineScope
|
||||||
import tachiyomi.core.common.preference.TriState
|
import tachiyomi.core.common.preference.TriState
|
||||||
|
import tachiyomi.core.common.util.lang.SubscriberGate
|
||||||
|
import tachiyomi.core.common.util.lang.pausedWhileUnsubscribed
|
||||||
import tachiyomi.core.common.util.system.logcat
|
import tachiyomi.core.common.util.system.logcat
|
||||||
import tachiyomi.domain.chapter.interactor.GetChapter
|
import tachiyomi.domain.chapter.interactor.GetChapter
|
||||||
import tachiyomi.domain.chapter.interactor.UpdateChapter
|
import tachiyomi.domain.chapter.interactor.UpdateChapter
|
||||||
@@ -61,6 +64,7 @@ import tachiyomi.domain.updates.interactor.GetUpdates
|
|||||||
import tachiyomi.domain.updates.model.UpdatesWithRelations
|
import tachiyomi.domain.updates.model.UpdatesWithRelations
|
||||||
import tachiyomi.domain.updates.service.UpdatesPreferences
|
import tachiyomi.domain.updates.service.UpdatesPreferences
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
@@ -81,6 +85,8 @@ class UpdatesViewModel(
|
|||||||
private val updatesPreferences: UpdatesPreferences,
|
private val updatesPreferences: UpdatesPreferences,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val subscriberGate = SubscriberGate()
|
||||||
|
|
||||||
val snackbarHostState: SnackbarHostState = SnackbarHostState()
|
val snackbarHostState: SnackbarHostState = SnackbarHostState()
|
||||||
|
|
||||||
private val _events: Channel<Event> = Channel(Int.MAX_VALUE)
|
private val _events: Channel<Event> = Channel(Int.MAX_VALUE)
|
||||||
@@ -105,7 +111,8 @@ class UpdatesViewModel(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
merge(downloadManager.statusFlow(), downloadManager.progressFlow())
|
// Each progress tick rebuilds every row of the list, so cap how often that happens
|
||||||
|
merge(downloadManager.statusFlow(), downloadManager.progressFlow().sample(200.milliseconds))
|
||||||
.catch { logcat(LogPriority.ERROR, it) }
|
.catch { logcat(LogPriority.ERROR, it) }
|
||||||
.collect(this@UpdatesViewModel::updateDownloadState)
|
.collect(this@UpdatesViewModel::updateDownloadState)
|
||||||
}
|
}
|
||||||
@@ -157,8 +164,8 @@ class UpdatesViewModel(
|
|||||||
excludedCategories = it.filterExcludedCategories,
|
excludedCategories = it.filterExcludedCategories,
|
||||||
).distinctUntilChanged()
|
).distinctUntilChanged()
|
||||||
},
|
},
|
||||||
downloadCache.changes,
|
downloadCache.changes.pausedWhileUnsubscribed(),
|
||||||
downloadManager.queueState,
|
downloadManager.queueState.pausedWhileUnsubscribed(),
|
||||||
// needed for Kotlin filters (downloaded)
|
// needed for Kotlin filters (downloaded)
|
||||||
getUpdatesItemPreferenceFlow().distinctUntilChanged { old, new ->
|
getUpdatesItemPreferenceFlow().distinctUntilChanged { old, new ->
|
||||||
old.filterDownloaded == new.filterDownloaded
|
old.filterDownloaded == new.filterDownloaded
|
||||||
@@ -169,12 +176,12 @@ class UpdatesViewModel(
|
|||||||
.applyFilters(itemPreferences)
|
.applyFilters(itemPreferences)
|
||||||
}
|
}
|
||||||
.flowOn(Dispatchers.Default)
|
.flowOn(Dispatchers.Default)
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), null)
|
.stateIn(viewModelScope + subscriberGate, SharingStarted.Eagerly, null)
|
||||||
|
|
||||||
val state: StateFlow<State> = combine(
|
val state: StateFlow<State> = combine(
|
||||||
updateItems,
|
updateItems,
|
||||||
selectedChapterIds,
|
selectedChapterIds,
|
||||||
queuedDownloadStates,
|
queuedDownloadStates.pausedWhileUnsubscribed(),
|
||||||
dialog,
|
dialog,
|
||||||
hasActiveFilters,
|
hasActiveFilters,
|
||||||
) { items, selectedIds, downloads, dialog, hasActiveFilters ->
|
) { items, selectedIds, downloads, dialog, hasActiveFilters ->
|
||||||
@@ -201,7 +208,7 @@ class UpdatesViewModel(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
.flowOn(Dispatchers.Default)
|
.flowOn(Dispatchers.Default)
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), State())
|
.stateIn(viewModelScope + subscriberGate, subscriberGate.whileSubscribed(5.seconds), State())
|
||||||
|
|
||||||
private fun List<UpdatesItem>.applyFilters(
|
private fun List<UpdatesItem>.applyFilters(
|
||||||
preferences: ItemPreferences,
|
preferences: ItemPreferences,
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ import mihon.feature.migration.list.models.MigratingManga
|
|||||||
import mihon.feature.migration.list.models.MigratingManga.SearchResult
|
import mihon.feature.migration.list.models.MigratingManga.SearchResult
|
||||||
import mihon.feature.migration.list.search.SmartSourceSearchEngine
|
import mihon.feature.migration.list.search.SmartSourceSearchEngine
|
||||||
import tachiyomi.core.common.util.system.logcat
|
import tachiyomi.core.common.util.system.logcat
|
||||||
import tachiyomi.domain.chapter.interactor.GetChaptersByMangaId
|
import tachiyomi.domain.chapter.interactor.GetChapterSummaryByMangaId
|
||||||
import tachiyomi.domain.manga.interactor.GetManga
|
import tachiyomi.domain.manga.interactor.GetManga
|
||||||
import tachiyomi.domain.manga.interactor.NetworkToLocalManga
|
import tachiyomi.domain.manga.interactor.NetworkToLocalManga
|
||||||
import tachiyomi.domain.manga.model.Manga
|
import tachiyomi.domain.manga.model.Manga
|
||||||
@@ -52,7 +52,7 @@ class MigrationListViewModel(
|
|||||||
private val sourceManager: SourceManager,
|
private val sourceManager: SourceManager,
|
||||||
private val getManga: GetManga,
|
private val getManga: GetManga,
|
||||||
private val networkToLocalManga: NetworkToLocalManga,
|
private val networkToLocalManga: NetworkToLocalManga,
|
||||||
private val getChaptersByMangaId: GetChaptersByMangaId,
|
private val getChapterSummaryByMangaId: GetChapterSummaryByMangaId,
|
||||||
private val migrateManga: MigrateMangaUseCase,
|
private val migrateManga: MigrateMangaUseCase,
|
||||||
private val updateMangaFromRemote: UpdateMangaFromRemote,
|
private val updateMangaFromRemote: UpdateMangaFromRemote,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
@@ -103,10 +103,10 @@ class MigrationListViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun getChapterInfo(id: Long) = getChaptersByMangaId.await(id).let { chapters ->
|
private suspend fun getChapterInfo(id: Long) = getChapterSummaryByMangaId.await(id).let { summary ->
|
||||||
ChapterInfo(
|
ChapterInfo(
|
||||||
latestChapter = chapters.maxOfOrNull { it.chapterNumber },
|
latestChapter = summary.latestChapterNumber,
|
||||||
chapterCount = chapters.size,
|
chapterCount = summary.chapterCount.toInt(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import kotlinx.coroutines.flow.Flow
|
|||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.WhileSubscribed
|
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
@@ -24,6 +23,7 @@ import kotlinx.coroutines.flow.flowOn
|
|||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.plus
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.YearMonth
|
import kotlinx.datetime.YearMonth
|
||||||
@@ -31,6 +31,7 @@ import kotlinx.datetime.toLocalDateTime
|
|||||||
import kotlinx.datetime.yearMonth
|
import kotlinx.datetime.yearMonth
|
||||||
import mihon.domain.upcoming.interactor.GetUpcomingManga
|
import mihon.domain.upcoming.interactor.GetUpcomingManga
|
||||||
import tachiyomi.core.common.preference.getAndSet
|
import tachiyomi.core.common.preference.getAndSet
|
||||||
|
import tachiyomi.core.common.util.lang.SubscriberGate
|
||||||
import tachiyomi.domain.category.interactor.GetCategories
|
import tachiyomi.domain.category.interactor.GetCategories
|
||||||
import tachiyomi.domain.category.model.Category
|
import tachiyomi.domain.category.model.Category
|
||||||
import tachiyomi.domain.manga.model.Manga
|
import tachiyomi.domain.manga.model.Manga
|
||||||
@@ -47,6 +48,8 @@ class UpcomingViewModel(
|
|||||||
val upcomingPreferences: UpcomingPreferences,
|
val upcomingPreferences: UpcomingPreferences,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val subscriberGate = SubscriberGate()
|
||||||
|
|
||||||
val excludedCategories = upcomingPreferences.filterExcludedCategories
|
val excludedCategories = upcomingPreferences.filterExcludedCategories
|
||||||
val includedCategories = upcomingPreferences.filterIncludedCategories
|
val includedCategories = upcomingPreferences.filterIncludedCategories
|
||||||
|
|
||||||
@@ -80,7 +83,7 @@ class UpcomingViewModel(
|
|||||||
.distinctUntilChanged()
|
.distinctUntilChanged()
|
||||||
.map { it.toUpcomingUIModels() }
|
.map { it.toUpcomingUIModels() }
|
||||||
.flowOn(Dispatchers.Default)
|
.flowOn(Dispatchers.Default)
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), emptyList())
|
.stateIn(viewModelScope + subscriberGate, SharingStarted.Eagerly, emptyList())
|
||||||
|
|
||||||
val state: StateFlow<State> = combine(
|
val state: StateFlow<State> = combine(
|
||||||
upcoming,
|
upcoming,
|
||||||
@@ -98,8 +101,8 @@ class UpcomingViewModel(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
.stateIn(
|
.stateIn(
|
||||||
viewModelScope,
|
viewModelScope + subscriberGate,
|
||||||
SharingStarted.WhileSubscribed(5.seconds),
|
subscriberGate.whileSubscribed(5.seconds),
|
||||||
State(selectedYearMonth = selectedYearMonth.value),
|
State(selectedYearMonth = selectedYearMonth.value),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package tachiyomi.core.common.util.lang
|
||||||
|
|
||||||
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingCommand
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlinx.coroutines.flow.conflate
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.emitAll
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlin.coroutines.AbstractCoroutineContextElement
|
||||||
|
import kotlin.coroutines.CoroutineContext
|
||||||
|
import kotlin.time.Duration
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps a view model's flows running while its screen is hidden, but holds back database and other frequent
|
||||||
|
* updates until the screen is visible again, so returning to it shows the last state instead of re-running
|
||||||
|
* everything. Flows collected without a gate in their context are unaffected.
|
||||||
|
*/
|
||||||
|
class SubscriberGate : AbstractCoroutineContextElement(SubscriberGate) {
|
||||||
|
|
||||||
|
companion object Key : CoroutineContext.Key<SubscriberGate>
|
||||||
|
|
||||||
|
private val open = MutableStateFlow(true)
|
||||||
|
|
||||||
|
fun whileSubscribed(stopTimeout: Duration): SharingStarted = object : SharingStarted {
|
||||||
|
override fun command(subscriptionCount: StateFlow<Int>): Flow<SharingCommand> = flow {
|
||||||
|
emit(SharingCommand.START)
|
||||||
|
subscriptionCount
|
||||||
|
.map { it > 0 }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.collectLatest { subscribed ->
|
||||||
|
if (!subscribed) delay(stopTimeout)
|
||||||
|
open.value = subscribed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal suspend fun awaitOpen() {
|
||||||
|
open.first { it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun <T> Flow<T>.pausedWhileUnsubscribed(): Flow<T> = flow {
|
||||||
|
val gate = currentCoroutineContext()[SubscriberGate]
|
||||||
|
if (gate == null) {
|
||||||
|
emitAll(this@pausedWhileUnsubscribed)
|
||||||
|
} else {
|
||||||
|
this@pausedWhileUnsubscribed.conflate().collect {
|
||||||
|
gate.awaitOpen()
|
||||||
|
emit(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -318,8 +318,16 @@ object ImageUtil {
|
|||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (image == null) return Color.WHITE.toDrawable()
|
||||||
|
return try {
|
||||||
|
chooseBackground(context, image)
|
||||||
|
} finally {
|
||||||
|
image.recycle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun chooseBackground(context: Context, image: Bitmap): Drawable {
|
||||||
val whiteColor = Color.WHITE
|
val whiteColor = Color.WHITE
|
||||||
if (image == null) return whiteColor.toDrawable()
|
|
||||||
if (image.width < 50 || image.height < 50) {
|
if (image.width < 50 || image.height < 50) {
|
||||||
return whiteColor.toDrawable()
|
return whiteColor.toDrawable()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,17 +6,18 @@ import app.cash.sqldelight.coroutines.mapToList
|
|||||||
import app.cash.sqldelight.coroutines.mapToOne
|
import app.cash.sqldelight.coroutines.mapToOne
|
||||||
import app.cash.sqldelight.coroutines.mapToOneOrNull
|
import app.cash.sqldelight.coroutines.mapToOneOrNull
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import tachiyomi.core.common.util.lang.pausedWhileUnsubscribed
|
||||||
import kotlin.coroutines.CoroutineContext
|
import kotlin.coroutines.CoroutineContext
|
||||||
import kotlin.coroutines.EmptyCoroutineContext
|
import kotlin.coroutines.EmptyCoroutineContext
|
||||||
|
|
||||||
fun <T : Any> Query<T>.subscribeToList(
|
fun <T : Any> Query<T>.subscribeToList(
|
||||||
context: CoroutineContext = EmptyCoroutineContext,
|
context: CoroutineContext = EmptyCoroutineContext,
|
||||||
): Flow<List<T>> = asFlow().mapToList(context)
|
): Flow<List<T>> = asFlow().pausedWhileUnsubscribed().mapToList(context)
|
||||||
|
|
||||||
fun <T : Any> Query<T>.subscribeToOne(
|
fun <T : Any> Query<T>.subscribeToOne(
|
||||||
context: CoroutineContext = EmptyCoroutineContext,
|
context: CoroutineContext = EmptyCoroutineContext,
|
||||||
): Flow<T> = asFlow().mapToOne(context)
|
): Flow<T> = asFlow().pausedWhileUnsubscribed().mapToOne(context)
|
||||||
|
|
||||||
fun <T : Any> Query<T>.subscribeToOneOrNull(
|
fun <T : Any> Query<T>.subscribeToOneOrNull(
|
||||||
context: CoroutineContext = EmptyCoroutineContext,
|
context: CoroutineContext = EmptyCoroutineContext,
|
||||||
): Flow<T?> = asFlow().mapToOneOrNull(context)
|
): Flow<T?> = asFlow().pausedWhileUnsubscribed().mapToOneOrNull(context)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import tachiyomi.data.Database
|
|||||||
import tachiyomi.data.subscribeToList
|
import tachiyomi.data.subscribeToList
|
||||||
import tachiyomi.domain.chapter.model.Chapter
|
import tachiyomi.domain.chapter.model.Chapter
|
||||||
import tachiyomi.domain.chapter.model.ChapterRemoteUpdate
|
import tachiyomi.domain.chapter.model.ChapterRemoteUpdate
|
||||||
|
import tachiyomi.domain.chapter.model.ChapterSummary
|
||||||
import tachiyomi.domain.chapter.model.ChapterUpdate
|
import tachiyomi.domain.chapter.model.ChapterUpdate
|
||||||
import tachiyomi.domain.chapter.repository.ChapterRepository
|
import tachiyomi.domain.chapter.repository.ChapterRepository
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
@@ -112,6 +113,14 @@ class ChapterRepositoryImpl(
|
|||||||
.awaitAsList()
|
.awaitAsList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun getChapterSummaryByMangaId(mangaId: Long): ChapterSummary {
|
||||||
|
return database.chapterQueries
|
||||||
|
.getChapterSummaryByMangaId(mangaId) { total, latestChapterNumber ->
|
||||||
|
ChapterSummary(chapterCount = total, latestChapterNumber = latestChapterNumber)
|
||||||
|
}
|
||||||
|
.awaitAsOne()
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun getScanlatorsByMangaId(mangaId: Long): List<String> {
|
override suspend fun getScanlatorsByMangaId(mangaId: Long): List<String> {
|
||||||
return database.chapterQueries
|
return database.chapterQueries
|
||||||
.getScanlatorsByMangaId(mangaId) { it.orEmpty() }
|
.getScanlatorsByMangaId(mangaId) { it.orEmpty() }
|
||||||
|
|||||||
@@ -48,6 +48,11 @@ AND (
|
|||||||
OR ES.scanlator IS NULL
|
OR ES.scanlator IS NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
getChapterSummaryByMangaId:
|
||||||
|
SELECT count(*) AS total, max(remote_chapter_number) AS latestChapterNumber
|
||||||
|
FROM chapter
|
||||||
|
WHERE manga_id = :mangaId;
|
||||||
|
|
||||||
getScanlatorsByMangaId:
|
getScanlatorsByMangaId:
|
||||||
SELECT DISTINCT remote_scanlator
|
SELECT DISTINCT remote_scanlator
|
||||||
FROM chapter
|
FROM chapter
|
||||||
|
|||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
package tachiyomi.domain.chapter.interactor
|
||||||
|
|
||||||
|
import dev.zacsweers.metro.Inject
|
||||||
|
import tachiyomi.domain.chapter.model.ChapterSummary
|
||||||
|
import tachiyomi.domain.chapter.repository.ChapterRepository
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
class GetChapterSummaryByMangaId(
|
||||||
|
private val chapterRepository: ChapterRepository,
|
||||||
|
) {
|
||||||
|
|
||||||
|
suspend fun await(mangaId: Long): ChapterSummary {
|
||||||
|
return chapterRepository.getChapterSummaryByMangaId(mangaId)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package tachiyomi.domain.chapter.model
|
||||||
|
|
||||||
|
data class ChapterSummary(
|
||||||
|
val chapterCount: Long,
|
||||||
|
val latestChapterNumber: Double?,
|
||||||
|
)
|
||||||
@@ -3,6 +3,7 @@ package tachiyomi.domain.chapter.repository
|
|||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import tachiyomi.domain.chapter.model.Chapter
|
import tachiyomi.domain.chapter.model.Chapter
|
||||||
import tachiyomi.domain.chapter.model.ChapterRemoteUpdate
|
import tachiyomi.domain.chapter.model.ChapterRemoteUpdate
|
||||||
|
import tachiyomi.domain.chapter.model.ChapterSummary
|
||||||
import tachiyomi.domain.chapter.model.ChapterUpdate
|
import tachiyomi.domain.chapter.model.ChapterUpdate
|
||||||
|
|
||||||
interface ChapterRepository {
|
interface ChapterRepository {
|
||||||
@@ -19,6 +20,8 @@ interface ChapterRepository {
|
|||||||
|
|
||||||
suspend fun getChapterByMangaId(mangaId: Long, applyScanlatorFilter: Boolean = false): List<Chapter>
|
suspend fun getChapterByMangaId(mangaId: Long, applyScanlatorFilter: Boolean = false): List<Chapter>
|
||||||
|
|
||||||
|
suspend fun getChapterSummaryByMangaId(mangaId: Long): ChapterSummary
|
||||||
|
|
||||||
suspend fun getScanlatorsByMangaId(mangaId: Long): List<String>
|
suspend fun getScanlatorsByMangaId(mangaId: Long): List<String>
|
||||||
|
|
||||||
fun getScanlatorsByMangaIdAsFlow(mangaId: Long): Flow<List<String>>
|
fun getScanlatorsByMangaIdAsFlow(mangaId: Long): Flow<List<String>>
|
||||||
|
|||||||
+11
@@ -1,9 +1,20 @@
|
|||||||
package tachiyomi.presentation.widget
|
package tachiyomi.presentation.widget
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import androidx.glance.appwidget.GlanceAppWidget
|
import androidx.glance.appwidget.GlanceAppWidget
|
||||||
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||||
|
|
||||||
class UpdatesGridCoverScreenGlanceReceiver : GlanceAppWidgetReceiver() {
|
class UpdatesGridCoverScreenGlanceReceiver : GlanceAppWidgetReceiver() {
|
||||||
override val glanceAppWidget: GlanceAppWidget
|
override val glanceAppWidget: GlanceAppWidget
|
||||||
get() = UpdatesGridCoverScreenGlanceWidget()
|
get() = UpdatesGridCoverScreenGlanceWidget()
|
||||||
|
|
||||||
|
override fun onEnabled(context: Context) {
|
||||||
|
super.onEnabled(context)
|
||||||
|
WidgetManager.onWidgetEnabled(glanceAppWidget)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDisabled(context: Context) {
|
||||||
|
super.onDisabled(context)
|
||||||
|
WidgetManager.onWidgetDisabled(glanceAppWidget)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+11
@@ -1,5 +1,6 @@
|
|||||||
package tachiyomi.presentation.widget
|
package tachiyomi.presentation.widget
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import androidx.glance.appwidget.GlanceAppWidget
|
import androidx.glance.appwidget.GlanceAppWidget
|
||||||
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||||
|
|
||||||
@@ -7,4 +8,14 @@ class UpdatesGridGlanceReceiver : GlanceAppWidgetReceiver() {
|
|||||||
|
|
||||||
override val glanceAppWidget: GlanceAppWidget
|
override val glanceAppWidget: GlanceAppWidget
|
||||||
get() = UpdatesGridGlanceWidget()
|
get() = UpdatesGridGlanceWidget()
|
||||||
|
|
||||||
|
override fun onEnabled(context: Context) {
|
||||||
|
super.onEnabled(context)
|
||||||
|
WidgetManager.onWidgetEnabled(glanceAppWidget)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDisabled(context: Context) {
|
||||||
|
super.onDisabled(context)
|
||||||
|
WidgetManager.onWidgetDisabled(glanceAppWidget)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,25 @@
|
|||||||
package tachiyomi.presentation.widget
|
package tachiyomi.presentation.widget
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import androidx.glance.appwidget.GlanceAppWidget
|
||||||
|
import androidx.glance.appwidget.GlanceAppWidgetManager
|
||||||
import androidx.glance.appwidget.updateAll
|
import androidx.glance.appwidget.updateAll
|
||||||
import dev.zacsweers.metro.Inject
|
import dev.zacsweers.metro.Inject
|
||||||
import eu.kanade.tachiyomi.core.security.SecurityPreferences
|
import eu.kanade.tachiyomi.core.security.SecurityPreferences
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.emptyFlow
|
||||||
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import logcat.LogPriority
|
import logcat.LogPriority
|
||||||
import tachiyomi.core.common.util.system.logcat
|
import tachiyomi.core.common.util.system.logcat
|
||||||
import tachiyomi.domain.updates.interactor.GetUpdates
|
import tachiyomi.domain.updates.interactor.GetUpdates
|
||||||
@@ -21,21 +30,40 @@ class WidgetManager(
|
|||||||
private val securityPreferences: SecurityPreferences,
|
private val securityPreferences: SecurityPreferences,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
context(context: Context)
|
context(context: Context)
|
||||||
fun init(scope: CoroutineScope) {
|
fun init(scope: CoroutineScope) {
|
||||||
|
scope.launch(Dispatchers.Default) {
|
||||||
|
val manager = GlanceAppWidgetManager(context)
|
||||||
|
val placed = widgets.filter { manager.getGlanceIds(it.javaClass).isNotEmpty() }
|
||||||
|
placedWidgets.update { it + placed.map { widget -> widget.javaClass } }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only watch updates while a widget is on the home screen
|
||||||
|
placedWidgets
|
||||||
|
.map { it.isNotEmpty() }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.flatMapLatest { hasWidgets ->
|
||||||
|
if (hasWidgets) {
|
||||||
combine(
|
combine(
|
||||||
getUpdates.subscribe(read = false, after = BaseUpdatesGridGlanceWidget.DateLimit),
|
getUpdates.subscribe(
|
||||||
|
read = false,
|
||||||
|
after = BaseUpdatesGridGlanceWidget.DateLimit,
|
||||||
|
),
|
||||||
securityPreferences.useAuthenticator.changes(),
|
securityPreferences.useAuthenticator.changes(),
|
||||||
transform = { a, b -> a to b },
|
transform = { a, b -> a to b },
|
||||||
)
|
)
|
||||||
|
} else {
|
||||||
|
emptyFlow()
|
||||||
|
}
|
||||||
|
}
|
||||||
.distinctUntilChanged { old, new ->
|
.distinctUntilChanged { old, new ->
|
||||||
old.second == new.second &&
|
old.second == new.second &&
|
||||||
old.first.map { it.chapterId }.toSet() == new.first.map { it.chapterId }.toSet()
|
old.first.map { it.chapterId }.toSet() == new.first.map { it.chapterId }.toSet()
|
||||||
}
|
}
|
||||||
.onEach {
|
.onEach {
|
||||||
try {
|
try {
|
||||||
UpdatesGridGlanceWidget().updateAll(context)
|
widgets.forEach { it.updateAll(context) }
|
||||||
UpdatesGridCoverScreenGlanceWidget().updateAll(context)
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
this.logcat(LogPriority.ERROR, e) { "Failed to update widget" }
|
this.logcat(LogPriority.ERROR, e) { "Failed to update widget" }
|
||||||
}
|
}
|
||||||
@@ -43,4 +71,19 @@ class WidgetManager(
|
|||||||
.flowOn(Dispatchers.Default)
|
.flowOn(Dispatchers.Default)
|
||||||
.launchIn(scope)
|
.launchIn(scope)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val widgets: List<GlanceAppWidget>
|
||||||
|
get() = listOf(UpdatesGridGlanceWidget(), UpdatesGridCoverScreenGlanceWidget())
|
||||||
|
|
||||||
|
private val placedWidgets = MutableStateFlow(emptySet<Class<out GlanceAppWidget>>())
|
||||||
|
|
||||||
|
internal fun onWidgetEnabled(widget: GlanceAppWidget) {
|
||||||
|
placedWidgets.update { it + widget.javaClass }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun onWidgetDisabled(widget: GlanceAppWidget) {
|
||||||
|
placedWidgets.update { it - widget.javaClass }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user