Make the source manager surface suspend (#3869)

Assisted-by: Claude:claude-opus-5
This commit is contained in:
AntsyLich
2026-08-28 23:36:28 +06:00
committed by GitHub
parent c1d1f6b161
commit 5fadea61e7
50 changed files with 328 additions and 251 deletions
@@ -1,13 +0,0 @@
package eu.kanade.core.util
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import mihon.app.di.appGraph
@Composable
fun ifSourcesLoaded(): Boolean {
val context = LocalContext.current
return remember { context.appGraph.sourceManager.isInitialized }.collectAsState().value
}
@@ -14,7 +14,7 @@ class GetIncognitoState(
private val sourcePreferences: SourcePreferences, private val sourcePreferences: SourcePreferences,
private val extensionManager: ExtensionManager, private val extensionManager: ExtensionManager,
) { ) {
fun await(sourceId: Long?): Boolean { suspend fun await(sourceId: Long?): Boolean {
if (basePreferences.incognitoMode.get()) return true if (basePreferences.incognitoMode.get()) return true
if (sourceId == null) return false if (sourceId == null) return false
val extensionPackage = extensionManager.getExtensionPackage(sourceId) ?: return false val extensionPackage = extensionManager.getExtensionPackage(sourceId) ?: return false
@@ -5,13 +5,13 @@ import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.core.graphics.drawable.toBitmap import androidx.core.graphics.drawable.toBitmap
import mihon.app.di.appGraph import mihon.app.di.appGraph
import tachiyomi.core.common.util.lang.withIOContext
import tachiyomi.domain.source.model.Source import tachiyomi.domain.source.model.Source
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
val Source.icon: ImageBitmap? suspend fun Source.icon(): ImageBitmap? = withIOContext {
get() { Injekt.get<Context>().appGraph.extensionManager.getAppIconForSource(id)
return Injekt.get<Context>().appGraph.extensionManager.getAppIconForSource(id) ?.toBitmap()
?.toBitmap() ?.asImageBitmap()
?.asImageBitmap() }
}
@@ -44,7 +44,7 @@ fun SourceIcon(
source: Source, source: Source,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val icon = source.icon val icon = produceState<ImageBitmap?>(initialValue = null, source.id) { value = source.icon() }.value
when { when {
source.isStub && icon == null -> { source.isStub && icon == null -> {
@@ -38,6 +38,8 @@ import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.Typography import androidx.compose.material3.Typography
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -170,12 +172,12 @@ fun DuplicateMangaDialog(
@Composable @Composable
private fun DuplicateMangaListItem( private fun DuplicateMangaListItem(
duplicate: MangaWithChapterCount, duplicate: MangaWithChapterCount,
getSource: () -> Source, getSource: suspend () -> Source,
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
onOpenManga: () -> Unit, onOpenManga: () -> Unit,
onMigrate: () -> Unit, onMigrate: () -> Unit,
) { ) {
val source = getSource() val source by produceState<Source?>(initialValue = null) { value = getSource() }
val manga = duplicate.manga val manga = duplicate.manga
Column( Column(
modifier = Modifier modifier = Modifier
@@ -277,7 +279,7 @@ private fun DuplicateMangaListItem(
) )
} }
Text( Text(
text = source.name, text = source?.name.orEmpty(),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
maxLines = 1, maxLines = 1,
@@ -25,6 +25,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@@ -105,11 +106,12 @@ object SettingsTrackingScreen : SearchableSettings {
} }
} }
val installedSources by produceState(initialValue = emptyList()) { value = sourceManager.getAll() }
val enhancedTrackers = trackerManager.trackers val enhancedTrackers = trackerManager.trackers
.filter { it is EnhancedTracker } .filter { it is EnhancedTracker }
.partition { service -> .partition { service ->
val acceptedSources = (service as EnhancedTracker).getAcceptedSources() val acceptedSources = (service as EnhancedTracker).getAcceptedSources()
sourceManager.getAll().any { it::class.qualifiedName in acceptedSources } installedSources.any { it::class.qualifiedName in acceptedSources }
} }
var enhancedTrackerInfo = stringResource(MR.strings.enhanced_tracking_info) var enhancedTrackerInfo = stringResource(MR.strings.enhanced_tracking_info)
if (enhancedTrackers.second.isNotEmpty()) { if (enhancedTrackers.second.isNotEmpty()) {
@@ -20,6 +20,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle import androidx.compose.ui.text.withStyle
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow import cafe.adriel.voyager.navigator.currentOrThrow
import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.AppScope
@@ -41,6 +42,7 @@ import eu.kanade.tachiyomi.util.system.workManager
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import tachiyomi.core.common.util.lang.launchIO
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.LabeledCheckbox import tachiyomi.presentation.core.components.LabeledCheckbox
import tachiyomi.presentation.core.components.LazyColumnWithAction import tachiyomi.presentation.core.components.LazyColumnWithAction
@@ -191,7 +193,9 @@ class RestoreBackupViewModel(
} }
init { init {
validate(uri.toUri()) viewModelScope.launchIO {
validate(uri.toUri())
}
} }
fun toggle(setter: (RestoreOptions, Boolean) -> RestoreOptions, enabled: Boolean) { fun toggle(setter: (RestoreOptions, Boolean) -> RestoreOptions, enabled: Boolean) {
@@ -210,7 +214,7 @@ class RestoreBackupViewModel(
) )
} }
private fun validate(uri: Uri) { private suspend fun validate(uri: Uri) {
val results = try { val results = try {
backupFileValidator.validate(uri) backupFileValidator.validate(uri)
} catch (e: Exception) { } catch (e: Exception) {
@@ -17,7 +17,7 @@ class BackupFileValidator(
* *
* @return List of missing sources or missing trackers. * @return List of missing sources or missing trackers.
*/ */
fun validate(uri: Uri): Results { suspend fun validate(uri: Uri): Results {
val backup = try { val backup = try {
backupDecoder.decode(uri) backupDecoder.decode(uri)
} catch (e: Exception) { } catch (e: Exception) {
@@ -26,7 +26,7 @@ class BackupFileValidator(
val sources = backup.backupSources.associate { it.sourceId to it.name } val sources = backup.backupSources.associate { it.sourceId to it.name }
val missingSources = sources val missingSources = sources
.filter { sourceManager.get(it.key) == null } .filterKeys { sourceManager.get(it) == null }
.values.map { .values.map {
val id = it.toLongOrNull() val id = it.toLongOrNull()
if (id == null) { if (id == null) {
@@ -136,7 +136,7 @@ class BackupCreator(
return mangaBackupCreator(mangas, options) return mangaBackupCreator(mangas, options)
} }
private fun backupSources(mangas: List<BackupManga>): List<BackupSource> { private suspend fun backupSources(mangas: List<BackupManga>): List<BackupSource> {
return sourcesBackupCreator(mangas) return sourcesBackupCreator(mangas)
} }
@@ -152,7 +152,7 @@ class BackupCreator(
return extensionStoresBackupCreator() return extensionStoresBackupCreator()
} }
private fun backupSourcePreferences(options: BackupOptions): List<BackupSourcePreferences> { private suspend fun backupSourcePreferences(options: BackupOptions): List<BackupSourcePreferences> {
if (!options.sourceSettings) return emptyList() if (!options.sourceSettings) return emptyList()
return preferenceBackupCreator.createSource(includePrivatePreferences = options.privateSettings) return preferenceBackupCreator.createSource(includePrivatePreferences = options.privateSettings)
@@ -27,7 +27,7 @@ class PreferenceBackupCreator(
.withPrivatePreferences(includePrivatePreferences) .withPrivatePreferences(includePrivatePreferences)
} }
fun createSource(includePrivatePreferences: Boolean): List<BackupSourcePreferences> { suspend fun createSource(includePrivatePreferences: Boolean): List<BackupSourcePreferences> {
return sourceManager.getAll() return sourceManager.getAll()
.filterIsInstance<ConfigurableSource>() .filterIsInstance<ConfigurableSource>()
.map { .map {
@@ -11,14 +11,11 @@ class SourcesBackupCreator(
private val sourceManager: SourceManager, private val sourceManager: SourceManager,
) { ) {
operator fun invoke(mangas: List<BackupManga>): List<BackupSource> { suspend operator fun invoke(mangas: List<BackupManga>): List<BackupSource> {
return mangas return mangas
.asSequence()
.map(BackupManga::source) .map(BackupManga::source)
.distinct() .distinct()
.map(sourceManager::getOrStub) .map { sourceManager.getOrStub(it).toBackupSource() }
.map { it.toBackupSource() }
.toList()
} }
} }
@@ -51,7 +51,7 @@ class MangaCoverFetcher(
private val coverFileLazy: Lazy<File?>, private val coverFileLazy: Lazy<File?>,
private val customCoverFileLazy: Lazy<File>, private val customCoverFileLazy: Lazy<File>,
private val diskCacheKeyLazy: Lazy<String>, private val diskCacheKeyLazy: Lazy<String>,
private val sourceLazy: Lazy<HttpSource?>, private val getSource: suspend () -> HttpSource?,
private val callFactoryLazy: Lazy<Call.Factory>, private val callFactoryLazy: Lazy<Call.Factory>,
private val imageLoader: ImageLoader, private val imageLoader: ImageLoader,
) : Fetcher { ) : Fetcher {
@@ -169,8 +169,9 @@ class MangaCoverFetcher(
} }
private suspend fun executeNetworkRequest(): Response { private suspend fun executeNetworkRequest(): Response {
val client = sourceLazy.value?.client ?: callFactoryLazy.value val source = getSource()
val response = client.newCall(newRequest()).await() val client = source?.client ?: callFactoryLazy.value
val response = client.newCall(newRequest(source)).await()
if (!response.isSuccessful && response.code != HTTP_NOT_MODIFIED) { if (!response.isSuccessful && response.code != HTTP_NOT_MODIFIED) {
response.close() response.close()
throw IOException(response.message) throw IOException(response.message)
@@ -178,11 +179,11 @@ class MangaCoverFetcher(
return response return response
} }
private fun newRequest(): Request { private fun newRequest(source: HttpSource?): Request {
val request = Request.Builder().apply { val request = Request.Builder().apply {
url(url!!) url(url!!)
val sourceHeaders = sourceLazy.value?.headers val sourceHeaders = source?.headers
if (sourceHeaders != null) { if (sourceHeaders != null) {
headers(sourceHeaders) headers(sourceHeaders)
} }
@@ -310,7 +311,7 @@ class MangaCoverFetcher(
coverFileLazy = lazy { coverCache.getCoverFile(data.thumbnailUrl) }, coverFileLazy = lazy { coverCache.getCoverFile(data.thumbnailUrl) },
customCoverFileLazy = lazy { coverCache.getCustomCoverFile(data.id) }, customCoverFileLazy = lazy { coverCache.getCustomCoverFile(data.id) },
diskCacheKeyLazy = lazy { imageLoader.components.key(data, options)!! }, diskCacheKeyLazy = lazy { imageLoader.components.key(data, options)!! },
sourceLazy = lazy { sourceManager.get(data.source) as? HttpSource }, getSource = { sourceManager.get(data.source) as? HttpSource },
callFactoryLazy = callFactoryLazy, callFactoryLazy = callFactoryLazy,
imageLoader = imageLoader, imageLoader = imageLoader,
) )
@@ -331,7 +332,7 @@ class MangaCoverFetcher(
coverFileLazy = lazy { coverCache.getCoverFile(data.url) }, coverFileLazy = lazy { coverCache.getCoverFile(data.url) },
customCoverFileLazy = lazy { coverCache.getCustomCoverFile(data.mangaId) }, customCoverFileLazy = lazy { coverCache.getCustomCoverFile(data.mangaId) },
diskCacheKeyLazy = lazy { imageLoader.components.key(data, options)!! }, diskCacheKeyLazy = lazy { imageLoader.components.key(data, options)!! },
sourceLazy = lazy { sourceManager.get(data.sourceId) as? HttpSource }, getSource = { sourceManager.get(data.sourceId) as? HttpSource },
callFactoryLazy = callFactoryLazy, callFactoryLazy = callFactoryLazy,
imageLoader = imageLoader, imageLoader = imageLoader,
) )
@@ -133,7 +133,6 @@ class DownloadCache(
* @param chapterUrl the url of the chapter to query * @param chapterUrl the url of the chapter to query
* @param mangaTitle the title of the manga to query. * @param mangaTitle the title of the manga to query.
* @param sourceId the id of the source of the chapter. * @param sourceId the id of the source of the chapter.
* @param skipCache whether to skip the directory cache and check in the filesystem.
*/ */
fun isChapterDownloaded( fun isChapterDownloaded(
chapterName: String, chapterName: String,
@@ -141,13 +140,7 @@ class DownloadCache(
chapterUrl: String, chapterUrl: String,
mangaTitle: String, mangaTitle: String,
sourceId: Long, sourceId: Long,
skipCache: Boolean,
): Boolean { ): Boolean {
if (skipCache) {
val source = sourceManager.getOrStub(sourceId)
return provider.findChapterDir(chapterName, chapterScanlator, chapterUrl, mangaTitle, source) != null
}
renewCache() renewCache()
val sourceDir = rootDownloadsDir.sourceDirs[sourceId] val sourceDir = rootDownloadsDir.sourceDirs[sourceId]
@@ -354,8 +347,6 @@ class DownloadCache(
// Try to wait until extensions and sources have loaded // Try to wait until extensions and sources have loaded
var sources = emptyList<Source>() var sources = emptyList<Source>()
withTimeoutOrNull(30.seconds) { withTimeoutOrNull(30.seconds) {
sourceManager.isInitialized.first { it }
sources = getSources() sources = getSources()
} }
@@ -418,7 +409,7 @@ class DownloadCache(
notifyChanges() notifyChanges()
} }
private fun getSources(): List<Source> { private suspend fun getSources(): List<Source> {
return sourceManager.getOnlineSources() + sourceManager.getStubSources() return sourceManager.getOnlineSources() + sourceManager.getStubSources()
} }
@@ -140,7 +140,7 @@ class DownloadManager(
* @param chapters the list of chapters to enqueue. * @param chapters the list of chapters to enqueue.
* @param autoStart whether to start the downloader after enqueing the chapters. * @param autoStart whether to start the downloader after enqueing the chapters.
*/ */
fun downloadChapters(manga: Manga, chapters: List<Chapter>, autoStart: Boolean = true) { suspend fun downloadChapters(manga: Manga, chapters: List<Chapter>, autoStart: Boolean = true) {
downloader.queueChapters(manga, chapters, autoStart) downloader.queueChapters(manga, chapters, autoStart)
} }
@@ -188,7 +188,6 @@ class DownloadManager(
* @param chapterScanlator scanlator of the chapter to query * @param chapterScanlator scanlator of the chapter to query
* @param mangaTitle the title of the manga to query. * @param mangaTitle the title of the manga to query.
* @param sourceId the id of the source of the chapter. * @param sourceId the id of the source of the chapter.
* @param skipCache whether to skip the directory cache and check in the filesystem.
*/ */
fun isChapterDownloaded( fun isChapterDownloaded(
chapterName: String, chapterName: String,
@@ -196,9 +195,26 @@ class DownloadManager(
chapterUrl: String, chapterUrl: String,
mangaTitle: String, mangaTitle: String,
sourceId: Long, sourceId: Long,
skipCache: Boolean = false,
): Boolean { ): Boolean {
return cache.isChapterDownloaded(chapterName, chapterScanlator, chapterUrl, mangaTitle, sourceId, skipCache) return cache.isChapterDownloaded(chapterName, chapterScanlator, chapterUrl, mangaTitle, sourceId)
}
/**
* Returns true if the chapter is present on disk, bypassing the directory cache.
*
* @param chapterName the name of the chapter to query.
* @param chapterScanlator scanlator of the chapter to query
* @param mangaTitle the title of the manga to query.
* @param source the source of the chapter.
*/
fun isChapterDownloadedOnDisk(
chapterName: String,
chapterScanlator: String?,
chapterUrl: String,
mangaTitle: String,
source: Source,
): Boolean {
return provider.findChapterDir(chapterName, chapterScanlator, chapterUrl, mangaTitle, source) != null
} }
/** /**
@@ -302,7 +318,7 @@ class DownloadManager(
/** /**
* Triggers the execution of the deletion of pending chapters. * Triggers the execution of the deletion of pending chapters.
*/ */
fun deletePendingChapters() { suspend fun deletePendingChapters() {
val pendingChapters = pendingDeleter.getPendingChapters() val pendingChapters = pendingDeleter.getPendingChapters()
for ((manga, chapters) in pendingChapters) { for ((manga, chapters) in pendingChapters) {
val source = sourceManager.get(manga.source) ?: continue val source = sourceManager.get(manga.source) ?: continue
@@ -7,7 +7,6 @@ import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn import dev.zacsweers.metro.SingleIn
import eu.kanade.tachiyomi.data.download.model.Download import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.HttpSource
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import tachiyomi.domain.chapter.interactor.GetChapter import tachiyomi.domain.chapter.interactor.GetChapter
@@ -92,7 +91,7 @@ class DownloadStore(
/** /**
* Returns the list of downloads to restore. It should be called in a background thread. * Returns the list of downloads to restore. It should be called in a background thread.
*/ */
fun restore(): List<Download> { suspend fun restore(): List<Download> {
val objs = preferences.all val objs = preferences.all
.mapNotNull { it.value as? String } .mapNotNull { it.value as? String }
.mapNotNull { deserialize(it) } .mapNotNull { deserialize(it) }
@@ -103,10 +102,10 @@ class DownloadStore(
val cachedManga = mutableMapOf<Long, Manga?>() val cachedManga = mutableMapOf<Long, Manga?>()
for ((mangaId, chapterId) in objs) { for ((mangaId, chapterId) in objs) {
val manga = cachedManga.getOrPut(mangaId) { val manga = cachedManga.getOrPut(mangaId) {
runBlocking { getManga.await(mangaId) } getManga.await(mangaId)
} ?: continue } ?: continue
val source = sourceManager.get(manga.source) as? HttpSource ?: continue val source = sourceManager.get(manga.source) as? HttpSource ?: continue
val chapter = runBlocking { getChapter.await(chapterId) } ?: continue val chapter = getChapter.await(chapterId) ?: continue
downloads.add(Download(source, manga, chapter)) downloads.add(Download(source, manga, chapter))
} }
} }
@@ -265,7 +265,7 @@ class Downloader(
* @param chapters the list of chapters to download. * @param chapters the list of chapters to download.
* @param autoStart whether to start the downloader after enqueing the chapters. * @param autoStart whether to start the downloader after enqueing the chapters.
*/ */
fun queueChapters(manga: Manga, chapters: List<Chapter>, autoStart: Boolean) { suspend fun queueChapters(manga: Manga, chapters: List<Chapter>, autoStart: Boolean) {
if (chapters.isEmpty()) return if (chapters.isEmpty()) return
val source = sourceManager.get(manga.source) as? HttpSource ?: return val source = sourceManager.get(manga.source) as? HttpSource ?: return
@@ -329,7 +329,7 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet
} }
} }
private fun downloadChapters(manga: Manga, chapters: List<Chapter>) { private suspend fun downloadChapters(manga: Manga, chapters: List<Chapter>) {
// We don't want to start downloading while the library is updating, because websites // We don't want to start downloading while the library is updating, because websites
// may don't like it and they could ban the user. // may don't like it and they could ban the user.
downloadManager.downloadChapters(manga, chapters, false) downloadManager.downloadChapters(manga, chapters, false)
@@ -387,7 +387,7 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet
/** /**
* Writes basic file of update errors to cache dir. * Writes basic file of update errors to cache dir.
*/ */
private fun writeErrorFile(errors: List<Pair<Manga, String?>>): File { private suspend fun writeErrorFile(errors: List<Pair<Manga, String?>>): File {
try { try {
if (errors.isNotEmpty()) { if (errors.isNotEmpty()) {
val file = context.createFileInCacheDir("mihon_update_errors.txt") val file = context.createFileInCacheDir("mihon_update_errors.txt")
@@ -402,8 +402,8 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet
mangas.groupBy { it.source }.forEach { (srcId, mangas) -> mangas.groupBy { it.source }.forEach { (srcId, mangas) ->
val source = sourceManager.getOrStub(srcId) val source = sourceManager.getOrStub(srcId)
out.write(" # $source\n") out.write(" # $source\n")
mangas.forEach { mangas.forEach { manga ->
out.write(" - ${it.title}\n") out.write(" - ${manga.title}\n")
} }
} }
} }
@@ -113,10 +113,10 @@ class LibraryUpdateNotifier(
/** /**
* Warn when excessively checking any single source. * Warn when excessively checking any single source.
*/ */
fun showQueueSizeWarningNotificationIfNeeded(mangaToUpdate: List<LibraryManga>) { suspend fun showQueueSizeWarningNotificationIfNeeded(mangaToUpdate: List<LibraryManga>) {
val maxUpdatesFromSource = mangaToUpdate val maxUpdatesFromSource = mangaToUpdate
.groupBy { it.manga.source } .groupBy { it.manga.source }
.filterKeys { sourceManager.get(it) !is UnmeteredSource } .filter { (sourceId, _) -> sourceManager.get(sourceId) !is UnmeteredSource }
.maxOfOrNull { it.value.size } ?: 0 .maxOfOrNull { it.value.size } ?: 0
if (maxUpdatesFromSource <= MANGA_PER_SOURCE_QUEUE_WARNING_THRESHOLD) { if (maxUpdatesFromSource <= MANGA_PER_SOURCE_QUEUE_WARNING_THRESHOLD) {
@@ -9,6 +9,7 @@ import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.source.ConfigurableSource import eu.kanade.tachiyomi.source.ConfigurableSource
import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.sourcePreferences import eu.kanade.tachiyomi.source.sourcePreferences
import kotlinx.coroutines.runBlocking
import tachiyomi.domain.manga.model.Manga import tachiyomi.domain.manga.model.Manga
import tachiyomi.domain.source.service.SourceManager import tachiyomi.domain.source.service.SourceManager
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
@@ -118,7 +119,8 @@ class Kavita(id: Long) : BaseTracker(id, "Kavita"), EnhancedTracker {
(0..7).map { bytes[it].toLong() and 0xff shl 8 * (7 - it) } (0..7).map { bytes[it].toLong() and 0xff shl 8 * (7 - it) }
.reduce(Long::or) and Long.MAX_VALUE .reduce(Long::or) and Long.MAX_VALUE
} }
val preferences = (sourceManager.get(sourceId) as ConfigurableSource).sourcePreferences() val preferences = runBlocking { sourceManager.get(sourceId) as ConfigurableSource }
.sourcePreferences()
val prefApiUrl = preferences.getString("APIURL", "") val prefApiUrl = preferences.getString("APIURL", "")
val prefApiKey = preferences.getString("APIKEY", "") val prefApiKey = preferences.getString("APIKEY", "")
@@ -10,6 +10,7 @@ import eu.kanade.tachiyomi.network.parseAs
import eu.kanade.tachiyomi.source.ConfigurableSource import eu.kanade.tachiyomi.source.ConfigurableSource
import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.source.sourcePreferences import eu.kanade.tachiyomi.source.sourcePreferences
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.addAll import kotlinx.serialization.json.addAll
import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.buildJsonObject
@@ -29,8 +30,12 @@ class SuwayomiApi(
) { ) {
private val json: Json by injectLazy() private val json: Json by injectLazy()
private val source: HttpSource by lazy { (sourceManager.get(sourceId) as HttpSource) }
private val configurableSource: ConfigurableSource by lazy { (sourceManager.get(sourceId) as ConfigurableSource) } // Blocking is fine here: these are only touched from OkHttp and tracker threads.
private val source: HttpSource by lazy { runBlocking { sourceManager.get(sourceId) as HttpSource } }
private val configurableSource: ConfigurableSource by lazy {
runBlocking { sourceManager.get(sourceId) as ConfigurableSource }
}
private val client: OkHttpClient by lazy { source.client } private val client: OkHttpClient by lazy { source.client }
private val baseUrl: String by lazy { source.baseUrl.trimEnd('/') } private val baseUrl: String by lazy { source.baseUrl.trimEnd('/') }
private val apiUrl: String by lazy { "$baseUrl/api/graphql" } private val apiUrl: String by lazy { "$baseUrl/api/graphql" }
@@ -16,6 +16,7 @@ import eu.kanade.tachiyomi.extension.util.ExtensionInstallReceiver
import eu.kanade.tachiyomi.extension.util.ExtensionInstaller import eu.kanade.tachiyomi.extension.util.ExtensionInstaller
import eu.kanade.tachiyomi.extension.util.ExtensionLoader import eu.kanade.tachiyomi.extension.util.ExtensionLoader
import eu.kanade.tachiyomi.util.system.toast import eu.kanade.tachiyomi.util.system.toast
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
@@ -24,7 +25,6 @@ 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.emptyFlow import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
@@ -56,18 +56,18 @@ class ExtensionManager(
val scope = CoroutineScope(SupervisorJob()) val scope = CoroutineScope(SupervisorJob())
private val isInitialized = MutableStateFlow(false) private val initialized = CompletableDeferred<Unit>()
private val iconMap = mutableMapOf<String, Drawable>() private val iconMap = mutableMapOf<String, Drawable>()
private val installedExtensionMapFlow = MutableStateFlow(emptyMap<String, Extension.Installed>()) private val installedExtensionMapFlow = MutableStateFlow(emptyMap<String, Extension.Installed>())
val installedExtensionsFlow = installedExtensionMapFlow.mapExtensionsOnceInitialized() val installedExtensionsFlow = installedExtensionMapFlow.mapExtensionsWhenInitialized()
private val availableExtensionMapFlow = MutableStateFlow(emptyMap<String, Extension.Available>()) private val availableExtensionMapFlow = MutableStateFlow(emptyMap<String, Extension.Available>())
val availableExtensionsFlow = availableExtensionMapFlow.mapExtensions(scope) val availableExtensionsFlow = availableExtensionMapFlow.mapExtensions(scope)
private val untrustedExtensionMapFlow = MutableStateFlow(emptyMap<String, Extension.Untrusted>()) private val untrustedExtensionMapFlow = MutableStateFlow(emptyMap<String, Extension.Untrusted>())
val untrustedExtensionsFlow = untrustedExtensionMapFlow.mapExtensionsOnceInitialized() val untrustedExtensionsFlow = untrustedExtensionMapFlow.mapExtensionsWhenInitialized()
init { init {
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
@@ -78,8 +78,13 @@ class ExtensionManager(
private var subLanguagesEnabledOnFirstRun = preferences.enabledLanguages.isSet() private var subLanguagesEnabledOnFirstRun = preferences.enabledLanguages.isSet()
fun getExtensionPackage(sourceId: Long): String? { suspend fun getInstalledExtensions(): List<Extension.Installed> {
return installedExtensionMapFlow.value.values.find { extension -> initialized.await()
return installedExtensionMapFlow.value.values.toList()
}
suspend fun getExtensionPackage(sourceId: Long): String? {
return getInstalledExtensions().find { extension ->
extension.sources.any { it.id == sourceId } extension.sources.any { it.id == sourceId }
} }
?.pkgName ?.pkgName
@@ -94,7 +99,7 @@ class ExtensionManager(
} }
} }
fun getAppIconForSource(sourceId: Long): Drawable? { suspend fun getAppIconForSource(sourceId: Long): Drawable? {
val pkgName = getExtensionPackage(sourceId) ?: return null val pkgName = getExtensionPackage(sourceId) ?: return null
return iconMap[pkgName] ?: iconMap.getOrPut(pkgName) { return iconMap[pkgName] ?: iconMap.getOrPut(pkgName) {
@@ -118,17 +123,23 @@ class ExtensionManager(
* Loads and registers the installed extensions. * Loads and registers the installed extensions.
*/ */
private fun initExtensions() { private fun initExtensions() {
val extensions = ExtensionLoader.loadExtensions(context) try {
val extensions = ExtensionLoader.loadExtensions(context)
installedExtensionMapFlow.value = extensions installedExtensionMapFlow.value = extensions
.filterIsInstance<LoadResult.Success>() .filterIsInstance<LoadResult.Success>()
.associate { it.extension.pkgName to it.extension } .associate { it.extension.pkgName to it.extension }
untrustedExtensionMapFlow.value = extensions untrustedExtensionMapFlow.value = extensions
.filterIsInstance<LoadResult.Untrusted>() .filterIsInstance<LoadResult.Untrusted>()
.associate { it.extension.pkgName to it.extension } .associate { it.extension.pkgName to it.extension }
isInitialized.value = true initialized.complete(Unit)
} catch (e: Throwable) {
// Release anything waiting on the extensions before the failure propagates
initialized.complete(Unit)
throw e
}
} }
/** /**
@@ -380,10 +391,9 @@ class ExtensionManager(
} }
/** /**
* Extensions are loaded in the background, and [stateIn] would replay the empty list it was * Extensions are loaded in the background, so this flow only starts emitting once that finished.
* seeded with at construction, so this only starts emitting once that finished.
*/ */
private fun <T : Extension> StateFlow<Map<String, T>>.mapExtensionsOnceInitialized(): Flow<List<T>> { private fun <T : Extension> StateFlow<Map<String, T>>.mapExtensionsWhenInitialized(): Flow<List<T>> {
return onStart { isInitialized.first { it } }.map { it.values.toList() } return onStart { initialized.await() }.map { it.values.toList() }
} }
} }
@@ -12,12 +12,11 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import tachiyomi.domain.source.model.StubSource import tachiyomi.domain.source.model.StubSource
import tachiyomi.domain.source.repository.StubSourceRepository import tachiyomi.domain.source.repository.StubSourceRepository
import tachiyomi.domain.source.service.SourceManager import tachiyomi.domain.source.service.SourceManager
@@ -34,16 +33,18 @@ class AndroidSourceManager(
private val downloadManager: Lazy<DownloadManager>, private val downloadManager: Lazy<DownloadManager>,
) : SourceManager { ) : SourceManager {
private val _isInitialized = MutableStateFlow(false)
override val isInitialized: StateFlow<Boolean> = _isInitialized.asStateFlow()
private val scope = CoroutineScope(Job() + Dispatchers.IO) private val scope = CoroutineScope(Job() + Dispatchers.IO)
private val sourcesMapFlow = MutableStateFlow(ConcurrentHashMap<Long, Source>()) /**
* Null until the extensions have loaded, so that nothing observes the empty seed value.
*/
private val sourcesMapFlow = MutableStateFlow<Map<Long, Source>?>(null)
private val stubSourcesMap = ConcurrentHashMap<Long, StubSource>() private val stubSourcesMap = ConcurrentHashMap<Long, StubSource>()
override val sources: Flow<List<Source>> = sourcesMapFlow.map { it.values.toList() } override val sources: Flow<List<Source>> = sourcesMapFlow
.filterNotNull()
.map { it.values.toList() }
init { init {
scope.launch { scope.launch {
@@ -59,7 +60,6 @@ class AndroidSourceManager(
} }
} }
sourcesMapFlow.value = mutableMap sourcesMapFlow.value = mutableMap
_isInitialized.value = true
} }
} }
@@ -74,21 +74,30 @@ class AndroidSourceManager(
} }
} }
override fun get(sourceKey: Long): Source? { /**
return sourcesMapFlow.value[sourceKey] * Awaits the extensions to have loaded before returning the sources.
*/
private suspend fun sourcesMap(): Map<Long, Source> = sourcesMapFlow.filterNotNull().first()
override suspend fun get(sourceKey: Long): Source? {
return sourcesMap()[sourceKey]
} }
override fun getOrStub(sourceKey: Long): Source { override suspend fun getOrStub(sourceKey: Long): Source {
return sourcesMapFlow.value[sourceKey] ?: stubSourcesMap.getOrPut(sourceKey) { return sourcesMap()[sourceKey] ?: stubSourcesMap.getOrPut(sourceKey) {
runBlocking { createStubSource(sourceKey) } createStubSource(sourceKey)
} }
} }
override fun getAll() = sourcesMapFlow.value.values.toList() override suspend fun getAll(): List<Source> {
return sourcesMap().values.toList()
}
override fun getOnlineSources() = sourcesMapFlow.value.values.filterIsInstance<HttpSource>() override suspend fun getOnlineSources(): List<HttpSource> {
return sourcesMap().values.filterIsInstance<HttpSource>()
}
override fun getStubSources(): List<StubSource> { override suspend fun getStubSources(): List<StubSource> {
val onlineSourceIds = getOnlineSources().map { it.id } val onlineSourceIds = getOnlineSources().map { it.id }
return stubSourcesMap.values.filterNot { it.id in onlineSourceIds } return stubSourcesMap.values.filterNot { it.id in onlineSourceIds }
} }
@@ -11,6 +11,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -30,14 +31,15 @@ import androidx.preference.forEach
import androidx.preference.getOnBindEditTextListener import androidx.preference.getOnBindEditTextListener
import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow import cafe.adriel.voyager.navigator.currentOrThrow
import eu.kanade.core.util.ifSourcesLoaded
import eu.kanade.presentation.components.AppBar import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.util.Screen import eu.kanade.presentation.util.Screen
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.preference.SharedPreferencesDataStore import eu.kanade.tachiyomi.data.preference.SharedPreferencesDataStore
import eu.kanade.tachiyomi.source.ConfigurableSource import eu.kanade.tachiyomi.source.ConfigurableSource
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.sourcePreferences import eu.kanade.tachiyomi.source.sourcePreferences
import eu.kanade.tachiyomi.widget.TachiyomiTextInputEditText.Companion.setIncognito import eu.kanade.tachiyomi.widget.TachiyomiTextInputEditText.Companion.setIncognito
import kotlinx.coroutines.launch
import mihon.app.di.appGraph import mihon.app.di.appGraph
import tachiyomi.presentation.core.components.material.Scaffold import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.screens.LoadingScreen import tachiyomi.presentation.core.screens.LoadingScreen
@@ -46,18 +48,22 @@ class SourcePreferencesScreen(val sourceId: Long) : Screen() {
@Composable @Composable
override fun Content() { override fun Content() {
if (!ifSourcesLoaded()) { val context = LocalContext.current
val navigator = LocalNavigator.currentOrThrow
val source by produceState<Source?>(initialValue = null) {
value = context.appGraph.sourceManager.getOrStub(sourceId)
}
if (source == null) {
LoadingScreen() LoadingScreen()
return return
} }
val context = LocalContext.current
val navigator = LocalNavigator.currentOrThrow
Scaffold( Scaffold(
topBar = { topBar = {
AppBar( AppBar(
title = context.appGraph.sourceManager.getOrStub(sourceId).toString(), title = source.toString(),
navigateUp = navigator::pop, navigateUp = navigator::pop,
scrollBehavior = it, scrollBehavior = it,
) )
@@ -125,10 +131,13 @@ class SourcePreferencesFragment : PreferenceFragmentCompat() {
} }
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
preferenceScreen = populateScreen() preferenceScreen = preferenceManager.createPreferenceScreen(requireContext())
lifecycleScope.launch {
preferenceScreen = populateScreen()
}
} }
private fun populateScreen(): PreferenceScreen { private suspend fun populateScreen(): PreferenceScreen {
val sourceId = requireArguments().getLong(SOURCE_ID) val sourceId = requireArguments().getLong(SOURCE_ID)
val appGraph = requireContext().appGraph val appGraph = requireContext().appGraph
val source = appGraph.sourceManager.getOrStub(sourceId) val source = appGraph.sourceManager.getOrStub(sourceId)
@@ -12,6 +12,7 @@ import dev.zacsweers.metrox.viewmodel.ManualViewModelAssistedFactory
import dev.zacsweers.metrox.viewmodel.ManualViewModelAssistedFactoryKey import dev.zacsweers.metrox.viewmodel.ManualViewModelAssistedFactoryKey
import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.Source
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -50,7 +51,7 @@ class MigrateMangaViewModel(
private val _events: Channel<MigrationMangaEvent> = Channel() private val _events: Channel<MigrationMangaEvent> = Channel()
val events: Flow<MigrationMangaEvent> = _events.receiveAsFlow() val events: Flow<MigrationMangaEvent> = _events.receiveAsFlow()
private val source by lazy { sourceManager.getOrStub(sourceId) } private val source = viewModelScope.async { sourceManager.getOrStub(sourceId) }
private val selection = MutableStateFlow(emptySet<Long>()) private val selection = MutableStateFlow(emptySet<Long>())
@@ -68,7 +69,7 @@ class MigrateMangaViewModel(
favorites, favorites,
selection, selection,
) { titleList, selection -> ) { titleList, selection ->
State(source = source, selection = selection, titleList = titleList) State(source = source.await(), selection = selection, titleList = titleList)
} }
.flowOn(Dispatchers.IO) .flowOn(Dispatchers.IO)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), State()) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), State())
@@ -64,7 +64,7 @@ class MigrateSearchViewModel(
} }
} }
override fun getEnabledSources(): List<Source> { override suspend fun getEnabledSources(): List<Source> {
return migrationSources.mapNotNull { sourceManager.get(it) } return migrationSources.mapNotNull { sourceManager.get(it) }
} }
} }
@@ -20,7 +20,6 @@ import androidx.compose.ui.platform.LocalUriHandler
import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow import cafe.adriel.voyager.navigator.currentOrThrow
import dev.zacsweers.metrox.viewmodel.assistedMetroViewModel import dev.zacsweers.metrox.viewmodel.assistedMetroViewModel
import eu.kanade.core.util.ifSourcesLoaded
import eu.kanade.presentation.browse.BrowseSourceContent import eu.kanade.presentation.browse.BrowseSourceContent
import eu.kanade.presentation.components.SearchToolbar import eu.kanade.presentation.components.SearchToolbar
import eu.kanade.presentation.util.Screen import eu.kanade.presentation.util.Screen
@@ -50,11 +49,6 @@ data class MigrateSourceSearchScreen(
@Composable @Composable
override fun Content() { override fun Content() {
if (!ifSourcesLoaded()) {
LoadingScreen()
return
}
val uriHandler = LocalUriHandler.current val uriHandler = LocalUriHandler.current
val navigator = LocalNavigator.currentOrThrow val navigator = LocalNavigator.currentOrThrow
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -65,6 +59,12 @@ data class MigrateSourceSearchScreen(
} }
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
val source = state.source
if (source == null) {
LoadingScreen()
return
}
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
Scaffold( Scaffold(
@@ -103,19 +103,19 @@ data class MigrateSourceSearchScreen(
} }
} }
BrowseSourceContent( BrowseSourceContent(
source = viewModel.source, source = source,
mangaList = viewModel.mangaPagerFlowFlow.collectAsLazyPagingItems(), mangaList = viewModel.mangaPagerFlowFlow.collectAsLazyPagingItems(),
columns = viewModel.getColumnsPreference(LocalConfiguration.current.orientation), columns = viewModel.getColumnsPreference(LocalConfiguration.current.orientation),
displayMode = viewModel.displayMode, displayMode = viewModel.displayMode,
snackbarHostState = snackbarHostState, snackbarHostState = snackbarHostState,
contentPadding = paddingValues, contentPadding = paddingValues,
onWebViewClick = { onWebViewClick = {
val source = viewModel.source as? HttpSource ?: return@BrowseSourceContent val httpSource = source as? HttpSource ?: return@BrowseSourceContent
navigator.push( navigator.push(
WebViewScreen( WebViewScreen(
url = source.getHomeUrl(), url = httpSource.getHomeUrl(),
initialTitle = source.name, initialTitle = httpSource.name,
sourceId = source.id, sourceId = httpSource.id,
), ),
) )
}, },
@@ -35,7 +35,6 @@ import androidx.compose.ui.platform.LocalUriHandler
import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow import cafe.adriel.voyager.navigator.currentOrThrow
import dev.zacsweers.metrox.viewmodel.assistedMetroViewModel import dev.zacsweers.metrox.viewmodel.assistedMetroViewModel
import eu.kanade.core.util.ifSourcesLoaded
import eu.kanade.presentation.browse.BrowseSourceContent import eu.kanade.presentation.browse.BrowseSourceContent
import eu.kanade.presentation.browse.MissingSourceScreen import eu.kanade.presentation.browse.MissingSourceScreen
import eu.kanade.presentation.browse.components.BrowseSourceToolbar import eu.kanade.presentation.browse.components.BrowseSourceToolbar
@@ -76,11 +75,6 @@ data class BrowseSourceScreen(
@Composable @Composable
override fun Content() { override fun Content() {
if (!ifSourcesLoaded()) {
LoadingScreen()
return
}
val viewModel = val viewModel =
assistedMetroViewModel<BrowseSourceViewModel, BrowseSourceViewModel.Factory> { assistedMetroViewModel<BrowseSourceViewModel, BrowseSourceViewModel.Factory> {
create(sourceId = sourceId, listingQuery = listingQuery) create(sourceId = sourceId, listingQuery = listingQuery)
@@ -95,9 +89,15 @@ data class BrowseSourceScreen(
} }
} }
if (viewModel.source is StubSource) { val source = state.source
if (source == null) {
LoadingScreen()
return
}
if (source is StubSource) {
MissingSourceScreen( MissingSourceScreen(
source = viewModel.source, source = source,
navigateUp = navigateUp, navigateUp = navigateUp,
) )
return return
@@ -110,18 +110,18 @@ data class BrowseSourceScreen(
val onHelpClick = { uriHandler.openUri(LocalSource.HELP_URL) } val onHelpClick = { uriHandler.openUri(LocalSource.HELP_URL) }
val onWebViewClick = f@{ val onWebViewClick = f@{
val source = viewModel.source as? HttpSource ?: return@f val httpSource = source as? HttpSource ?: return@f
navigator.push( navigator.push(
WebViewScreen( WebViewScreen(
url = source.getHomeUrl(), url = httpSource.getHomeUrl(),
initialTitle = source.name, initialTitle = httpSource.name,
sourceId = source.id, sourceId = httpSource.id,
), ),
) )
} }
LaunchedEffect(viewModel.source) { LaunchedEffect(source) {
assistUrl = (viewModel.source as? HttpSource)?.getHomeUrl() assistUrl = (source as? HttpSource)?.getHomeUrl()
} }
Scaffold( Scaffold(
@@ -134,7 +134,7 @@ data class BrowseSourceScreen(
BrowseSourceToolbar( BrowseSourceToolbar(
searchQuery = state.toolbarQuery, searchQuery = state.toolbarQuery,
onSearchQueryChange = viewModel::setToolbarQuery, onSearchQueryChange = viewModel::setToolbarQuery,
source = viewModel.source, source = source,
displayMode = viewModel.displayMode, displayMode = viewModel.displayMode,
onDisplayModeChange = { viewModel.displayMode = it }, onDisplayModeChange = { viewModel.displayMode = it },
navigateUp = navigateUp, navigateUp = navigateUp,
@@ -168,7 +168,7 @@ data class BrowseSourceScreen(
Text(text = stringResource(MR.strings.popular)) Text(text = stringResource(MR.strings.popular))
}, },
) )
if (viewModel.source.supportsLatest) { if (source.supportsLatest) {
FilterChip( FilterChip(
selected = state.listing == Listing.Latest, selected = state.listing == Listing.Latest,
onClick = { onClick = {
@@ -213,7 +213,7 @@ data class BrowseSourceScreen(
snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
) { paddingValues -> ) { paddingValues ->
BrowseSourceContent( BrowseSourceContent(
source = viewModel.source, source = source,
mangaList = viewModel.mangaPagerFlowFlow.collectAsLazyPagingItems(), mangaList = viewModel.mangaPagerFlowFlow.collectAsLazyPagingItems(),
columns = viewModel.getColumnsPreference(LocalConfiguration.current.orientation), columns = viewModel.getColumnsPreference(LocalConfiguration.current.orientation),
displayMode = viewModel.displayMode, displayMode = viewModel.displayMode,
@@ -26,6 +26,7 @@ import eu.kanade.domain.source.interactor.GetIncognitoState
import eu.kanade.domain.source.service.SourcePreferences import eu.kanade.domain.source.service.SourcePreferences
import eu.kanade.domain.track.interactor.AddTracks import eu.kanade.domain.track.interactor.AddTracks
import eu.kanade.tachiyomi.data.cache.CoverCache import eu.kanade.tachiyomi.data.cache.CoverCache
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.model.FilterList import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.util.removeCovers import eu.kanade.tachiyomi.util.removeCovers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -33,6 +34,7 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
@@ -60,7 +62,7 @@ import eu.kanade.tachiyomi.source.model.Filter as SourceModelFilter
class BrowseSourceViewModel( class BrowseSourceViewModel(
@Assisted private val sourceId: Long, @Assisted private val sourceId: Long,
@Assisted listingQuery: String?, @Assisted listingQuery: String?,
sourceManager: SourceManager, private val sourceManager: SourceManager,
sourcePreferences: SourcePreferences, sourcePreferences: SourcePreferences,
private val libraryPreferences: LibraryPreferences, private val libraryPreferences: LibraryPreferences,
private val coverCache: CoverCache, private val coverCache: CoverCache,
@@ -87,27 +89,32 @@ class BrowseSourceViewModel(
var displayMode by sourcePreferences.sourceDisplayMode.asState(viewModelScope) var displayMode by sourcePreferences.sourceDisplayMode.asState(viewModelScope)
val source = sourceManager.getOrStub(sourceId) private val source: Source? get() = state.value.source
init { init {
state.update { viewModelScope.launchIO {
var query: String? = null val source = sourceManager.getOrStub(sourceId)
var listing = it.listing
if (listing is Listing.Search) { state.update {
query = listing.query var query: String? = null
listing = Listing.Search(query, source.getFilterList()) var listing = it.listing
if (listing is Listing.Search) {
query = listing.query
listing = Listing.Search(query, source.getFilterList())
}
it.copy(
source = source,
listing = listing,
filters = source.getFilterList(),
toolbarQuery = query,
)
} }
it.copy( if (!getIncognitoState.await(source.id)) {
listing = listing, sourcePreferences.lastUsedSource.set(source.id)
filters = source.getFilterList(), }
toolbarQuery = query,
)
}
if (!getIncognitoState.await(source.id)) {
sourcePreferences.lastUsedSource.set(source.id)
} }
} }
@@ -115,7 +122,9 @@ class BrowseSourceViewModel(
* Flow of Pager flow tied to [State.listing] * Flow of Pager flow tied to [State.listing]
*/ */
private val hideInLibraryItems = sourcePreferences.hideInLibraryItems.get() private val hideInLibraryItems = sourcePreferences.hideInLibraryItems.get()
val mangaPagerFlowFlow = state.map { it.listing } val mangaPagerFlowFlow = state.map { it.source to it.listing }
.filter { (source, _) -> source != null }
.map { (_, listing) -> listing }
.distinctUntilChanged() .distinctUntilChanged()
.map { listing -> .map { listing ->
Pager(PagingConfig(pageSize = 25)) { Pager(PagingConfig(pageSize = 25)) {
@@ -143,6 +152,7 @@ class BrowseSourceViewModel(
} }
fun resetFilters() { fun resetFilters() {
val source = source ?: return
state.update { it.copy(filters = source.getFilterList()) } state.update { it.copy(filters = source.getFilterList()) }
} }
@@ -160,7 +170,7 @@ class BrowseSourceViewModel(
fun search(query: String? = null, filters: FilterList? = null) { fun search(query: String? = null, filters: FilterList? = null) {
val input = state.value.listing as? Listing.Search val input = state.value.listing as? Listing.Search
?: Listing.Search(query = null, filters = source.getFilterList()) ?: Listing.Search(query = null, filters = source?.getFilterList() ?: FilterList())
state.update { state.update {
it.copy( it.copy(
@@ -174,7 +184,7 @@ class BrowseSourceViewModel(
} }
fun searchGenre(genreName: String) { fun searchGenre(genreName: String) {
val defaultFilters = source.getFilterList() val defaultFilters = source?.getFilterList() ?: return
var genreExists = false var genreExists = false
filter@ for (sourceFilter in defaultFilters) { filter@ for (sourceFilter in defaultFilters) {
@@ -235,7 +245,7 @@ class BrowseSourceViewModel(
new = new.removeCovers(coverCache) new = new.removeCovers(coverCache)
} else { } else {
setMangaDefaultChapterFlags.await(manga) setMangaDefaultChapterFlags.await(manga)
addTracks.bindEnhancedTrackers(manga, source) addTracks.bindEnhancedTrackers(manga, sourceManager.getOrStub(manga.source))
} }
updateManga.await(new.toMangaUpdate()) updateManga.await(new.toMangaUpdate())
@@ -351,6 +361,7 @@ class BrowseSourceViewModel(
@Immutable @Immutable
data class State( data class State(
val listing: Listing, val listing: Listing,
val source: Source? = null,
val filters: FilterList = FilterList(), val filters: FilterList = FilterList(),
val toolbarQuery: String? = null, val toolbarQuery: String? = null,
val dialog: Dialog? = null, val dialog: Dialog? = null,
@@ -10,7 +10,6 @@ import androidx.compose.runtime.setValue
import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow import cafe.adriel.voyager.navigator.currentOrThrow
import dev.zacsweers.metrox.viewmodel.assistedMetroViewModel import dev.zacsweers.metrox.viewmodel.assistedMetroViewModel
import eu.kanade.core.util.ifSourcesLoaded
import eu.kanade.presentation.browse.GlobalSearchScreen import eu.kanade.presentation.browse.GlobalSearchScreen
import eu.kanade.presentation.util.Screen import eu.kanade.presentation.util.Screen
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceScreen import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceScreen
@@ -24,11 +23,6 @@ class GlobalSearchScreen(
@Composable @Composable
override fun Content() { override fun Content() {
if (!ifSourcesLoaded()) {
LoadingScreen()
return
}
val navigator = LocalNavigator.currentOrThrow val navigator = LocalNavigator.currentOrThrow
val viewModel = val viewModel =
@@ -52,7 +52,7 @@ class GlobalSearchViewModel(
} }
} }
override fun getEnabledSources(): List<Source> { override suspend fun getEnabledSources(): List<Source> {
return super.getEnabledSources() return super.getEnabledSources()
.filter { state.value.sourceFilter != SourceFilter.PinnedOnly || "${it.id}" in pinnedSources } .filter { state.value.sourceFilter != SourceFilter.PinnedOnly || "${it.id}" in pinnedSources }
} }
@@ -16,7 +16,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -88,7 +87,7 @@ abstract class SearchViewModel(
} }
} }
open fun getEnabledSources(): List<Source> { open suspend fun getEnabledSources(): List<Source> {
return sourceManager.getAll() return sourceManager.getAll()
.filter { it.lang in enabledLanguages && "${it.id}" !in disabledSources } .filter { it.lang in enabledLanguages && "${it.id}" !in disabledSources }
.sortedWith( .sortedWith(
@@ -107,7 +106,7 @@ abstract class SearchViewModel(
return enabledSources return enabledSources
} }
return extensionManager.installedExtensionsFlow.first() return extensionManager.getInstalledExtensions()
.filter { it.pkgName == filter } .filter { it.pkgName == filter }
.flatMap { it.sources } .flatMap { it.sources }
.filter { it in enabledSources } .filter { it in enabledSources }
@@ -171,7 +171,7 @@ class MainActivity : BaseActivity() {
setComposeContent { setComposeContent {
val context = LocalContext.current val context = LocalContext.current
var incognito by remember { mutableStateOf(getIncognitoState.await(null)) } var incognito by remember { mutableStateOf(false) }
val downloadOnly by preferences.downloadedOnly.collectAsState() val downloadOnly by preferences.downloadedOnly.collectAsState()
val indexing by downloadCache.isInitializing.collectAsState() val indexing by downloadCache.isInitializing.collectAsState()
@@ -21,7 +21,6 @@ import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.Navigator import cafe.adriel.voyager.navigator.Navigator
import cafe.adriel.voyager.navigator.currentOrThrow import cafe.adriel.voyager.navigator.currentOrThrow
import dev.zacsweers.metrox.viewmodel.assistedMetroViewModel import dev.zacsweers.metrox.viewmodel.assistedMetroViewModel
import eu.kanade.core.util.ifSourcesLoaded
import eu.kanade.domain.manga.model.hasCustomCover import eu.kanade.domain.manga.model.hasCustomCover
import eu.kanade.domain.manga.model.toSManga import eu.kanade.domain.manga.model.toSManga
import eu.kanade.presentation.category.components.ChangeCategoryDialog import eu.kanade.presentation.category.components.ChangeCategoryDialog
@@ -73,11 +72,6 @@ class MangaScreen(
@Composable @Composable
override fun Content() { override fun Content() {
if (!ifSourcesLoaded()) {
LoadingScreen()
return
}
val navigator = LocalNavigator.currentOrThrow val navigator = LocalNavigator.currentOrThrow
val context = LocalContext.current val context = LocalContext.current
val haptic = LocalHapticFeedback.current val haptic = LocalHapticFeedback.current
@@ -798,7 +798,7 @@ class MangaViewModel(
* Downloads the given list of chapters with the manager. * Downloads the given list of chapters with the manager.
* @param chapters the list of chapters to download. * @param chapters the list of chapters to download.
*/ */
private fun downloadChapters(chapters: List<Chapter>) { private suspend fun downloadChapters(chapters: List<Chapter>) {
val manga = successState?.manga ?: return val manga = successState?.manga ?: return
downloadManager.downloadChapters(manga, chapters) downloadManager.downloadChapters(manga, chapters)
toggleAllSelection(false) toggleAllSelection(false)
@@ -273,7 +273,7 @@ data class TrackInfoDialogHomeScreen(
} }
} }
private fun List<Track>.mapToTrackItem(): List<TrackItem> { private suspend fun List<Track>.mapToTrackItem(): List<TrackItem> {
val loggedInTrackers = trackerManager.loggedInTrackers() val loggedInTrackers = trackerManager.loggedInTrackers()
val source = sourceManager.getOrStub(sourceId) val source = sourceManager.getOrStub(sourceId)
return loggedInTrackers return loggedInTrackers
@@ -48,7 +48,6 @@ import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import com.google.android.material.transition.platform.MaterialContainerTransform import com.google.android.material.transition.platform.MaterialContainerTransform
import com.hippo.unifile.UniFile import com.hippo.unifile.UniFile
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import eu.kanade.core.util.ifSourcesLoaded
import eu.kanade.domain.base.BasePreferences import eu.kanade.domain.base.BasePreferences
import eu.kanade.presentation.reader.DisplayRefreshHost import eu.kanade.presentation.reader.DisplayRefreshHost
import eu.kanade.presentation.reader.OrientationSelectDialog import eu.kanade.presentation.reader.OrientationSelectDialog
@@ -454,11 +453,7 @@ class ReaderActivity : BaseActivity() {
@Composable @Composable
fun AppBars(state: ReaderViewModel.State) { fun AppBars(state: ReaderViewModel.State) {
if (!ifSourcesLoaded()) { val isHttpSource = state.source is HttpSource
return
}
val isHttpSource = viewModel.getSource() is HttpSource
val cropBorderPaged by readerPreferences.cropBorders.collectAsState() val cropBorderPaged by readerPreferences.cropBorders.collectAsState()
val cropBorderWebtoon by readerPreferences.cropBordersWebtoon.collectAsState() val cropBorderWebtoon by readerPreferences.cropBordersWebtoon.collectAsState()
@@ -35,6 +35,7 @@ import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.data.saver.Image import eu.kanade.tachiyomi.data.saver.Image
import eu.kanade.tachiyomi.data.saver.ImageSaver import eu.kanade.tachiyomi.data.saver.ImageSaver
import eu.kanade.tachiyomi.data.saver.Location import eu.kanade.tachiyomi.data.saver.Location
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.reader.loader.ChapterLoader import eu.kanade.tachiyomi.ui.reader.loader.ChapterLoader
@@ -155,6 +156,12 @@ class ReaderViewModel(
val manga: Manga? val manga: Manga?
get() = state.value.manga get() = state.value.manga
/**
* The source of the manga loaded in the reader. Null until it has been resolved.
*/
val source: Source?
get() = state.value.source
/** /**
* The chapter id of the currently loaded chapter. Used to restore from process kill. * The chapter id of the currently loaded chapter. Used to restore from process kill.
*/ */
@@ -265,7 +272,7 @@ class ReaderViewModel(
.map(::ReaderChapter) .map(::ReaderChapter)
} }
private val incognitoMode: Boolean by lazy { getIncognitoState.await(manga?.source) } private var incognitoMode: Boolean = false
private val downloadAheadAmount = downloadPreferences.autoDownloadWhileReading.get() private val downloadAheadAmount = downloadPreferences.autoDownloadWhileReading.get()
init { init {
@@ -316,11 +323,11 @@ class ReaderViewModel(
withIOContext { withIOContext {
try { try {
val manga = getManga.await(mangaId) ?: error("Requested manga of id $mangaId not found") val manga = getManga.await(mangaId) ?: error("Requested manga of id $mangaId not found")
sourceManager.isInitialized.first { it } val source = sourceManager.getOrStub(manga.source)
mutableState.update { it.copy(manga = manga) } incognitoMode = getIncognitoState.await(manga.source)
mutableState.update { it.copy(manga = manga, source = source) }
if (chapterId == -1L) chapterId = initialChapterId if (chapterId == -1L) chapterId = initialChapterId
val source = sourceManager.getOrStub(manga.source)
loader = ChapterLoader(context, downloadManager, downloadProvider, chapterCache, manga, source) loader = ChapterLoader(context, downloadManager, downloadProvider, chapterCache, manga, source)
loadChapter(loader!!, chapterList.first { chapterId == it.chapter.id }) loadChapter(loader!!, chapterList.first { chapterId == it.chapter.id })
@@ -425,13 +432,13 @@ class ReaderViewModel(
if (chapter.pageLoader?.isLocal == false) { if (chapter.pageLoader?.isLocal == false) {
val manga = manga ?: return val manga = manga ?: return
val dbChapter = chapter.chapter val dbChapter = chapter.chapter
val isDownloaded = downloadManager.isChapterDownloaded( val source = state.value.source ?: return
val isDownloaded = downloadManager.isChapterDownloadedOnDisk(
dbChapter.name, dbChapter.name,
dbChapter.scanlator, dbChapter.scanlator,
dbChapter.url, dbChapter.url,
manga.title, manga.title,
manga.source, source,
skipCache = true,
) )
if (isDownloaded) { if (isDownloaded) {
chapter.state = ReaderChapter.State.Wait chapter.state = ReaderChapter.State.Wait
@@ -654,7 +661,7 @@ class ReaderViewModel(
return state.value.currentChapter return state.value.currentChapter
} }
fun getSource() = manga?.source?.let { sourceManager.getOrStub(it) } as? HttpSource fun getSource() = state.value.source as? HttpSource
fun getChapterUrl(): String? { fun getChapterUrl(): String? {
val sChapter = getCurrentChapter()?.chapter ?: return null val sChapter = getCurrentChapter()?.chapter ?: return null
@@ -974,6 +981,7 @@ class ReaderViewModel(
@Immutable @Immutable
data class State( data class State(
val manga: Manga? = null, val manga: Manga? = null,
val source: Source? = null,
val initError: Throwable? = null, val initError: Throwable? = null,
val viewerChapters: ViewerChapters? = null, val viewerChapters: ViewerChapters? = null,
val bookmarked: Boolean = false, val bookmarked: Boolean = false,
@@ -79,13 +79,12 @@ class ChapterLoader(
*/ */
private fun getPageLoader(chapter: ReaderChapter): PageLoader { private fun getPageLoader(chapter: ReaderChapter): PageLoader {
val dbChapter = chapter.chapter val dbChapter = chapter.chapter
val isDownloaded = downloadManager.isChapterDownloaded( val isDownloaded = downloadManager.isChapterDownloadedOnDisk(
dbChapter.name, dbChapter.name,
dbChapter.scanlator, dbChapter.scanlator,
dbChapter.url, dbChapter.url,
manga.title, manga.title,
manga.source, source,
skipCache = true,
) )
return when { return when {
isDownloaded -> DownloadPageLoader( isDownloaded -> DownloadPageLoader(
@@ -14,6 +14,7 @@ import androidx.compose.ui.platform.AbstractComposeView
import eu.kanade.presentation.reader.ChapterTransition import eu.kanade.presentation.reader.ChapterTransition
import eu.kanade.presentation.theme.TachiyomiTheme import eu.kanade.presentation.theme.TachiyomiTheme
import eu.kanade.tachiyomi.data.download.DownloadManager import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition
import tachiyomi.domain.manga.model.Manga import tachiyomi.domain.manga.model.Manga
import tachiyomi.source.local.isLocal import tachiyomi.source.local.isLocal
@@ -27,20 +28,19 @@ class ReaderTransitionView @JvmOverloads constructor(context: Context, attrs: At
layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT) layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)
} }
fun bind(transition: ChapterTransition, downloadManager: DownloadManager, manga: Manga?) { fun bind(transition: ChapterTransition, downloadManager: DownloadManager, manga: Manga?, source: Source?) {
data = if (manga != null) { data = if (manga != null && source != null) {
Data( Data(
transition = transition, transition = transition,
currChapterDownloaded = transition.from.pageLoader?.isLocal == true, currChapterDownloaded = transition.from.pageLoader?.isLocal == true,
goingToChapterDownloaded = manga.isLocal() || goingToChapterDownloaded = manga.isLocal() ||
transition.to?.chapter?.let { goingToChapter -> transition.to?.chapter?.let { goingToChapter ->
downloadManager.isChapterDownloaded( downloadManager.isChapterDownloadedOnDisk(
chapterName = goingToChapter.name, chapterName = goingToChapter.name,
chapterScanlator = goingToChapter.scanlator, chapterScanlator = goingToChapter.scanlator,
chapterUrl = goingToChapter.url, chapterUrl = goingToChapter.url,
mangaTitle = manga.title, mangaTitle = manga.title,
sourceId = manga.source, source = source,
skipCache = true,
) )
} ?: false, } ?: false,
) )
@@ -62,7 +62,12 @@ class PagerTransitionHolder(
addView(transitionView) addView(transitionView)
addView(pagesContainer) addView(pagesContainer)
transitionView.bind(transition, viewer.downloadManager, viewer.activity.viewModel.manga) transitionView.bind(
transition,
viewer.downloadManager,
viewer.activity.viewModel.manga,
viewer.activity.viewModel.source,
)
transition.to?.let(::observeStatus) transition.to?.let(::observeStatus)
} }
@@ -64,7 +64,12 @@ class WebtoonTransitionHolder(
* Binds the given [transition] with this view holder, subscribing to its state. * Binds the given [transition] with this view holder, subscribing to its state.
*/ */
fun bind(transition: ChapterTransition) { fun bind(transition: ChapterTransition) {
transitionView.bind(transition, viewer.downloadManager, viewer.activity.viewModel.manga) transitionView.bind(
transition,
viewer.downloadManager,
viewer.activity.viewModel.manga,
viewer.activity.viewModel.source,
)
transition.to?.let { observeStatus(it, transition) } transition.to?.let { observeStatus(it, transition) }
} }
@@ -6,6 +6,8 @@ import android.content.Intent
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.widget.Toast import android.widget.Toast
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.core.net.toUri import androidx.core.net.toUri
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import eu.kanade.presentation.webview.WebViewScreenContent import eu.kanade.presentation.webview.WebViewScreenContent
@@ -24,6 +26,7 @@ import okhttp3.HttpUrl.Companion.toHttpUrl
import tachiyomi.core.common.util.system.logcat import tachiyomi.core.common.util.system.logcat
import tachiyomi.domain.source.service.SourceManager import tachiyomi.domain.source.service.SourceManager
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import tachiyomi.presentation.core.screens.LoadingScreen
class WebViewActivity : BaseActivity() { class WebViewActivity : BaseActivity() {
@@ -60,21 +63,28 @@ class WebViewActivity : BaseActivity() {
val url = intent.extras?.getString(URL_KEY) ?: return val url = intent.extras?.getString(URL_KEY) ?: return
assistUrl = url assistUrl = url
var headers = emptyMap<String, String>()
(sourceManager.get(intent.extras!!.getLong(SOURCE_KEY)) as? HttpSource)?.let { source ->
try {
headers = source.headers.toMultimap().mapValues { it.value.getOrNull(0) ?: "" }
} catch (e: Exception) {
logcat(LogPriority.ERROR, e) { "Failed to build headers" }
}
}
setComposeContent { setComposeContent {
// Null until the source it belongs to has been resolved
val headers by produceState<Map<String, String>?>(initialValue = null) {
val source = sourceManager.get(intent.extras!!.getLong(SOURCE_KEY)) as? HttpSource
value = try {
source?.headers?.toMultimap()?.mapValues { it.value.getOrNull(0) ?: "" }.orEmpty()
} catch (e: Exception) {
logcat(LogPriority.ERROR, e) { "Failed to build headers" }
emptyMap()
}
}
if (headers == null) {
LoadingScreen()
return@setComposeContent
}
WebViewScreenContent( WebViewScreenContent(
onNavigateUp = { finish() }, onNavigateUp = { finish() },
initialTitle = intent.extras?.getString(TITLE_KEY), initialTitle = intent.extras?.getString(TITLE_KEY),
url = url, url = url,
headers = headers, headers = headers.orEmpty(),
defaultUserAgentProvider = network::defaultUserAgentProvider, defaultUserAgentProvider = network::defaultUserAgentProvider,
onUrlChange = { assistUrl = it }, onUrlChange = { assistUrl = it },
onShare = this::shareWebpage, onShare = this::shareWebpage,
@@ -1,6 +1,8 @@
package eu.kanade.tachiyomi.ui.webview package eu.kanade.tachiyomi.ui.webview
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow import cafe.adriel.voyager.navigator.currentOrThrow
@@ -8,6 +10,7 @@ import dev.zacsweers.metrox.viewmodel.assistedMetroViewModel
import eu.kanade.presentation.util.AssistContentScreen import eu.kanade.presentation.util.AssistContentScreen
import eu.kanade.presentation.util.Screen import eu.kanade.presentation.util.Screen
import eu.kanade.presentation.webview.WebViewScreenContent import eu.kanade.presentation.webview.WebViewScreenContent
import tachiyomi.presentation.core.screens.LoadingScreen
class WebViewScreen( class WebViewScreen(
private val url: String, private val url: String,
@@ -26,11 +29,17 @@ class WebViewScreen(
val viewModel = val viewModel =
assistedMetroViewModel<WebViewViewModel, WebViewViewModel.Factory> { create(sourceId = sourceId) } assistedMetroViewModel<WebViewViewModel, WebViewViewModel.Factory> { create(sourceId = sourceId) }
val headers by viewModel.headers.collectAsState()
if (headers == null) {
LoadingScreen()
return
}
WebViewScreenContent( WebViewScreenContent(
onNavigateUp = { navigator.pop() }, onNavigateUp = { navigator.pop() },
initialTitle = initialTitle, initialTitle = initialTitle,
url = url, url = url,
headers = viewModel.headers, headers = headers.orEmpty(),
defaultUserAgentProvider = viewModel::defaultUserAgentProvider, defaultUserAgentProvider = viewModel::defaultUserAgentProvider,
onUrlChange = { assistUrl = it }, onUrlChange = { assistUrl = it },
onShare = { viewModel.shareWebpage(context, it) }, onShare = { viewModel.shareWebpage(context, it) },
@@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.ui.webview
import android.content.Context import android.content.Context
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedFactory
@@ -15,6 +16,9 @@ import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.util.system.openInBrowser import eu.kanade.tachiyomi.util.system.openInBrowser
import eu.kanade.tachiyomi.util.system.toShareIntent import eu.kanade.tachiyomi.util.system.toShareIntent
import eu.kanade.tachiyomi.util.system.toast import eu.kanade.tachiyomi.util.system.toast
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import logcat.LogPriority import logcat.LogPriority
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import tachiyomi.core.common.util.system.logcat import tachiyomi.core.common.util.system.logcat
@@ -34,14 +38,18 @@ class WebViewViewModel(
fun create(sourceId: Long?): WebViewViewModel fun create(sourceId: Long?): WebViewViewModel
} }
var headers = emptyMap<String, String>() /** Null until the source it belongs to has been resolved. */
val headers: StateFlow<Map<String, String>?>
field = MutableStateFlow<Map<String, String>?>(null)
init { init {
sourceId?.let { sourceManager.get(it) as? HttpSource }?.let { source -> viewModelScope.launch {
try { val source = sourceId?.let { sourceManager.get(it) as? HttpSource }
headers = source.headers.toMultimap().mapValues { it.value.getOrNull(0) ?: "" } headers.value = try {
source?.headers?.toMultimap()?.mapValues { it.value.getOrNull(0) ?: "" }.orEmpty()
} catch (e: Exception) { } catch (e: Exception) {
logcat(LogPriority.ERROR, e) { "Failed to build headers" } logcat(LogPriority.ERROR, e) { "Failed to build headers" }
emptyMap()
} }
} }
} }
@@ -12,7 +12,6 @@ import eu.kanade.tachiyomi.util.system.WebViewUtil
import eu.kanade.tachiyomi.util.system.createFileInCacheDir import eu.kanade.tachiyomi.util.system.createFileInCacheDir
import eu.kanade.tachiyomi.util.system.toShareIntent import eu.kanade.tachiyomi.util.system.toShareIntent
import eu.kanade.tachiyomi.util.system.toast import eu.kanade.tachiyomi.util.system.toast
import kotlinx.coroutines.flow.first
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.offsetAt import kotlinx.datetime.offsetAt
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
@@ -66,7 +65,7 @@ class CrashLogUtil(
private suspend fun getExtensionsInfo(): String? { private suspend fun getExtensionsInfo(): String? {
val availableExtensions = extensionManager.availableExtensionsFlow.value.associateBy { it.pkgName } val availableExtensions = extensionManager.availableExtensionsFlow.value.associateBy { it.pkgName }
val extensionInfoList = extensionManager.installedExtensionsFlow.first() val extensionInfoList = extensionManager.getInstalledExtensions()
.sortedBy { it.name } .sortedBy { it.name }
.mapNotNull { .mapNotNull {
val availableExtension = availableExtensions[it.pkgName] val availableExtension = availableExtensions[it.pkgName]
@@ -11,5 +11,5 @@ import tachiyomi.source.local.isLocal
fun List<Chapter>.filterDownloaded(manga: Manga, downloadCache: DownloadCache): List<Chapter> { fun List<Chapter>.filterDownloaded(manga: Manga, downloadCache: DownloadCache): List<Chapter> {
if (manga.isLocal()) return this if (manga.isLocal()) return this
return filter { downloadCache.isChapterDownloaded(it.name, it.scanlator, it.url, manga.title, manga.source, false) } return filter { downloadCache.isChapterDownloaded(it.name, it.scanlator, it.url, manga.title, manga.source) }
} }
@@ -346,7 +346,7 @@ class MigrationConfigScreen(private val mangaIds: Collection<Long>) : Screen() {
saveSources() saveSources()
} }
private fun initSources() { private suspend fun initSources() {
val languages = sourcePreferences.enabledLanguages.get() val languages = sourcePreferences.enabledLanguages.get()
val pinnedSources = sourcePreferences.pinnedSources.get().mapNotNull { it.toLongOrNull() } val pinnedSources = sourcePreferences.pinnedSources.get().mapNotNull { it.toLongOrNull() }
val includedSources = sourcePreferences.migrationSources.get() val includedSources = sourcePreferences.migrationSources.get()
@@ -4,6 +4,7 @@ import androidx.paging.PagingState
import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.model.FilterList import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.MangasPage import eu.kanade.tachiyomi.source.model.MangasPage
import kotlinx.coroutines.CancellationException
import mihon.domain.manga.model.toDomainManga import mihon.domain.manga.model.toDomainManga
import tachiyomi.core.common.util.lang.withIOContext import tachiyomi.core.common.util.lang.withIOContext
import tachiyomi.domain.manga.interactor.NetworkToLocalManga import tachiyomi.domain.manga.interactor.NetworkToLocalManga
@@ -11,49 +12,50 @@ import tachiyomi.domain.manga.model.Manga
import tachiyomi.domain.source.repository.SourcePagingSource import tachiyomi.domain.source.repository.SourcePagingSource
class SourceSearchPagingSource( class SourceSearchPagingSource(
source: Source, source: suspend () -> Source,
private val query: String, private val query: String,
private val filters: FilterList, private val filters: FilterList,
networkToLocalManga: NetworkToLocalManga, networkToLocalManga: NetworkToLocalManga,
) : BaseSourcePagingSource(source, networkToLocalManga) { ) : BaseSourcePagingSource(source, networkToLocalManga) {
override suspend fun requestNextPage(currentPage: Int): MangasPage { override suspend fun requestNextPage(source: Source, currentPage: Int): MangasPage {
return source.getSearchManga(currentPage, query, filters) return source.getSearchManga(currentPage, query, filters)
} }
} }
class SourcePopularPagingSource( class SourcePopularPagingSource(
source: Source, source: suspend () -> Source,
networkToLocalManga: NetworkToLocalManga, networkToLocalManga: NetworkToLocalManga,
) : BaseSourcePagingSource(source, networkToLocalManga) { ) : BaseSourcePagingSource(source, networkToLocalManga) {
override suspend fun requestNextPage(currentPage: Int): MangasPage { override suspend fun requestNextPage(source: Source, currentPage: Int): MangasPage {
return source.getPopularManga(currentPage) return source.getPopularManga(currentPage)
} }
} }
class SourceLatestPagingSource( class SourceLatestPagingSource(
source: Source, source: suspend () -> Source,
networkToLocalManga: NetworkToLocalManga, networkToLocalManga: NetworkToLocalManga,
) : BaseSourcePagingSource(source, networkToLocalManga) { ) : BaseSourcePagingSource(source, networkToLocalManga) {
override suspend fun requestNextPage(currentPage: Int): MangasPage { override suspend fun requestNextPage(source: Source, currentPage: Int): MangasPage {
return source.getLatestUpdates(currentPage) return source.getLatestUpdates(currentPage)
} }
} }
abstract class BaseSourcePagingSource( abstract class BaseSourcePagingSource(
protected val source: Source, private val source: suspend () -> Source,
private val networkToLocalManga: NetworkToLocalManga, private val networkToLocalManga: NetworkToLocalManga,
) : SourcePagingSource() { ) : SourcePagingSource() {
private val seenManga = hashSetOf<String>() private val seenManga = hashSetOf<String>()
abstract suspend fun requestNextPage(currentPage: Int): MangasPage abstract suspend fun requestNextPage(source: Source, currentPage: Int): MangasPage
override suspend fun load(params: LoadParams<Long>): LoadResult<Long, Manga> { override suspend fun load(params: LoadParams<Long>): LoadResult<Long, Manga> {
val page = params.key ?: 1 val page = params.key ?: 1
return try { return try {
val source = source()
val mangasPage = withIOContext { val mangasPage = withIOContext {
requestNextPage(page.toInt()) requestNextPage(source, page.toInt())
.takeIf { it.mangas.isNotEmpty() } .takeIf { it.mangas.isNotEmpty() }
?: throw NoResultsException() ?: throw NoResultsException()
} }
@@ -68,6 +70,8 @@ abstract class BaseSourcePagingSource(
prevKey = null, prevKey = null,
nextKey = if (mangasPage.hasNextPage) page + 1 else null, nextKey = if (mangasPage.hasNextPage) page + 1 else null,
) )
} catch (e: CancellationException) {
throw e
} catch (e: Exception) { } catch (e: Exception) {
LoadResult.Error(e) LoadResult.Error(e)
} }
@@ -85,15 +85,20 @@ class SourceRepositoryImpl(
query: String, query: String,
filterList: FilterList, filterList: FilterList,
): SourcePagingSource { ): SourcePagingSource {
return SourceSearchPagingSource(sourceManager.getOrStub(sourceId), query, filterList, networkToLocalManga) return SourceSearchPagingSource(
{ sourceManager.getOrStub(sourceId) },
query,
filterList,
networkToLocalManga,
)
} }
override fun getPopular(sourceId: Long): SourcePagingSource { override fun getPopular(sourceId: Long): SourcePagingSource {
return SourcePopularPagingSource(sourceManager.getOrStub(sourceId), networkToLocalManga) return SourcePopularPagingSource({ sourceManager.getOrStub(sourceId) }, networkToLocalManga)
} }
override fun getLatest(sourceId: Long): SourcePagingSource { override fun getLatest(sourceId: Long): SourcePagingSource {
return SourceLatestPagingSource(sourceManager.getOrStub(sourceId), networkToLocalManga) return SourceLatestPagingSource({ sourceManager.getOrStub(sourceId) }, networkToLocalManga)
} }
private fun mapSourceToDomainSource(source: Source): DomainSource = DomainSource( private fun mapSourceToDomainSource(source: Source): DomainSource = DomainSource(
@@ -3,22 +3,19 @@ package tachiyomi.domain.source.service
import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.HttpSource
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import tachiyomi.domain.source.model.StubSource import tachiyomi.domain.source.model.StubSource
interface SourceManager { interface SourceManager {
val isInitialized: StateFlow<Boolean>
val sources: Flow<List<Source>> val sources: Flow<List<Source>>
fun get(sourceKey: Long): Source? suspend fun get(sourceKey: Long): Source?
fun getOrStub(sourceKey: Long): Source suspend fun getOrStub(sourceKey: Long): Source
fun getAll(): List<Source> suspend fun getAll(): List<Source>
fun getOnlineSources(): List<HttpSource> suspend fun getOnlineSources(): List<HttpSource>
fun getStubSources(): List<StubSource> suspend fun getStubSources(): List<StubSource>
} }