Merge branch 'master' into dev/refactor-library

This commit is contained in:
Ahmad Ansori Palembani 2024-12-21 19:13:59 +07:00 committed by GitHub
commit 7836c26c6e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
93 changed files with 2289 additions and 1486 deletions

View file

@ -31,7 +31,7 @@ fun runCommand(command: String): String {
return String(byteOut.toByteArray()).trim()
}
val _versionName = "1.8.5.12"
val _versionName = "1.9.7"
val betaCount by lazy {
val betaTags = runCommand("git tag -l --sort=refname v${_versionName}-b*")
@ -54,7 +54,7 @@ val supportedAbis = setOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
android {
defaultConfig {
applicationId = "eu.kanade.tachiyomi"
versionCode = 150
versionCode = 156
versionName = _versionName
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
multiDexEnabled = true
@ -269,6 +269,11 @@ dependencies {
testRuntimeOnly(libs.bundles.test.runtime)
androidTestImplementation(libs.bundles.test.android)
testImplementation(kotlinx.coroutines.test)
// For detecting memory leaks
// REF: https://square.github.io/leakcanary/
debugImplementation(libs.leakcanary.android)
implementation(libs.leakcanary.plumber)
}
tasks {

View file

@ -34,6 +34,8 @@ import coil3.util.DebugLogger
import com.google.firebase.crashlytics.ktx.crashlytics
import com.google.firebase.ktx.Firebase
import com.hippo.unifile.UniFile
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.utils.Log.Level
import eu.kanade.tachiyomi.appwidget.TachiyomiWidgetManager
import eu.kanade.tachiyomi.core.preference.Preference
import eu.kanade.tachiyomi.core.preference.PreferenceStore
@ -139,6 +141,12 @@ open class App : Application(), DefaultLifecycleObserver, SingletonImageLoader.F
.onEach { ImageUtil.hardwareBitmapThreshold = it }
.launchIn(scope)
networkPreferences.verboseLogging().changes()
.onEach { enabled ->
FlexibleAdapter.enableLogs(if (enabled) Level.VERBOSE else Level.SUPPRESS)
}
.launchIn(scope)
scope.launchIO {
with(TachiyomiWidgetManager()) { this@App.init() }
}

View file

@ -1,6 +1,9 @@
package eu.kanade.tachiyomi.data.cache
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.os.Build
import android.text.format.Formatter
import co.touchlab.kermit.Logger
import coil3.imageLoader
@ -171,8 +174,38 @@ class CoverCache(val context: Context) {
*/
@Throws(IOException::class)
fun setCustomCoverToCache(manga: Manga, inputStream: InputStream) {
val maxTextureSize = 4096f
var bitmap = BitmapFactory.decodeStream(inputStream)
if (maxOf(bitmap.width, bitmap.height) > maxTextureSize) {
val widthRatio = bitmap.width / maxTextureSize
val heightRatio = bitmap.height / maxTextureSize
val targetWidth: Float
val targetHeight: Float
if (widthRatio >= heightRatio) {
targetWidth = maxTextureSize
targetHeight = (targetWidth / bitmap.width) * bitmap.height
} else {
targetHeight = maxTextureSize
targetWidth = (targetHeight / bitmap.height) * bitmap.width
}
val scaledBitmap = Bitmap.createScaledBitmap(bitmap, targetWidth.toInt(), targetHeight.toInt(), true)
bitmap.recycle()
bitmap = scaledBitmap
}
getCustomCoverFile(manga).outputStream().use {
inputStream.copyTo(it)
@Suppress("DEPRECATION")
bitmap.compress(
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R)
Bitmap.CompressFormat.WEBP_LOSSLESS
else
Bitmap.CompressFormat.WEBP,
100,
it
)
bitmap.recycle()
}
}

View file

@ -50,7 +50,7 @@ class TachiyomiImageDecoder(private val resources: ImageSource, private val opti
if (
Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
options.bitmapConfig == Bitmap.Config.HARDWARE &&
!ImageUtil.isMaxTextureSizeExceeded(bitmap)
!ImageUtil.isHardwareThresholdExceeded(bitmap)
) {
val hwBitmap = bitmap.copy(Bitmap.Config.HARDWARE, false)
if (hwBitmap != null) {
@ -59,29 +59,6 @@ class TachiyomiImageDecoder(private val resources: ImageSource, private val opti
}
}
/*
val maxTextureSize = 4096f
if (maxOf(bitmap.width, bitmap.height) > maxTextureSize) {
val widthRatio = bitmap.width / maxTextureSize
val heightRatio = bitmap.height / maxTextureSize
val targetWidth: Float
val targetHeight: Float
if (widthRatio >= heightRatio) {
targetWidth = maxTextureSize
targetHeight = (targetWidth / bitmap.width) * bitmap.height
} else {
targetHeight = maxTextureSize
targetWidth = (targetHeight / bitmap.height) * bitmap.width
}
val scaledBitmap = Bitmap.createScaledBitmap(bitmap, targetWidth.toInt(), targetHeight.toInt(), true)
bitmap.recycle()
bitmap = scaledBitmap
}
*/
return DecodeResult(
image = bitmap.asImage(),
isSampled = sampleSize > 1,

View file

@ -90,4 +90,8 @@ interface Chapter : SChapter, Serializable {
source_order = other.source_order
copyFrom(other as SChapter)
}
fun copy() = ChapterImpl().apply {
copyFrom(this@Chapter)
}
}

View file

@ -49,8 +49,8 @@ interface History : Serializable {
): History = HistoryImpl().apply {
this.id = id
this.chapter_id = chapterId
this.last_read = lastRead ?: 0L
this.time_read = timeRead ?: 0L
lastRead?.let { this.last_read = it }
timeRead?.let { this.time_read = it }
}
}
}

View file

@ -38,7 +38,7 @@ data class MangaChapterHistory(val manga: Manga, val chapter: Chapter, val histo
coverLastModified: Long,
// chapter
chapterId: Long?,
_mangaId: Long?,
chapterMangaId: Long?,
chapterUrl: String?,
name: String?,
scanlator: String?,
@ -80,36 +80,38 @@ data class MangaChapterHistory(val manga: Manga, val chapter: Chapter, val histo
)
val chapter = try {
chapterId?.let {
Chapter.mapper(
id = chapterId,
mangaId = _mangaId ?: mangaId,
url = chapterUrl!!,
name = name!!,
scanlator = scanlator,
read = read!!,
bookmark = bookmark!!,
lastPageRead = lastPageRead!!,
pagesLeft = pagesLeft!!,
chapterNumber = chapterNumber!!,
sourceOrder = sourceOrder!!,
dateFetch = dateFetch!!,
dateUpload = dateUpload!!,
)
}
} catch (_: NullPointerException) { null } ?: Chapter.create()
Chapter.mapper(
id = chapterId!!,
mangaId = chapterMangaId!!,
url = chapterUrl!!,
name = name!!,
scanlator = scanlator,
read = read!!,
bookmark = bookmark!!,
lastPageRead = lastPageRead!!,
pagesLeft = pagesLeft!!,
chapterNumber = chapterNumber!!,
sourceOrder = sourceOrder!!,
dateFetch = dateFetch!!,
dateUpload = dateUpload!!,
)
} catch (_: NullPointerException) {
ChapterImpl()
}
val history = try {
historyId?.let {
History.mapper(
id = historyId,
chapterId = historyChapterId ?: chapterId ?: 0L,
lastRead = historyLastRead,
timeRead = historyTimeRead,
)
History.mapper(
id = historyId!!,
chapterId = historyChapterId!!,
lastRead = historyLastRead,
timeRead = historyTimeRead,
)
} catch (_: NullPointerException) {
HistoryImpl().apply {
historyChapterId?.let { chapter_id = it }
historyLastRead?.let { last_read = it }
historyTimeRead?.let { time_read = it }
}
} catch (_: NullPointerException) { null } ?: History.create().apply {
historyLastRead?.let { last_read = it }
}
return MangaChapterHistory(manga, chapter, history)

View file

@ -1,23 +1,53 @@
package eu.kanade.tachiyomi.data.download
import android.app.Application
import android.content.Context
import android.net.Uri
import co.touchlab.kermit.Logger
import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.domain.manga.models.Manga
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.util.storage.DiskUtil
import eu.kanade.tachiyomi.util.system.extension
import eu.kanade.tachiyomi.util.system.launchIO
import eu.kanade.tachiyomi.util.system.launchNonCancellableIO
import eu.kanade.tachiyomi.util.system.nameWithoutExtension
import java.io.File
import java.util.concurrent.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromByteArray
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encodeToByteArray
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.protobuf.ProtoBuf
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
import yokai.domain.manga.interactor.GetManga
import yokai.domain.storage.StorageManager
import java.util.concurrent.*
/**
* Cache where we dump the downloads directory from the filesystem. This class is needed because
@ -37,6 +67,13 @@ class DownloadCache(
private val storageManager: StorageManager = Injekt.get(),
) {
val scope = CoroutineScope(Dispatchers.IO)
private val _changes: Channel<Unit> = Channel(Channel.UNLIMITED)
val changes = _changes.receiveAsFlow()
.onStart { emit(Unit) }
.shareIn(scope, SharingStarted.Lazily, 1)
/**
* The interval after which this cache should be invalidated. 1 hour shouldn't cause major
* issues, as the cache is only used for UI feedback.
@ -47,12 +84,38 @@ class DownloadCache(
* The last time the cache was refreshed.
*/
private var lastRenew = 0L
private var renewalJob: Job? = null
private var mangaFiles: MutableMap<Long, MutableSet<String>> = mutableMapOf()
private val _isInitializing = MutableStateFlow(false)
val isInitializing = _isInitializing
.debounce(1000L) // Don't notify if it finishes quickly enough
.stateIn(scope, SharingStarted.WhileSubscribed(), false)
val scope = CoroutineScope(Job() + Dispatchers.IO)
private val diskCacheFile: File
get() = File(context.cacheDir, "dl_index_cache_v3")
private val rootDownloadsDirLock = Mutex()
private var rootDownloadsDir = RootDirectory(storageManager.getDownloadsDirectory())
init {
// Attempt to read cache file
scope.launch {
rootDownloadsDirLock.withLock {
try {
if (diskCacheFile.exists()) {
val diskCache = diskCacheFile.inputStream().use {
ProtoBuf.decodeFromByteArray<RootDirectory>(it.readBytes())
}
rootDownloadsDir = diskCache
lastRenew = System.currentTimeMillis()
}
} catch (e: Throwable) {
Logger.e(e) { "Failed to initialize disk cache" }
diskCacheFile.delete()
}
}
}
storageManager.changes
.onEach { forceRenewCache() } // invalidate cache
.launchIn(scope)
@ -71,12 +134,18 @@ class DownloadCache(
return provider.findChapterDir(chapter, manga, source) != null
}
checkRenew()
renewCache()
val files = mangaFiles[manga.id]?.toHashSet() ?: return false
return provider.getValidChapterDirNames(chapter).any { chapName ->
files.any { chapName.equals(it, true) || "$chapName.cbz".equals(it, true) }
val sourceDir = rootDownloadsDir.sourceDirs[manga.source]
if (sourceDir != null) {
val mangaDir = sourceDir.mangaDirs[provider.getMangaDirName(manga)]
if (mangaDir != null) {
return provider.getValidChapterDirNames(
chapter,
).any { it in mangaDir.chapterDirs }
}
}
return false
}
/**
@ -85,84 +154,138 @@ class DownloadCache(
* @param manga the manga to check.
*/
fun getDownloadCount(manga: Manga, forceCheckFolder: Boolean = false): Int {
checkRenew()
renewCache()
val sourceDir = rootDownloadsDir.sourceDirs[manga.source]
if (forceCheckFolder) {
val source = sourceManager.get(manga.source) ?: return 0
val mangaDir = provider.findMangaDir(manga, source)
if (mangaDir != null) {
val listFiles =
mangaDir.listFiles { _, filename -> !filename.endsWith(Downloader.TMP_DIR_SUFFIX) }
val listFiles = mangaDir.listFiles { _, filename -> !filename.endsWith(Downloader.TMP_DIR_SUFFIX) }
if (!listFiles.isNullOrEmpty()) {
return listFiles.size
}
}
return 0
} else {
val files = mangaFiles[manga.id] ?: return 0
return files.filter { !it.endsWith(Downloader.TMP_DIR_SUFFIX) }.size
}
}
/**
* Checks if the cache needs a renewal and performs it if needed.
*/
@Synchronized
private fun checkRenew() {
if (lastRenew + renewInterval < System.currentTimeMillis()) {
renew()
lastRenew = System.currentTimeMillis()
if (sourceDir != null) {
val mangaDir = sourceDir.mangaDirs[provider.getMangaDirName(manga)]
if (mangaDir != null) {
return mangaDir.chapterDirs.size
}
}
return 0
}
}
fun forceRenewCache() {
renew()
lastRenew = System.currentTimeMillis()
lastRenew = 0L
renewalJob?.cancel()
diskCacheFile.delete()
renewCache()
}
/**
* Renews the downloads cache.
*/
private fun renew() {
val onlineSources = sourceManager.getOnlineSources()
private fun renewCache() {
if (lastRenew + renewInterval >= System.currentTimeMillis() || renewalJob?.isActive == true) {
return
}
val sourceDirs = storageManager.getDownloadsDirectory()?.listFiles().orEmpty()
.associate { it.name to SourceDirectory(it) }.mapNotNullKeys { entry ->
onlineSources.find { provider.getSourceDirName(it).equals(entry.key, ignoreCase = true) }?.id
renewalJob = scope.launchIO {
if (lastRenew == 0L) {
_isInitializing.emit(true)
}
val getManga: GetManga by injectLazy()
val mangas = runBlocking(Dispatchers.IO) { getManga.awaitAll().groupBy { it.source } }
// FIXME: Wait for SourceManager to be initialized
val sources = getSources()
sourceDirs.forEach { sourceValue ->
val sourceMangaRaw = mangas[sourceValue.key]?.toMutableSet() ?: return@forEach
val sourceMangaPair = sourceMangaRaw.partition { it.favorite }
val sourceMap = sources.associate { provider.getSourceDirName(it).lowercase() to it.id }
val sourceDir = sourceValue.value
rootDownloadsDirLock.withLock {
rootDownloadsDir = RootDirectory(storageManager.getDownloadsDirectory())
val mangaDirs = sourceDir.dir.listFiles().orEmpty().mapNotNull { mangaDir ->
val name = mangaDir.name ?: return@mapNotNull null
val chapterDirs = mangaDir.listFiles().orEmpty().mapNotNull { chapterFile -> chapterFile.name?.substringBeforeLast(".cbz") }.toHashSet()
name to MangaDirectory(mangaDir, chapterDirs)
}.toMap()
val sourceDirs = rootDownloadsDir.dir?.listFiles().orEmpty()
.filter { it.isDirectory && !it.name.isNullOrBlank() }
.mapNotNull { dir ->
val sourceId = sourceMap[dir.name!!.lowercase()]
sourceId?.let { it to SourceDirectory(dir) }
}
.toMap()
val trueMangaDirs = mangaDirs.mapNotNull { mangaDir ->
val manga = findManga(sourceMangaPair.first, mangaDir.key, sourceValue.key) ?: findManga(sourceMangaPair.second, mangaDir.key, sourceValue.key)
val id = manga?.id ?: return@mapNotNull null
id to mangaDir.value.files
}.toMap()
rootDownloadsDir.sourceDirs = sourceDirs
mangaFiles.putAll(trueMangaDirs)
sourceDirs.values
.map { sourceDir ->
async {
sourceDir.mangaDirs = sourceDir.dir?.listFiles().orEmpty()
.filter { it.isDirectory && !it.name.isNullOrBlank() }
.associate { it.name!! to MangaDirectory(it) }
sourceDir.mangaDirs.values.forEach { mangaDir ->
val chapterDirs = mangaDir.dir?.listFiles().orEmpty()
.mapNotNull {
when {
// Ignore incomplete downloads
it.name?.endsWith(Downloader.TMP_DIR_SUFFIX) == true -> null
// Folder of images
it.isDirectory -> it.name
// CBZ files
it.isFile && it.extension == "cbz" -> it.nameWithoutExtension
// Anything else is irrelevant
else -> null
}
}
.toMutableSet()
mangaDir.chapterDirs = chapterDirs
}
}
}
.awaitAll()
_isInitializing.emit(false)
}
}.also {
it.invokeOnCompletion(onCancelling = true) { exception ->
if (exception != null && exception !is CancellationException) {
Logger.e(exception) { "DownloadCache: failed to create cache" }
}
lastRenew = System.currentTimeMillis()
notifyChanges()
}
}
// Mainly to notify the indexing notifier UI
notifyChanges()
}
/**
* Searches a manga list and matches the given mangakey and source key
*/
private fun findManga(mangaList: List<Manga>, mangaKey: String, sourceKey: Long): Manga? {
return mangaList.find {
DiskUtil.buildValidFilename(it.originalTitle).equals(mangaKey, ignoreCase = true) && it.source == sourceKey
private fun getSources(): List<Source> {
return sourceManager.getOnlineSources()
}
private fun notifyChanges() {
scope.launchNonCancellableIO {
_changes.send(Unit)
}
updateDiskCache()
}
private var updateDiskCacheJob: Job? = null
private fun updateDiskCache() {
updateDiskCacheJob?.cancel()
updateDiskCacheJob = scope.launchIO {
delay(1000)
ensureActive()
val bytes = ProtoBuf.encodeToByteArray(rootDownloadsDir)
ensureActive()
try {
diskCacheFile.writeBytes(bytes)
} catch (e: Throwable) {
Logger.e(e) { "Failed to write disk cache file" }
}
}
}
@ -173,15 +296,30 @@ class DownloadCache(
* @param mangaUniFile the directory of the manga.
* @param manga the manga of the chapter.
*/
@Synchronized
fun addChapter(chapterDirName: String, manga: Manga) {
val id = manga.id ?: return
val files = mangaFiles[id]
if (files == null) {
mangaFiles[id] = mutableSetOf(chapterDirName)
} else {
mangaFiles[id]?.add(chapterDirName)
suspend fun addChapter(chapterDirName: String, mangaUniFile: UniFile?, manga: Manga) {
rootDownloadsDirLock.withLock {
// Retrieve the cached source directory or cache a new one
var sourceDir = rootDownloadsDir.sourceDirs[manga.source]
if (sourceDir == null) {
val source = sourceManager.get(manga.source) ?: return
val sourceUniFile = provider.findSourceDir(source) ?: return
sourceDir = SourceDirectory(sourceUniFile)
rootDownloadsDir.sourceDirs += manga.source to sourceDir
}
// Retrieve the cached manga directory or cache a new one
val mangaDirName = provider.getMangaDirName(manga)
var mangaDir = sourceDir.mangaDirs[mangaDirName]
if (mangaDir == null) {
mangaDir = MangaDirectory(mangaUniFile)
sourceDir.mangaDirs += mangaDirName to mangaDir
}
// Save the chapter directory
mangaDir.chapterDirs += chapterDirName
}
notifyChanges()
}
/**
@ -190,26 +328,35 @@ class DownloadCache(
* @param chapters the list of chapter to remove.
* @param manga the manga of the chapter.
*/
@Synchronized
fun removeChapters(chapters: List<Chapter>, manga: Manga) {
val id = manga.id ?: return
for (chapter in chapters) {
val list = provider.getValidChapterDirNames(chapter)
list.forEach { fileName ->
mangaFiles[id]?.firstOrNull { fileName.equals(it, true) }?.let { chapterFile ->
mangaFiles[id]?.remove(chapterFile)
suspend fun removeChapters(chapters: List<Chapter>, manga: Manga) {
rootDownloadsDirLock.withLock {
val sourceDir = rootDownloadsDir.sourceDirs[manga.source] ?: return
val mangaDir = sourceDir.mangaDirs[provider.getMangaDirName(manga)] ?: return
chapters.forEach { chapter ->
provider.getValidChapterDirNames(chapter).forEach {
if (it in mangaDir.chapterDirs) {
mangaDir.chapterDirs -= it
}
}
}
}
notifyChanges()
}
fun removeFolders(folders: List<String>, manga: Manga) {
val id = manga.id ?: return
for (chapter in folders) {
if (mangaFiles[id] != null && chapter in mangaFiles[id]!!) {
mangaFiles[id]?.remove(chapter)
suspend fun removeChapterFolders(folders: List<String>, manga: Manga) {
rootDownloadsDirLock.withLock {
val sourceDir = rootDownloadsDir.sourceDirs[manga.source] ?: return
val mangaDir = sourceDir.mangaDirs[provider.getMangaDirName(manga)] ?: return
folders.forEach { chapter ->
if (chapter in mangaDir.chapterDirs) {
mangaDir.chapterDirs -= chapter
}
}
}
notifyChanges()
}
/*fun renameFolder(from: String, to: String, source: Long) {
@ -230,34 +377,25 @@ class DownloadCache(
*
* @param manga the manga to remove.
*/
@Synchronized
fun removeManga(manga: Manga) {
mangaFiles.remove(manga.id)
suspend fun removeManga(manga: Manga) {
rootDownloadsDirLock.withLock {
val sourceDir = rootDownloadsDir.sourceDirs[manga.source] ?: return
val mangaDirName = provider.getMangaDirName(manga)
if (sourceDir.mangaDirs.containsKey(mangaDirName)) {
sourceDir.mangaDirs -= mangaDirName
}
}
notifyChanges()
}
/**
* Class to store the files under the root downloads directory.
*/
private class RootDirectory(
val dir: UniFile,
var files: Map<Long, SourceDirectory> = hashMapOf(),
)
suspend fun removeSource(source: Source) {
rootDownloadsDirLock.withLock {
rootDownloadsDir.sourceDirs -= source.id
}
/**
* Class to store the files under a source directory.
*/
private class SourceDirectory(
val dir: UniFile,
var files: Map<Long, MutableSet<String>> = hashMapOf(),
)
/**
* Class to store the files under a manga directory.
*/
private class MangaDirectory(
val dir: UniFile,
var files: MutableSet<String> = hashSetOf(),
)
notifyChanges()
}
/**
* Returns a new map containing only the key entries of [transform] that are not null.
@ -282,3 +420,53 @@ class DownloadCache(
return destination
}
}
/**
* Class to store the files under the root downloads directory.
*/
@Serializable
private class RootDirectory(
@Serializable(with = UniFileAsStringSerializer::class)
val dir: UniFile?,
var sourceDirs: Map<Long, SourceDirectory> = hashMapOf(),
)
/**
* Class to store the files under a source directory.
*/
@Serializable
private class SourceDirectory(
@Serializable(with = UniFileAsStringSerializer::class)
val dir: UniFile?,
var mangaDirs: Map<String, MangaDirectory> = hashMapOf(),
)
/**
* Class to store the files under a manga directory.
*/
@Serializable
private class MangaDirectory(
@Serializable(with = UniFileAsStringSerializer::class)
val dir: UniFile?,
var chapterDirs: MutableSet<String> = hashSetOf(),
)
private object UniFileAsStringSerializer : KSerializer<UniFile?> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("UniFile", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: UniFile?) {
return if (value == null) {
encoder.encodeNull()
} else {
encoder.encodeString(value.uri.toString())
}
}
override fun deserialize(decoder: Decoder): UniFile? {
return if (decoder.decodeNotNullMark()) {
UniFile.fromUri(Injekt.get<Application>(), Uri.parse(decoder.decodeString()))
} else {
decoder.decodeNull()
}
}
}

View file

@ -22,8 +22,9 @@ import eu.kanade.tachiyomi.util.system.workManager
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.map
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import yokai.i18n.MR
@ -39,7 +40,7 @@ class DownloadJob(val context: Context, workerParams: WorkerParameters) : Corout
private val preferences: PreferencesHelper = Injekt.get()
override suspend fun getForegroundInfo(): ForegroundInfo {
val firstDL = downloadManager.queue.firstOrNull()
val firstDL = downloadManager.queueState.value.firstOrNull()
val notification = DownloadNotifier(context).setPlaceholder(firstDL).build()
val id = Notifications.ID_DOWNLOAD_CHAPTER
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
@ -70,7 +71,6 @@ class DownloadJob(val context: Context, workerParams: WorkerParameters) : Corout
} catch (_: CancellationException) {
Result.success()
} finally {
callListeners(false, downloadManager)
if (runExtJobAfter) {
ExtensionUpdateJob.runJobAgain(applicationContext, NetworkType.CONNECTED)
}
@ -96,12 +96,6 @@ class DownloadJob(val context: Context, workerParams: WorkerParameters) : Corout
private const val TAG = "Downloader"
private const val START_EXT_JOB_AFTER = "StartExtJobAfter"
private val downloadChannel = MutableSharedFlow<Boolean>(
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
val downloadFlow = downloadChannel.asSharedFlow()
fun start(context: Context, alsoStartExtJob: Boolean = false) {
val request = OneTimeWorkRequestBuilder<DownloadJob>()
.addTag(TAG)
@ -118,16 +112,17 @@ class DownloadJob(val context: Context, workerParams: WorkerParameters) : Corout
context.workManager.cancelUniqueWork(TAG)
}
fun callListeners(downloading: Boolean? = null, downloadManager: DownloadManager? = null) {
val dManager by lazy { downloadManager ?: Injekt.get() }
downloadChannel.tryEmit(downloading ?: !dManager.isPaused())
}
fun isRunning(context: Context): Boolean {
return context.workManager
.getWorkInfosForUniqueWork(TAG)
.get()
.let { list -> list.count { it.state == WorkInfo.State.RUNNING } == 1 }
}
fun isRunningFlow(context: Context): Flow<Boolean> {
return context.workManager
.getWorkInfosForUniqueWorkFlow(TAG)
.map { list -> list.count { it.state == WorkInfo.State.RUNNING } == 1 }
}
}
}

View file

@ -5,17 +5,21 @@ import co.touchlab.kermit.Logger
import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.data.download.model.DownloadQueue
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.domain.manga.models.Manga
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.util.system.ImageUtil
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import eu.kanade.tachiyomi.util.system.launchIO
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onStart
import uy.kohesive.injekt.injectLazy
import yokai.domain.download.DownloadPreferences
import yokai.i18n.MR
@ -64,8 +68,11 @@ class DownloadManager(val context: Context) {
/**
* Downloads queue, where the pending chapters are stored.
*/
val queue: DownloadQueue
get() = downloader.queue
val queueState
get() = downloader.queueState
val isDownloaderRunning
get() = DownloadJob.isRunningFlow(context)
/**
* Tells the downloader to begin downloads.
@ -74,7 +81,6 @@ class DownloadManager(val context: Context) {
*/
fun startDownloads(): Boolean {
val hasStarted = downloader.start()
DownloadJob.callListeners(downloadManager = this)
return hasStarted
}
@ -98,22 +104,21 @@ class DownloadManager(val context: Context) {
*
* @param isNotification value that determines if status is set (needed for view updates)
*/
fun clearQueue(isNotification: Boolean = false) {
deletePendingDownloads(*downloader.queue.toTypedArray())
downloader.clearQueue(isNotification)
DownloadJob.callListeners(false, this)
fun clearQueue() {
deletePendingDownloads(*queueState.value.toTypedArray())
downloader.clearQueue()
downloader.stop()
}
fun startDownloadNow(chapter: Chapter) {
val download = downloader.queue.find { it.chapter.id == chapter.id } ?: return
val queue = downloader.queue.toMutableList()
val download = queueState.value.find { it.chapter.id == chapter.id } ?: return
val queue = queueState.value.toMutableList()
queue.remove(download)
queue.add(0, download)
reorderQueue(queue)
if (isPaused()) {
if (DownloadJob.isRunning(context)) {
downloader.start()
DownloadJob.callListeners(true, this)
} else {
DownloadJob.start(context)
}
@ -126,24 +131,12 @@ class DownloadManager(val context: Context) {
* @param downloads value to set the download queue to
*/
fun reorderQueue(downloads: List<Download>) {
val wasPaused = isPaused()
if (downloads.isEmpty()) {
DownloadJob.stop(context)
downloader.queue.clear()
return
}
downloader.pause()
downloader.queue.clear()
downloader.queue.addAll(downloads)
if (!wasPaused) {
downloader.start()
DownloadJob.callListeners(true, this)
}
downloader.updateQueue(downloads)
}
fun isPaused() = !downloader.isRunning
fun hasQueue() = downloader.queue.isNotEmpty()
fun hasQueue() = queueState.value.isNotEmpty()
/**
* Tells the downloader to enqueue the given list of chapters.
@ -163,10 +156,7 @@ class DownloadManager(val context: Context) {
*/
fun addDownloadsToStartOfQueue(downloads: List<Download>) {
if (downloads.isEmpty()) return
queue.toMutableList().apply {
addAll(0, downloads)
reorderQueue(this)
}
reorderQueue(downloads + queueState.value)
if (!DownloadJob.isRunning(context)) DownloadJob.start(context)
}
@ -211,7 +201,7 @@ class DownloadManager(val context: Context) {
* @param chapter the chapter to check.
*/
fun getChapterDownloadOrNull(chapter: Chapter): Download? {
return downloader.queue
return queueState.value
.firstOrNull { it.chapter.id == chapter.id && it.chapter.manga_id == chapter.manga_id }
}
@ -248,27 +238,15 @@ class DownloadManager(val context: Context) {
* @param manga the manga of the chapters.
* @param source the source of the chapters.
*/
@OptIn(DelicateCoroutinesApi::class)
fun deleteChapters(chapters: List<Chapter>, manga: Manga, source: Source, force: Boolean = false) {
val filteredChapters = if (force) chapters else getChaptersToDelete(chapters, manga)
GlobalScope.launch(Dispatchers.IO) {
val wasPaused = isPaused()
launchIO {
val filteredChapters = if (force) chapters else getChaptersToDelete(chapters, manga)
if (filteredChapters.isEmpty()) {
return@launch
return@launchIO
}
downloader.pause()
downloader.queue.remove(filteredChapters)
if (!wasPaused && downloader.queue.isNotEmpty()) {
downloader.start()
DownloadJob.callListeners(true)
} else if (downloader.queue.isEmpty() && DownloadJob.isRunning(context)) {
DownloadJob.callListeners(false)
DownloadJob.stop(context)
} else if (downloader.queue.isEmpty()) {
DownloadJob.callListeners(false)
downloader.stop()
}
queue.remove(filteredChapters)
removeFromDownloadQueue(filteredChapters)
val chapterDirs =
provider.findChapterDirs(filteredChapters, manga, source) + provider.findTempChapterDirs(
filteredChapters,
@ -277,10 +255,27 @@ class DownloadManager(val context: Context) {
)
chapterDirs.forEach { it.delete() }
cache.removeChapters(filteredChapters, manga)
if (cache.getDownloadCount(manga, true) == 0) { // Delete manga directory if empty
chapterDirs.firstOrNull()?.parentFile?.delete()
}
queue.updateListeners()
}
}
private fun removeFromDownloadQueue(chapters: List<Chapter>) {
val wasRunning = downloader.isRunning
if (wasRunning) {
downloader.pause()
}
downloader.removeFromQueue(chapters)
if (wasRunning) {
if (queueState.value.isEmpty()) {
downloader.stop()
} else if (queueState.value.isNotEmpty()) {
downloader.start()
}
}
}
@ -298,7 +293,7 @@ class DownloadManager(val context: Context) {
* @param manga the manga of the chapters.
* @param source the source of the chapters.
*/
fun cleanupChapters(allChapters: List<Chapter>, manga: Manga, source: Source, removeRead: Boolean, removeNonFavorite: Boolean): Int {
suspend fun cleanupChapters(allChapters: List<Chapter>, manga: Manga, source: Source, removeRead: Boolean, removeNonFavorite: Boolean): Int {
var cleaned = 0
if (removeNonFavorite && !manga.favorite) {
@ -311,7 +306,7 @@ class DownloadManager(val context: Context) {
val filesWithNoChapter = provider.findUnmatchedChapterDirs(allChapters, manga, source)
cleaned += filesWithNoChapter.size
cache.removeFolders(filesWithNoChapter.mapNotNull { it.name }, manga)
cache.removeChapterFolders(filesWithNoChapter.mapNotNull { it.name }, manga)
filesWithNoChapter.forEach { it.delete() }
if (removeRead) {
@ -341,12 +336,21 @@ class DownloadManager(val context: Context) {
* @param manga the manga to delete.
* @param source the source of the manga.
*/
fun deleteManga(manga: Manga, source: Source) {
downloader.clearQueue(manga, true)
queue.remove(manga)
provider.findMangaDir(manga, source)?.delete()
cache.removeManga(manga)
queue.updateListeners()
fun deleteManga(manga: Manga, source: Source, removeQueued: Boolean = true) {
launchIO {
if (removeQueued) {
downloader.removeFromQueue(manga)
}
provider.findMangaDir(manga, source)?.delete()
cache.removeManga(manga)
// Delete source directory if empty
val sourceDir = provider.findSourceDir(source)
if (sourceDir?.listFiles()?.isEmpty() == true) {
sourceDir.delete()
cache.removeSource(source)
}
}
}
/**
@ -377,7 +381,7 @@ class DownloadManager(val context: Context) {
* @param oldChapter the existing chapter with the old name.
* @param newChapter the target chapter with the new name.
*/
fun renameChapter(source: Source, manga: Manga, oldChapter: Chapter, newChapter: Chapter) {
suspend fun renameChapter(source: Source, manga: Manga, oldChapter: Chapter, newChapter: Chapter) {
val oldNames = provider.getValidChapterDirNames(oldChapter).map { listOf(it, "$it.cbz") }.flatten()
var newName = provider.getChapterDirName(newChapter, includeId = downloadPreferences.downloadWithId().get())
val mangaDir = provider.getMangaDir(manga, source)
@ -395,7 +399,7 @@ class DownloadManager(val context: Context) {
if (oldDownload.renameTo(newName)) {
cache.removeChapters(listOf(oldChapter), manga)
cache.addChapter(newName, manga)
cache.addChapter(newName, mangaDir, manga)
} else {
Logger.e { "Could not rename downloaded chapter: ${oldNames.joinToString()}" }
}
@ -406,9 +410,6 @@ class DownloadManager(val context: Context) {
cache.forceRenewCache()
}
fun addListener(listener: DownloadQueue.DownloadListener) = queue.addListener(listener)
fun removeListener(listener: DownloadQueue.DownloadListener) = queue.removeListener(listener)
private fun getChaptersToDelete(chapters: List<Chapter>, manga: Manga): List<Chapter> {
// Retrieve the categories that are set to exclude from being deleted on read
return if (!preferences.removeBookmarkedChapters().get()) {
@ -417,4 +418,33 @@ class DownloadManager(val context: Context) {
chapters
}
}
fun statusFlow(): Flow<Download> = queueState
.flatMapLatest { downloads ->
downloads
.map { download ->
download.statusFlow.drop(1).map { download }
}
.merge()
}
.onStart {
emitAll(
queueState.value.filter { download -> download.status == Download.State.DOWNLOADING }.asFlow(),
)
}
fun progressFlow(): Flow<Download> = queueState
.flatMapLatest { downloads ->
downloads
.map { download ->
download.progressFlow.drop(1).map { download }
}
.merge()
}
.onStart {
emitAll(
queueState.value.filter { download -> download.status == Download.State.DOWNLOADING }
.asFlow(),
)
}
}

View file

@ -155,9 +155,10 @@ internal class DownloadNotifier(private val context: Context) {
}
setStyle(null)
setProgress(download.pages!!.size, download.downloadedImages, false)
// Displays the progress bar on notification
show()
}
// Displays the progress bar on notification
notification.show()
}
/**
@ -212,8 +213,9 @@ internal class DownloadNotifier(private val context: Context) {
clearActions()
setContentIntent(NotificationHandler.openDownloadManagerPendingActivity(context))
setProgress(0, 0, false)
show(Notifications.ID_DOWNLOAD_CHAPTER_ERROR)
}
notification.show(Notifications.ID_DOWNLOAD_CHAPTER_ERROR)
// Reset download information
isDownloading = false
@ -291,8 +293,9 @@ internal class DownloadNotifier(private val context: Context) {
}
color = ContextCompat.getColor(context, R.color.secondaryTachiyomi)
setProgress(0, 0, false)
show(Notifications.ID_DOWNLOAD_CHAPTER_ERROR)
}
notification.show(Notifications.ID_DOWNLOAD_CHAPTER_ERROR)
// Reset download information
errorThrown = true

View file

@ -59,6 +59,12 @@ class DownloadStore(
}
}
fun removeAll(downloads: List<Download>) {
preferences.edit {
downloads.forEach { remove(getKey(it)) }
}
}
/**
* Removes all the downloads from the store.
*/

View file

@ -1,15 +1,12 @@
package eu.kanade.tachiyomi.data.download
import android.content.Context
import android.os.Handler
import android.os.Looper
import co.touchlab.kermit.Logger
import com.hippo.unifile.UniFile
import com.jakewharton.rxrelay.PublishRelay
import eu.kanade.tachiyomi.data.cache.ChapterCache
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.data.download.model.DownloadQueue
import eu.kanade.tachiyomi.data.library.LibraryUpdateJob
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.domain.manga.models.Manga
@ -28,49 +25,49 @@ import eu.kanade.tachiyomi.util.system.launchNow
import eu.kanade.tachiyomi.util.system.withIOContext
import eu.kanade.tachiyomi.util.system.withUIContext
import eu.kanade.tachiyomi.util.system.writeText
import java.io.File
import java.util.*
import java.util.zip.*
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapMerge
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.retryWhen
import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.supervisorScope
import nl.adaptivity.xmlutil.serialization.XML
import okhttp3.Response
import rx.Observable
import rx.Subscription
import rx.android.schedulers.AndroidSchedulers
import rx.schedulers.Schedulers
import uy.kohesive.injekt.injectLazy
import yokai.core.archive.ZipWriter
import yokai.core.metadata.COMIC_INFO_FILE
import yokai.core.metadata.ComicInfo
import yokai.core.metadata.getComicInfo
import yokai.domain.category.interactor.GetCategories
import yokai.domain.download.DownloadPreferences
import yokai.i18n.MR
import yokai.util.lang.getString
import java.io.File
import java.util.*
import java.util.zip.*
import yokai.domain.category.interactor.GetCategories
/**
* This class is the one in charge of downloading chapters.
*
* Its [queue] contains the list of chapters to download. In order to download them, the downloader
* subscriptions must be running and the list of chapters must be sent to them by [downloadsRelay].
*
* The queue manipulation must be done in one thread (currently the main thread) to avoid unexpected
* behavior, but it's safe to read it from multiple threads.
*
* @param context the application context.
* @param provider the downloads directory provider.
* @param cache the downloads cache, used to add the downloads to the cache after their completion.
* @param sourceManager the source manager.
* Its queue contains the list of chapters to download.
*/
class Downloader(
private val context: Context,
@ -92,30 +89,22 @@ class Downloader(
/**
* Queue where active downloads are kept.
*/
val queue = DownloadQueue(store)
private val handler = Handler(Looper.getMainLooper())
private val _queueState = MutableStateFlow<List<Download>>(emptyList())
val queueState = _queueState.asStateFlow()
/**
* Notifier for the downloader state and progress.
*/
private val notifier by lazy { DownloadNotifier(context) }
/**
* Downloader subscription.
*/
private var subscription: Subscription? = null
/**
* Relay to send a list of downloads to the downloader.
*/
private val downloadsRelay = PublishRelay.create<List<Download>>()
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var downloaderJob: Job? = null
/**
* Whether the downloader is running.
*/
val isRunning: Boolean
get() = subscription != null
get() = downloaderJob?.isActive ?: false
/**
* Whether the downloader is paused
@ -126,8 +115,7 @@ class Downloader(
init {
launchNow {
val chapters = async { store.restore() }
queue.addAll(chapters.await())
DownloadJob.callListeners()
addAllToQueue(chapters.await())
}
}
@ -138,17 +126,17 @@ class Downloader(
* @return true if the downloader is started, false otherwise.
*/
fun start(): Boolean {
if (subscription != null || queue.isEmpty()) {
if (isRunning || queueState.value.isEmpty()) {
return false
}
initializeSubscription()
val pending = queue.filter { it.status != Download.State.DOWNLOADED }
val pending = queueState.value.filter { it.status != Download.State.DOWNLOADED }
pending.forEach { if (it.status != Download.State.QUEUE) it.status = Download.State.QUEUE }
isPaused = false
downloadsRelay.call(pending)
launchDownloaderJob()
return pending.isNotEmpty()
}
@ -156,8 +144,8 @@ class Downloader(
* Stops the downloader.
*/
fun stop(reason: String? = null) {
destroySubscription()
queue
cancelDownloaderJob()
queueState.value
.filter { it.status == Download.State.DOWNLOADING }
.forEach { it.status = Download.State.ERROR }
@ -166,104 +154,109 @@ class Downloader(
return
}
DownloadJob.stop(context)
if (isPaused && queue.isNotEmpty()) {
handler.postDelayed({ notifier.onDownloadPaused() }, 150)
if (isPaused && queueState.value.isNotEmpty()) {
notifier.onDownloadPaused()
} else {
notifier.dismiss()
}
DownloadJob.callListeners(false)
isPaused = false
DownloadJob.stop(context)
}
/**
* Pauses the downloader
*/
fun pause() {
destroySubscription()
queue
cancelDownloaderJob()
queueState.value
.filter { it.status == Download.State.DOWNLOADING }
.forEach { it.status = Download.State.QUEUE }
isPaused = true
}
/**
* Removes everything from the queue.
*
* @param isNotification value that determines if status is set (needed for view updates)
*/
fun clearQueue(isNotification: Boolean = false) {
destroySubscription()
fun clearQueue() {
cancelDownloaderJob()
// Needed to update the chapter view
if (isNotification) {
queue
.filter { it.status == Download.State.QUEUE }
.forEach { it.status = Download.State.NOT_DOWNLOADED }
}
queue.clear()
internalClearQueue()
notifier.dismiss()
}
/**
* Removes everything from the queue for a certain manga
*
* @param isNotification value that determines if status is set (needed for view updates)
*/
fun clearQueue(manga: Manga, isNotification: Boolean = false) {
// Needed to update the chapter view
if (isNotification) {
queue.filter { it.status == Download.State.QUEUE && it.manga.id == manga.id }
.forEach { it.status = Download.State.NOT_DOWNLOADED }
}
queue.remove(manga)
if (queue.isEmpty()) {
if (DownloadJob.isRunning(context)) DownloadJob.stop(context)
stop()
}
notifier.dismiss()
}
/**
* Prepares the subscriptions to start downloading.
*/
private fun initializeSubscription() {
private fun launchDownloaderJob() {
if (isRunning) return
subscription = downloadsRelay.concatMapIterable { it }
// Concurrently download from 5 different sources
.groupBy { it.source }
.flatMap(
{ bySource ->
bySource.concatMap { download ->
Observable.fromCallable {
runBlocking { downloadChapter(download) }
download
}.subscribeOn(Schedulers.io())
downloaderJob = scope.launch {
val activeDownloadsFlow = queueState.transformLatest { queue ->
while (true) {
val activeDownloads = queue.asSequence()
// Ignore completed downloads, leave them in the queue
.filter {
val statusValue = it.status.value
Download.State.NOT_DOWNLOADED.value <= statusValue && statusValue <= Download.State.DOWNLOADING.value
}
.groupBy { it.source }
.toList()
// Concurrently download from 5 different sources
.take(5)
.map { (_, downloads) -> downloads.first() }
emit(activeDownloads)
if (activeDownloads.isEmpty()) break
// Suspend until a download enters the ERROR state
val activeDownloadsErroredFlow =
combine(activeDownloads.map(Download::statusFlow)) { states ->
states.contains(Download.State.ERROR)
}.filter { it }
activeDownloadsErroredFlow.first()
}
}.distinctUntilChanged()
// Use supervisorScope to cancel child jobs when the downloader job is cancelled
supervisorScope {
val downloadJobs = mutableMapOf<Download, Job>()
activeDownloadsFlow.collectLatest { activeDownloads ->
val downloadJobsToStop = downloadJobs.filter { it.key !in activeDownloads }
downloadJobsToStop.forEach { (download, job) ->
job.cancel()
downloadJobs.remove(download)
}
},
5,
)
.onBackpressureLatest()
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{
completeDownload(it)
},
{ error ->
Logger.e(error)
notifier.onError(error.message)
stop()
},
)
val downloadsToStart = activeDownloads.filter { it !in downloadJobs }
downloadsToStart.forEach { download ->
downloadJobs[download] = launchDownloadJob(download)
}
}
}
}
}
private fun CoroutineScope.launchDownloadJob(download: Download) = launchIO {
try {
downloadChapter(download)
// Remove successful download from queue
if (download.status == Download.State.DOWNLOADED) {
removeFromQueue(download)
}
if (areAllDownloadsFinished()) {
stop()
}
} catch (e: Throwable) {
if (e is CancellationException) throw e
Logger.e(e)
notifier.onError(e.message)
stop()
}
}
/**
* Destroys the downloader subscriptions.
*/
private fun destroySubscription() {
subscription?.unsubscribe()
subscription = null
private fun cancelDownloaderJob() {
downloaderJob?.cancel()
downloaderJob = null
}
/**
@ -279,7 +272,7 @@ class Downloader(
}
val source = sourceManager.get(manga.source) as? HttpSource ?: return@launchIO
val wasEmpty = queue.isEmpty()
val wasEmpty = queueState.value.isEmpty()
// Called in background thread, the operation can be slow with SAF.
val chaptersWithoutDir = async {
chapters
@ -292,22 +285,17 @@ class Downloader(
// Runs in main thread (synchronization needed).
val chaptersToQueue = chaptersWithoutDir.await()
// Filter out those already enqueued.
.filter { chapter -> queue.none { it.chapter.id == chapter.id } }
.filter { chapter -> queueState.value.none { it.chapter.id == chapter.id } }
// Create a download for each one.
.map { Download(source, manga, it) }
if (chaptersToQueue.isNotEmpty()) {
queue.addAll(chaptersToQueue)
if (isRunning) {
// Send the list of downloads to the downloader.
downloadsRelay.call(chaptersToQueue)
}
addAllToQueue(chaptersToQueue)
// Start downloader if needed
if (autoStart && wasEmpty) {
val queuedDownloads = queue.count { it.source !is UnmeteredSource }
val maxDownloadsFromSource = queue
val queuedDownloads = queueState.value.count { it.source !is UnmeteredSource }
val maxDownloadsFromSource = queueState.value
.groupBy { it.source }
.filterKeys { it !is UnmeteredSource }
.maxOfOrNull { it.value.size } ?: 0
@ -398,7 +386,30 @@ class Downloader(
}
// Do after download completes
ensureSuccessfulDownload(download, mangaDir, tmpDir, chapterDirname)
if (!isDownloadSuccessful(download, tmpDir)) {
download.status = Download.State.ERROR
return
}
createComicInfoFile(
tmpDir,
download.manga,
download.chapter,
download.source,
)
// Only rename the directory if it's downloaded
if (preferences.saveChaptersAsCBZ().get()) {
archiveChapter(mangaDir, chapterDirname, tmpDir)
} else {
tmpDir.renameTo(chapterDirname)
}
cache.addChapter(chapterDirname, mangaDir, download.manga)
DiskUtil.createNoMediaFile(tmpDir, context)
download.status = Download.State.DOWNLOADED
} catch (error: Throwable) {
if (error is CancellationException) throw error
// If the page list threw, it will resume here
@ -408,6 +419,31 @@ class Downloader(
}
}
private fun isDownloadSuccessful(
download: Download,
tmpDir: UniFile,
): Boolean {
// Page list hasn't been initialized
val downloadPageCount = download.pages?.size ?: return false
// Ensure that all pages has been downloaded
if (download.downloadedImages != downloadPageCount) return false
// Ensure that the chapter folder has all the pages
val downloadedImagesCount = tmpDir.listFiles().orEmpty().count {
val fileName = it.name.orEmpty()
when {
fileName in listOf(COMIC_INFO_FILE, NOMEDIA_FILE) -> false
fileName.endsWith(".tmp") -> false
// Only count the first split page and not the others
fileName.contains("__") && !fileName.endsWith("__001.jpg") -> false
else -> true
}
}
return downloadedImagesCount == downloadPageCount
}
/**
* Returns the observable which gets the image from the filesystem if it exists or downloads it
* otherwise.
@ -558,60 +594,6 @@ class Downloader(
}
}
/**
* Checks if the download was successful.
*
* @param download the download to check.
* @param mangaDir the manga directory of the download.
* @param tmpDir the directory where the download is currently stored.
* @param dirname the real (non temporary) directory name of the download.
*/
private fun ensureSuccessfulDownload(
download: Download,
mangaDir: UniFile,
tmpDir: UniFile,
dirname: String,
) {
// Page list hasn't been initialized
val downloadPageCount = download.pages?.size ?: return
// Ensure that all pages has been downloaded
if (download.downloadedImages < downloadPageCount) return
// Ensure that the chapter folder has all the pages
val downloadedImagesCount = tmpDir.listFiles().orEmpty().count {
val fileName = it.name.orEmpty()
when {
fileName in listOf(COMIC_INFO_FILE, NOMEDIA_FILE) -> false
fileName.endsWith(".tmp") -> false
// Only count the first split page and not the others
fileName.contains("__") && !fileName.endsWith("__001.jpg") -> false
else -> true
}
}
download.status = if (downloadedImagesCount == downloadPageCount) {
createComicInfoFile(
tmpDir,
download.manga,
download.chapter,
download.source,
)
// Only rename the directory if it's downloaded
if (preferences.saveChaptersAsCBZ().get()) {
archiveChapter(mangaDir, dirname, tmpDir)
} else {
tmpDir.renameTo(dirname)
}
cache.addChapter(dirname, download.manga)
DiskUtil.createNoMediaFile(tmpDir, context)
Download.State.DOWNLOADED
} else {
Download.State.ERROR
}
}
/**
* Archive the chapter pages as a CBZ.
*/
@ -620,7 +602,7 @@ class Downloader(
dirname: String,
tmpDir: UniFile,
) {
val zip = mangaDir.createFile("$dirname.cbz$TMP_DIR_SUFFIX") ?: return
val zip = mangaDir.createFile("$dirname.cbz$TMP_DIR_SUFFIX")!!
ZipWriter(context, zip).use { writer ->
tmpDir.listFiles()?.forEach { file ->
writer.write(file)
@ -670,25 +652,86 @@ class Downloader(
dir.createFile(COMIC_INFO_FILE)?.writeText(xml.encodeToString(ComicInfo.serializer(), comicInfo))
}
/**
* Completes a download. This method is called in the main thread.
*/
private fun completeDownload(download: Download) {
// Delete successful downloads from queue
if (download.status == Download.State.DOWNLOADED) {
// Remove downloaded chapter from queue
queue.remove(download)
}
if (areAllDownloadsFinished()) {
stop()
}
}
/**
* Returns true if all the queued downloads are in DOWNLOADED or ERROR state.
*/
private fun areAllDownloadsFinished(): Boolean {
return queue.none { it.status <= Download.State.DOWNLOADING }
return queueState.value.none { it.status <= Download.State.DOWNLOADING }
}
private fun addAllToQueue(downloads: List<Download>) {
_queueState.update {
downloads.forEach { download ->
download.status = Download.State.QUEUE
}
store.addAll(downloads)
it + downloads
}
}
fun removeFromQueue(download: Download) {
_queueState.update {
store.remove(download)
if (download.status == Download.State.DOWNLOADING || download.status == Download.State.QUEUE) {
download.status = Download.State.NOT_DOWNLOADED
}
it - download
}
}
private inline fun removeFromQueueIf(predicate: (Download) -> Boolean) {
_queueState.update { queue ->
val downloads = queue.filter { predicate(it) }
store.removeAll(downloads)
downloads.forEach { download ->
if (download.status == Download.State.DOWNLOADING || download.status == Download.State.QUEUE) {
download.status = Download.State.NOT_DOWNLOADED
}
}
queue - downloads
}
}
fun removeFromQueue(chapter: Chapter) {
removeFromQueueIf { it.chapter.id == chapter.id }
}
fun removeFromQueue(chapters: List<Chapter>) {
removeFromQueueIf { it.chapter.id in chapters.map { it.id } }
}
fun removeFromQueue(manga: Manga) {
removeFromQueueIf { it.manga.id == manga.id }
}
private fun internalClearQueue() {
_queueState.update {
it.forEach { download ->
if (download.status == Download.State.DOWNLOADING || download.status == Download.State.QUEUE) {
download.status = Download.State.NOT_DOWNLOADED
}
}
store.clear()
emptyList()
}
}
fun updateQueue(downloads: List<Download>) {
val wasRunning = isRunning
if (downloads.isEmpty()) {
clearQueue()
DownloadJob.stop(context)
return
}
pause()
internalClearQueue()
addAllToQueue(downloads)
if (wasRunning) {
start()
}
}
companion object {

View file

@ -4,8 +4,15 @@ import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.domain.manga.models.Manga
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.online.HttpSource
import rx.subjects.PublishSubject
import kotlin.math.roundToInt
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
class Download(val source: HttpSource, val manga: Manga, val chapter: Chapter) {
@ -17,17 +24,31 @@ class Download(val source: HttpSource, val manga: Manga, val chapter: Chapter) {
val downloadedImages: Int
get() = pages?.count { it.status == Page.State.READY } ?: 0
@Volatile @Transient
var status: State = State.default
@Transient
private val _statusFlow = MutableStateFlow(State.NOT_DOWNLOADED)
@Transient
val statusFlow = _statusFlow.asStateFlow()
var status: State
get() = _statusFlow.value
set(status) {
field = status
statusSubject?.onNext(this)
statusCallback?.invoke(this)
_statusFlow.value = status
}
@Transient private var statusSubject: PublishSubject<Download>? = null
@Transient
val progressFlow = flow {
if (pages == null) {
emit(0)
while (pages == null) {
delay(50)
}
}
@Transient private var statusCallback: ((Download) -> Unit)? = null
val progressFlows = pages!!.map(Page::progressFlow)
emitAll(combine(progressFlows) { it.average().roundToInt() })
}
.distinctUntilChanged()
.debounce(50)
val pageProgress: Int
get() {
@ -41,21 +62,13 @@ class Download(val source: HttpSource, val manga: Manga, val chapter: Chapter) {
return pages.map(Page::progress).average().roundToInt()
}
fun setStatusSubject(subject: PublishSubject<Download>?) {
statusSubject = subject
}
fun setStatusCallback(f: ((Download) -> Unit)?) {
statusCallback = f
}
enum class State {
CHECKED,
NOT_DOWNLOADED,
QUEUE,
DOWNLOADING,
DOWNLOADED,
ERROR,
enum class State(val value: Int) {
CHECKED(-1),
NOT_DOWNLOADED(0),
QUEUE(1),
DOWNLOADING(2),
DOWNLOADED(3),
ERROR(4),
;
companion object {

View file

@ -1,128 +1,84 @@
package eu.kanade.tachiyomi.data.download.model
import com.jakewharton.rxrelay.PublishRelay
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.download.DownloadStore
import eu.kanade.tachiyomi.domain.manga.models.Manga
import kotlinx.coroutines.MainScope
import androidx.annotation.CallSuper
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.util.system.launchUI
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import rx.subjects.PublishSubject
import java.util.concurrent.*
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
class DownloadQueue(
private val store: DownloadStore,
private val queue: MutableList<Download> = CopyOnWriteArrayList<Download>(),
) :
List<Download> by queue {
sealed class DownloadQueue {
interface Listener {
val progressJobs: MutableMap<Download, Job>
private val statusSubject = PublishSubject.create<Download>()
// Override with presenterScope or viewScope
val queueListenerScope: CoroutineScope
private val updatedRelay = PublishRelay.create<Unit>()
private val downloadListeners = mutableListOf<DownloadListener>()
private var scope = MainScope()
fun addAll(downloads: List<Download>) {
downloads.forEach { download ->
download.setStatusSubject(statusSubject)
download.setStatusCallback(::setPagesFor)
download.status = Download.State.QUEUE
fun onPageProgressUpdate(download: Download) {
onProgressUpdate(download)
}
queue.addAll(downloads)
store.addAll(downloads)
updatedRelay.call(Unit)
}
fun onProgressUpdate(download: Download)
fun onQueueUpdate(download: Download)
fun remove(download: Download) {
val removed = queue.remove(download)
store.remove(download)
download.setStatusSubject(null)
download.setStatusCallback(null)
if (download.status == Download.State.DOWNLOADING || download.status == Download.State.QUEUE) {
download.status = Download.State.NOT_DOWNLOADED
}
downloadListeners.forEach { it.updateDownload(download) }
if (removed) {
updatedRelay.call(Unit)
}
}
// Subscribe on presenter/controller creation on UI thread
@CallSuper
fun onStatusChange(download: Download) {
when (download.status) {
Download.State.DOWNLOADING -> {
launchProgressJob(download)
// Initial update of the downloaded pages
onQueueUpdate(download)
}
Download.State.DOWNLOADED -> {
cancelProgressJob(download)
fun updateListeners() {
val listeners = downloadListeners.toList()
listeners.forEach { it.updateDownloads() }
}
fun remove(chapter: Chapter) {
find { it.chapter.id == chapter.id }?.let { remove(it) }
}
fun remove(chapters: List<Chapter>) {
for (chapter in chapters) { remove(chapter) }
}
fun remove(manga: Manga) {
filter { it.manga.id == manga.id }.forEach { remove(it) }
}
fun clear() {
queue.forEach { download ->
download.setStatusSubject(null)
download.setStatusCallback(null)
if (download.status == Download.State.DOWNLOADING || download.status == Download.State.QUEUE) {
download.status = Download.State.NOT_DOWNLOADED
onProgressUpdate(download)
onQueueUpdate(download)
}
Download.State.ERROR -> cancelProgressJob(download)
else -> {
/* unused */
}
}
downloadListeners.forEach { it.updateDownload(download) }
}
queue.clear()
store.clear()
updatedRelay.call(Unit)
}
private fun setPagesFor(download: Download) {
if (download.status == Download.State.DOWNLOADING) {
if (download.pages != null) {
for (page in download.pages!!)
scope.launch {
page.statusFlow.collectLatest {
callListeners(download)
}
/**
* Observe the progress of a download and notify the view.
*
* @param download the download to observe its progress.
*/
private fun launchProgressJob(download: Download) {
val job = queueListenerScope.launchUI {
while (download.pages == null) {
delay(50)
}
val progressFlows = download.pages!!.map(Page::progressFlow)
combine(progressFlows, Array<Int>::sum)
.distinctUntilChanged()
.debounce(50)
.collectLatest {
onPageProgressUpdate(download)
}
}
callListeners(download)
} else if (download.status == Download.State.DOWNLOADED || download.status == Download.State.ERROR) {
// setPagesSubject(download.pages, null)
if (download.status == Download.State.ERROR) {
callListeners(download)
}
} else {
callListeners(download)
// Avoid leaking jobs
progressJobs.remove(download)?.cancel()
progressJobs[download] = job
}
/**
* Unsubscribes the given download from the progress subscriptions.
*
* @param download the download to unsubscribe.
*/
private fun cancelProgressJob(download: Download) {
progressJobs.remove(download)?.cancel()
}
}
private fun callListeners(download: Download) {
downloadListeners.forEach { it.updateDownload(download) }
}
// private fun setPagesSubject(pages: List<Page>?, subject: PublishSubject<Int>?) {
// if (pages != null) {
// for (page in pages) {
// page.setStatusSubject(subject)
// }
// }
// }
fun addListener(listener: DownloadListener) {
downloadListeners.add(listener)
}
fun removeListener(listener: DownloadListener) {
downloadListeners.remove(listener)
}
interface DownloadListener {
fun updateDownload(download: Download)
fun updateDownloads()
}
}

View file

@ -64,7 +64,7 @@ class NotificationReceiver : BroadcastReceiver() {
downloadManager.pauseDownloads()
}
// Clear the download queue
ACTION_CLEAR_DOWNLOADS -> downloadManager.clearQueue(true)
ACTION_CLEAR_DOWNLOADS -> downloadManager.clearQueue()
// Delete image from path and dismiss notification
ACTION_DELETE_IMAGE -> deleteImage(
context,
@ -610,6 +610,11 @@ class NotificationReceiver : BroadcastReceiver() {
)
}
internal fun dismissFailThenStartAppUpdatePendingJob(context: Context, url: String, notifyOnInstall: Boolean = false): PendingIntent {
dismissNotification(context, Notifications.ID_UPDATER_FAILED)
return startAppUpdatePendingJob(context, url, notifyOnInstall)
}
/**
* Returns [PendingIntent] that cancels the download for a Tachiyomi update
*

View file

@ -14,6 +14,7 @@ import eu.kanade.tachiyomi.data.notification.NotificationHandler
import eu.kanade.tachiyomi.data.notification.NotificationReceiver
import eu.kanade.tachiyomi.data.notification.Notifications
import eu.kanade.tachiyomi.util.system.getResourceColor
import eu.kanade.tachiyomi.util.system.notificationBuilder
import eu.kanade.tachiyomi.util.system.notificationManager
import yokai.i18n.MR
import yokai.util.lang.getString
@ -30,7 +31,7 @@ internal class AppUpdateNotifier(private val context: Context) {
* Builder to manage notifications.
*/
val notificationBuilder by lazy {
NotificationCompat.Builder(context, Notifications.CHANNEL_COMMON).apply {
context.notificationBuilder(Notifications.CHANNEL_COMMON).apply {
setSmallIcon(AR.drawable.stat_sys_download)
setContentTitle(context.getString(MR.strings.app_name))
}
@ -232,7 +233,7 @@ internal class AppUpdateNotifier(private val context: Context) {
addAction(
R.drawable.ic_refresh_24dp,
context.getString(MR.strings.retry),
NotificationReceiver.startAppUpdatePendingJob(context, url),
NotificationReceiver.dismissFailThenStartAppUpdatePendingJob(context, url),
)
// Cancel action
addAction(

View file

@ -183,6 +183,9 @@ class ExtensionInstallerJob(val context: Context, workerParams: WorkerParameters
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
.build()
}
if (requests.isEmpty()) return
var workContinuation = WorkManager.getInstance(context)
.beginUniqueWork(TAG, ExistingWorkPolicy.REPLACE, requests.first())
for (i in 1 until requests.size) {

View file

@ -69,11 +69,18 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour
.filter { !it.isDirectory }
.firstOrNull { it.name == COMIC_INFO_FILE }
return if (localDetails != null) {
decodeComicInfo(localDetails.openInputStream()).language?.value ?: "other"
val lang = if (localDetails != null) {
try {
decodeComicInfo(localDetails.openInputStream()).language?.value
} catch (e: Exception) {
Logger.e(e) { "Unable to retrieve manga language" }
null
}
} else {
"other"
null
}
return lang ?: "other"
}
}
@ -279,8 +286,9 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour
if (!directory.exists()) return
lang?.let { langMap[manga.url] = it }
val file = directory.createFile(COMIC_INFO_FILE)!!
file.writeText(xml.encodeToString(ComicInfo.serializer(), manga.toComicInfo(lang = lang)))
directory.createFile(COMIC_INFO_FILE)?.let { file ->
file.writeText(xml.encodeToString(ComicInfo.serializer(), manga.toComicInfo(lang = lang)))
}
}
@Serializable

View file

@ -1,10 +1,11 @@
package eu.kanade.tachiyomi.ui.base.presenter
import androidx.annotation.CallSuper
import java.lang.ref.WeakReference
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import java.lang.ref.WeakReference
open class BaseCoroutinePresenter<T> {
var presenterScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@ -24,6 +25,7 @@ open class BaseCoroutinePresenter<T> {
open fun onCreate() {
}
@CallSuper
open fun onDestroy() {
presenterScope.cancel()
weakView = null

View file

@ -100,7 +100,9 @@ class CategoryPresenter(
scope.launch {
deleteCategories.awaitOne(safeCategory.toLong())
categories.remove(category)
controller.setCategories(categories.map(::CategoryItem))
withContext(Dispatchers.Main) {
controller.setCategories(categories.map(::CategoryItem))
}
}
}

View file

@ -4,7 +4,9 @@ import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.data.download.model.DownloadQueue
import eu.kanade.tachiyomi.ui.base.presenter.BaseCoroutinePresenter
import eu.kanade.tachiyomi.util.system.launchUI
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import uy.kohesive.injekt.injectLazy
@ -12,7 +14,8 @@ import uy.kohesive.injekt.injectLazy
/**
* Presenter of [DownloadBottomSheet].
*/
class DownloadBottomPresenter : BaseCoroutinePresenter<DownloadBottomSheet>() {
class DownloadBottomPresenter : BaseCoroutinePresenter<DownloadBottomSheet>(),
DownloadQueue.Listener {
/**
* Download manager.
@ -20,15 +23,27 @@ class DownloadBottomPresenter : BaseCoroutinePresenter<DownloadBottomSheet>() {
val downloadManager: DownloadManager by injectLazy()
var items = listOf<DownloadHeaderItem>()
override val progressJobs = mutableMapOf<Download, Job>()
override val queueListenerScope get() = presenterScope
/**
* Property to get the queue from the download manager.
*/
val downloadQueue: DownloadQueue
get() = downloadManager.queue
val downloadQueueState
get() = downloadManager.queueState
override fun onCreate() {
presenterScope.launchUI {
downloadManager.statusFlow().collect(::onStatusChange)
}
presenterScope.launchUI {
downloadManager.progressFlow().collect(::onPageProgressUpdate)
}
}
fun getItems() {
presenterScope.launch {
val items = downloadQueue
val items = downloadQueueState.value
.groupBy { it.source }
.map { entry ->
DownloadHeaderItem(entry.key.id, entry.key.name, entry.value.size).apply {
@ -85,4 +100,22 @@ class DownloadBottomPresenter : BaseCoroutinePresenter<DownloadBottomSheet>() {
fun cancelDownloads(downloads: List<Download>) {
downloadManager.deletePendingDownloads(*downloads.toTypedArray())
}
override fun onStatusChange(download: Download) {
super.onStatusChange(download)
view?.update(downloadManager.isRunning)
}
override fun onQueueUpdate(download: Download) {
view?.onUpdateDownloadedPages(download)
}
override fun onProgressUpdate(download: Download) {
view?.onUpdateProgress(download)
}
override fun onPageProgressUpdate(download: Download) {
super.onPageProgressUpdate(download)
view?.onUpdateDownloadedPages(download)
}
}

View file

@ -117,14 +117,14 @@ class DownloadBottomSheet @JvmOverloads constructor(
fun update(isRunning: Boolean) {
presenter.getItems()
onQueueStatusChange(isRunning)
if (binding.downloadFab.isInvisible != presenter.downloadQueue.isEmpty()) {
binding.downloadFab.isInvisible = presenter.downloadQueue.isEmpty()
if (binding.downloadFab.isInvisible != presenter.downloadQueueState.value.isEmpty()) {
binding.downloadFab.isInvisible = presenter.downloadQueueState.value.isEmpty()
}
prepareMenu()
}
private fun updateDLTitle() {
val extCount = presenter.downloadQueue.firstOrNull()
val extCount = presenter.downloadQueueState.value.firstOrNull()
binding.titleText.text = if (extCount != null) {
context.getString(
MR.strings.downloading_,
@ -143,8 +143,8 @@ class DownloadBottomSheet @JvmOverloads constructor(
private fun onQueueStatusChange(running: Boolean) {
val oldRunning = isRunning
isRunning = running
if (binding.downloadFab.isInvisible != presenter.downloadQueue.isEmpty()) {
binding.downloadFab.isInvisible = presenter.downloadQueue.isEmpty()
if (binding.downloadFab.isInvisible != presenter.downloadQueueState.value.isEmpty()) {
binding.downloadFab.isInvisible = presenter.downloadQueueState.value.isEmpty()
}
updateFab()
if (oldRunning != running) {
@ -210,7 +210,7 @@ class DownloadBottomSheet @JvmOverloads constructor(
private fun setInformationView() {
updateDLTitle()
setBottomSheet()
if (presenter.downloadQueue.isEmpty()) {
if (presenter.downloadQueueState.value.isEmpty()) {
binding.emptyView.show(
R.drawable.ic_download_off_24dp,
MR.strings.nothing_is_downloading,
@ -224,10 +224,10 @@ class DownloadBottomSheet @JvmOverloads constructor(
val menu = binding.sheetToolbar.menu
updateFab()
// Set clear button visibility.
menu.findItem(R.id.clear_queue)?.isVisible = !presenter.downloadQueue.isEmpty()
menu.findItem(R.id.clear_queue)?.isVisible = presenter.downloadQueueState.value.isNotEmpty()
// Set reorder button visibility.
menu.findItem(R.id.reorder)?.isVisible = !presenter.downloadQueue.isEmpty()
menu.findItem(R.id.reorder)?.isVisible = presenter.downloadQueueState.value.isNotEmpty()
}
private fun updateFab() {
@ -274,7 +274,7 @@ class DownloadBottomSheet @JvmOverloads constructor(
}
private fun setBottomSheet() {
val hasQueue = presenter.downloadQueue.isNotEmpty()
val hasQueue = presenter.downloadQueueState.value.isNotEmpty()
if (hasQueue) {
sheetBehavior?.skipCollapsed = !hasQueue
if (sheetBehavior.isHidden()) sheetBehavior?.collapse()
@ -320,7 +320,6 @@ class DownloadBottomSheet @JvmOverloads constructor(
}
}
presenter.reorder(downloads)
controller?.updateChapterDownload(download, false)
}
/**

View file

@ -68,7 +68,7 @@ class DownloadHolder(private val view: View, val adapter: DownloadAdapter) :
if (binding.downloadProgress.max == 1) {
binding.downloadProgress.max = pages.size * 100
}
binding.downloadProgress.progress = download.pageProgress
binding.downloadProgress.setProgressCompat(download.pageProgress, true)
}
/**

View file

@ -2,8 +2,6 @@ package eu.kanade.tachiyomi.ui.extension
import android.content.pm.PackageInstaller
import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.data.download.model.DownloadQueue
import eu.kanade.tachiyomi.extension.ExtensionInstallerJob
import eu.kanade.tachiyomi.extension.ExtensionManager
import eu.kanade.tachiyomi.extension.model.Extension
@ -12,7 +10,6 @@ import eu.kanade.tachiyomi.extension.model.InstalledExtensionsOrder
import eu.kanade.tachiyomi.extension.util.ExtensionLoader
import eu.kanade.tachiyomi.ui.migration.BaseMigrationPresenter
import eu.kanade.tachiyomi.util.system.LocaleHelper
import eu.kanade.tachiyomi.util.system.launchUI
import eu.kanade.tachiyomi.util.system.withUIContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
@ -31,7 +28,7 @@ typealias ExtensionIntallInfo = Pair<InstallStep, PackageInstaller.SessionInfo?>
/**
* Presenter of [ExtensionBottomSheet].
*/
class ExtensionBottomPresenter : BaseMigrationPresenter<ExtensionBottomSheet>(), DownloadQueue.DownloadListener {
class ExtensionBottomPresenter : BaseMigrationPresenter<ExtensionBottomSheet>() {
private var extensions = emptyList<ExtensionItem>()
@ -43,7 +40,7 @@ class ExtensionBottomPresenter : BaseMigrationPresenter<ExtensionBottomSheet>(),
override fun onCreate() {
super.onCreate()
downloadManager.addListener(this)
presenterScope.launch {
val extensionJob = async {
extensionManager.findAvailableExtensions()
@ -289,11 +286,4 @@ class ExtensionBottomPresenter : BaseMigrationPresenter<ExtensionBottomSheet>(),
extensionManager.trust(pkgName, versionCode, signatureHash)
}
}
override fun updateDownload(download: Download) = updateDownloads()
override fun updateDownloads() {
presenterScope.launchUI {
view?.updateDownloadStatus(!downloadManager.isPaused())
}
}
}

View file

@ -521,8 +521,4 @@ class ExtensionBottomSheet @JvmOverloads constructor(context: Context, attrs: At
return if (index == -1) POSITION_NONE else index
}
}
fun updateDownloadStatus(isRunning: Boolean) {
(controller.activity as? MainActivity)?.downloadStatusChanged(isRunning)
}
}

View file

@ -61,7 +61,6 @@ import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.core.preference.Preference
import eu.kanade.tachiyomi.data.database.models.Category
import eu.kanade.tachiyomi.data.database.models.LibraryManga
import eu.kanade.tachiyomi.data.download.DownloadJob
import eu.kanade.tachiyomi.data.library.LibraryUpdateJob
import eu.kanade.tachiyomi.data.notification.NotificationReceiver
import eu.kanade.tachiyomi.data.notification.Notifications
@ -101,6 +100,7 @@ import eu.kanade.tachiyomi.util.system.getResourceColor
import eu.kanade.tachiyomi.util.system.getResourceDrawable
import eu.kanade.tachiyomi.util.system.ignoredSystemInsets
import eu.kanade.tachiyomi.util.system.isImeVisible
import eu.kanade.tachiyomi.util.system.launchIO
import eu.kanade.tachiyomi.util.system.launchUI
import eu.kanade.tachiyomi.util.system.materialAlertDialog
import eu.kanade.tachiyomi.util.system.openInBrowser
@ -128,14 +128,13 @@ import eu.kanade.tachiyomi.util.view.snack
import eu.kanade.tachiyomi.util.view.text
import eu.kanade.tachiyomi.util.view.withFadeTransaction
import eu.kanade.tachiyomi.widget.EmptyView
import java.util.*
import java.util.Locale
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.roundToInt
import kotlin.random.Random
import kotlin.random.nextInt
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
@ -616,10 +615,12 @@ open class LibraryController(
setPreferenceFlows()
LibraryUpdateJob.updateFlow.onEach(::onUpdateManga).launchIn(viewScope)
viewScope.launchUI {
LibraryUpdateJob.isRunningFlow(view.context).collectLatest {
val holder = if (mAdapter != null) visibleHeaderHolder() else null
val category = holder?.category ?: return@collectLatest
holder.notifyStatus(LibraryUpdateJob.categoryInQueue(category.id), category)
LibraryUpdateJob.isRunningFlow(view.context).collect {
adapter.getHeaderPositions().forEach {
val holder = (binding.libraryGridRecycler.recycler.findViewHolderForAdapterPosition(it) as? LibraryHeaderHolder) ?: return@forEach
val category = holder.category ?: return@forEach
holder.notifyStatus(LibraryUpdateJob.categoryInQueue(category.id), category)
}
}
}
@ -1058,7 +1059,6 @@ open class LibraryController(
presenter.updateLibrary()
isPoppingIn = true
}
DownloadJob.callListeners()
binding.recyclerCover.isClickable = false
binding.recyclerCover.isFocusable = false
singleCategory = presenter.categories.size <= 1
@ -2191,13 +2191,11 @@ open class LibraryController(
*/
private fun showChangeMangaCategoriesSheet() {
val activity = activity ?: return
selectedMangas.toList().moveCategories(activity) {
presenter.updateLibrary()
destroyActionModeIfNeeded()
viewScope.launchIO {
selectedMangas.toList().moveCategories(activity) {
presenter.updateLibrary()
destroyActionModeIfNeeded()
}
}
}
fun updateDownloadStatus(isRunning: Boolean) {
(activity as? MainActivity)?.downloadStatusChanged(isRunning)
}
}

View file

@ -260,24 +260,24 @@ class LibraryHeaderHolder(val view: View, val adapter: LibraryCategoryAdapter) :
}
private fun showCatSortOptions() {
if (category == null) return
val cat = category ?: return
adapter.controller?.activity?.let { activity ->
val items = LibrarySort.entries.map { it.menuSheetItem(category!!.isDynamic) }
val sortingMode = category!!.sortingMode(true)
val items = LibrarySort.entries.map { it.menuSheetItem(cat.isDynamic) }
val sortingMode = cat.sortingMode(true)
val sheet = MaterialMenuSheet(
activity,
items,
activity.getString(MR.strings.sort_by),
sortingMode?.mainValue,
) { sheet, item ->
onCatSortClicked(category!!, item)
onCatSortClicked(cat, item)
val nCategory = (adapter.getItem(flexibleAdapterPosition) as? LibraryHeaderItem)?.category
val isAscending = nCategory?.isAscending() ?: false
val drawableRes = getSortRes(item, isAscending)
sheet.setDrawable(item, drawableRes)
false
}
val isAscending = category!!.isAscending()
val isAscending = cat.isAscending()
val drawableRes = getSortRes(sortingMode, isAscending)
sheet.setDrawable(sortingMode?.mainValue ?: -1, drawableRes)
sheet.show()

View file

@ -80,8 +80,8 @@ abstract class LibraryHolder(
override fun onLongClick(view: View?): Boolean {
return if (adapter.isLongPressDragEnabled) {
val manga = (adapter.getItem(flexibleAdapterPosition) as LibraryItem).manga
if (!isDraggable && !manga.isBlank() && !manga.isHidden()) {
val manga = (adapter.getItem(flexibleAdapterPosition) as? LibraryItem)?.manga
if (manga != null && !isDraggable && !manga.isBlank() && !manga.isHidden()) {
adapter.mItemLongClickListener.onItemLongClick(flexibleAdapterPosition)
toggleActivation()
true

View file

@ -11,8 +11,6 @@ import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.database.models.removeCover
import eu.kanade.tachiyomi.data.database.models.seriesType
import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.data.download.model.DownloadQueue
import eu.kanade.tachiyomi.data.preference.DelayedLibrarySuggestionsJob
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.track.TrackManager
@ -44,7 +42,6 @@ import eu.kanade.tachiyomi.util.manga.MangaCoverMetadata
import eu.kanade.tachiyomi.util.mapStatus
import eu.kanade.tachiyomi.util.system.launchIO
import eu.kanade.tachiyomi.util.system.launchNonCancellableIO
import eu.kanade.tachiyomi.util.system.launchUI
import eu.kanade.tachiyomi.util.system.withIOContext
import eu.kanade.tachiyomi.util.system.withUIContext
import java.util.*
@ -58,6 +55,8 @@ import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.retry
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
@ -91,7 +90,7 @@ class LibraryPresenter(
private val downloadManager: DownloadManager = Injekt.get(),
private val chapterFilter: ChapterFilter = Injekt.get(),
private val trackManager: TrackManager = Injekt.get(),
) : BaseCoroutinePresenter<LibraryController>(), DownloadQueue.DownloadListener {
) : BaseCoroutinePresenter<LibraryController>() {
private val getCategories: GetCategories by injectLazy()
private val setMangaCategories: SetMangaCategories by injectLazy()
private val updateCategories: UpdateCategories by injectLazy()
@ -184,7 +183,7 @@ class LibraryPresenter(
override fun onCreate() {
super.onCreate()
downloadManager.addListener(this)
if (!controllerIsSubClass) {
lastLibraryItems?.let { libraryItems = it }
lastCategories?.let { categories = it }
@ -644,7 +643,7 @@ class LibraryPresenter(
category.mangaSort != null -> {
var sort = when (category.sortingMode() ?: LibrarySort.Title) {
LibrarySort.Title -> sortAlphabetical(i1, i2)
LibrarySort.LatestChapter -> i2.manga.last_update.compareTo(i1.manga.last_update)
LibrarySort.LatestChapter -> i2.manga.latestUpdate.compareTo(i1.manga.latestUpdate)
LibrarySort.Unread -> when {
i1.manga.unread == i2.manga.unread -> 0
i1.manga.unread == 0 -> if (category.isAscending()) 1 else -1
@ -801,7 +800,8 @@ class LibraryPresenter(
private fun getLibraryFlow(): Flow<LibraryData> {
val libraryItemFlow = combine(
getCategories.subscribe(),
getLibraryManga.subscribe(),
// FIXME: Remove retry once a real solution is found
getLibraryManga.subscribe().retry(1) { e -> e is NullPointerException },
getPreferencesFlow(),
forceUpdateEvent.receiveAsFlow(),
) { allCategories, libraryMangaList, prefs, _ ->
@ -817,6 +817,7 @@ class LibraryPresenter(
prefs.sortAscending,
prefs.showAllCategories,
prefs.collapsedCategories,
defaultCategory,
)
} else {
getDynamicLibraryItems(
@ -852,6 +853,7 @@ class LibraryPresenter(
isAscending: Boolean,
showAll: Boolean,
collapsedCategories: Set<String>,
defaultCategory: Category,
): Triple<List<LibraryItem>, List<Category>, List<LibraryItem>> {
val categories = allCategories.toMutableList()
val hiddenItems = mutableListOf<LibraryItem>()
@ -901,7 +903,7 @@ class LibraryPresenter(
collapsedCategories.mapNotNull { it.toIntOrNull() }.toSet()
}
if (categorySet.contains(0)) categories.add(0, createDefaultCategory())
if (categorySet.contains(0)) categories.add(0, defaultCategory)
if (libraryIsGrouped) {
categories.forEach { category ->
val catId = category.id ?: return@forEach
@ -1636,13 +1638,6 @@ class LibraryPresenter(
}
}
override fun updateDownload(download: Download) = updateDownloads()
override fun updateDownloads() {
presenterScope.launchUI {
view?.updateDownloadStatus(!downloadManager.isPaused())
}
}
data class ItemPreferences(
val filterDownloaded: Int,
val filterUnread: Int,

View file

@ -62,12 +62,12 @@ import com.bluelinelabs.conductor.ControllerChangeHandler
import com.bluelinelabs.conductor.Router
import com.getkeepsafe.taptargetview.TapTarget
import com.getkeepsafe.taptargetview.TapTargetView
import com.google.android.material.badge.BadgeDrawable
import com.google.android.material.navigation.NavigationBarView
import com.google.android.material.snackbar.Snackbar
import com.google.android.material.transition.platform.MaterialContainerTransformSharedElementCallback
import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.download.DownloadJob
import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.data.library.LibraryUpdateJob
import eu.kanade.tachiyomi.data.notification.NotificationReceiver
@ -138,6 +138,7 @@ import kotlin.math.min
import kotlin.math.roundToLong
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.withContext
@ -458,8 +459,12 @@ open class MainActivity : BaseActivity<MainActivityBinding>() {
}
}
DownloadJob.downloadFlow.onEach(::downloadStatusChanged).launchIn(lifecycleScope)
lifecycleScope
combine(
downloadManager.isDownloaderRunning,
downloadManager.queueState,
) { isDownloading, queueState -> isDownloading to queueState.size }
.onEach { downloadStatusChanged(it.first, it.second) }
.launchIn(lifecycleScope)
WindowCompat.setDecorFitsSystemWindows(window, false)
setSupportActionBar(binding.toolbar)
supportActionBar?.setDisplayShowCustomEnabled(true)
@ -947,7 +952,6 @@ open class MainActivity : BaseActivity<MainActivityBinding>() {
extensionManager.getExtensionUpdates(false)
}
setExtensionsBadge()
DownloadJob.callListeners(downloadManager = downloadManager)
showDLQueueTutorial()
reEnableBackPressedCallBack()
}
@ -1504,13 +1508,17 @@ open class MainActivity : BaseActivity<MainActivityBinding>() {
}
}
fun downloadStatusChanged(downloading: Boolean) {
private fun BadgeDrawable.updateQueueSize(queueSize: Int) {
number = queueSize
}
private fun downloadStatusChanged(downloading: Boolean, queueSize: Int) {
lifecycleScope.launchUI {
val hasQueue = downloading || downloadManager.hasQueue()
val hasQueue = downloading || queueSize > 0
if (hasQueue) {
val badge = nav.getOrCreateBadge(R.id.nav_recents)
badge.number = downloadManager.queue.size
if (downloading) badge.backgroundColor = -870219 else badge.backgroundColor = Color.GRAY
badge.updateQueueSize(queueSize)
badge.backgroundColor = if (downloading) getResourceColor(R.attr.colorError) else Color.GRAY
showDLQueueTutorial()
} else {
nav.removeBadge(R.id.nav_recents)

View file

@ -111,12 +111,14 @@ import eu.kanade.tachiyomi.util.system.isLandscape
import eu.kanade.tachiyomi.util.system.isOnline
import eu.kanade.tachiyomi.util.system.isPromptChecked
import eu.kanade.tachiyomi.util.system.isTablet
import eu.kanade.tachiyomi.util.system.launchIO
import eu.kanade.tachiyomi.util.system.launchUI
import eu.kanade.tachiyomi.util.system.materialAlertDialog
import eu.kanade.tachiyomi.util.system.rootWindowInsetsCompat
import eu.kanade.tachiyomi.util.system.setCustomTitleAndMessage
import eu.kanade.tachiyomi.util.system.timeSpanFromNow
import eu.kanade.tachiyomi.util.system.toast
import eu.kanade.tachiyomi.util.system.withUIContext
import eu.kanade.tachiyomi.util.view.activityBinding
import eu.kanade.tachiyomi.util.view.copyToClipboard
import eu.kanade.tachiyomi.util.view.findChild
@ -790,7 +792,6 @@ class MangaDetailsController :
binding.swipeRefresh.isRefreshing = enabled
}
//region Recycler methods
fun updateChapterDownload(download: Download) {
getHolder(download.chapter)?.notifyStatus(
download.status,
@ -820,27 +821,24 @@ class MangaDetailsController :
updateMenuVisibility(activityBinding?.toolbar?.menu)
}
fun updateChapters(chapters: List<ChapterItem>) {
fun updateChapters() {
view ?: return
binding.swipeRefresh.isRefreshing = presenter.isLoading
if (presenter.chapters.isEmpty() && fromCatalogue && !presenter.hasRequested) {
launchUI { binding.swipeRefresh.isRefreshing = true }
presenter.fetchChaptersFromSource()
}
tabletAdapter?.notifyItemChanged(0)
adapter?.setChapters(chapters)
adapter?.setChapters(presenter.chapters)
addMangaHeader()
colorToolbar(binding.recycler.canScrollVertically(-1))
updateMenuVisibility(activityBinding?.toolbar?.menu)
}
private fun addMangaHeader() {
if (tabletAdapter?.scrollableHeaders?.isEmpty() == true) {
val tabletHeader = presenter.tabletChapterHeaderItem
if (tabletHeader != null && tabletAdapter?.scrollableHeaders?.isEmpty() == true) {
tabletAdapter?.removeAllScrollableHeaders()
tabletAdapter?.addScrollableHeader(presenter.headerItem)
adapter?.removeAllScrollableHeaders()
adapter?.addScrollableHeader(presenter.tabletChapterHeaderItem!!)
} else if (!isTablet && adapter?.scrollableHeaders?.isEmpty() == true) {
adapter?.addScrollableHeader(tabletHeader)
} else if (adapter?.scrollableHeaders?.isEmpty() == true) {
adapter?.removeAllScrollableHeaders()
adapter?.addScrollableHeader(presenter.headerItem)
}
@ -1633,10 +1631,12 @@ class MangaDetailsController :
private fun showCategoriesSheet() {
val adding = !presenter.manga.favorite
presenter.manga.moveCategories(activity!!, adding) {
updateHeader()
if (adding) {
showAddedSnack()
viewScope.launchIO {
presenter.manga.moveCategories(activity!!, adding) {
updateHeader()
if (adding) {
showAddedSnack()
}
}
}
}
@ -1644,32 +1644,37 @@ class MangaDetailsController :
private fun toggleMangaFavorite() {
val view = view ?: return
val activity = activity ?: return
snack?.dismiss()
snack = presenter.manga.addOrRemoveToFavorites(
presenter.preferences,
view,
activity,
presenter.sourceManager,
this,
onMangaAdded = { migrationInfo ->
migrationInfo?.let {
viewScope.launchIO {
withUIContext { snack?.dismiss() }
snack = presenter.manga.addOrRemoveToFavorites(
presenter.preferences,
view,
activity,
presenter.sourceManager,
this@MangaDetailsController,
onMangaAdded = { migrationInfo ->
migrationInfo?.let {
presenter.fetchChapters(andTracking = true)
}
updateHeader()
showAddedSnack()
},
onMangaMoved = {
updateHeader()
presenter.fetchChapters(andTracking = true)
},
onMangaDeleted = {
updateHeader()
presenter.confirmDeletion()
},
scope = viewScope,
)
if (snack?.duration == Snackbar.LENGTH_INDEFINITE) {
withUIContext {
val favButton = getHeader()?.binding?.favoriteButton
(activity as? MainActivity)?.setUndoSnackBar(snack, favButton)
}
updateHeader()
showAddedSnack()
},
onMangaMoved = {
updateHeader()
presenter.fetchChapters(andTracking = true)
},
onMangaDeleted = {
updateHeader()
presenter.confirmDeletion()
},
)
if (snack?.duration == Snackbar.LENGTH_INDEFINITE) {
val favButton = getHeader()?.binding?.favoriteButton
(activity as? MainActivity)?.setUndoSnackBar(snack, favButton)
}
}
}
@ -1793,7 +1798,7 @@ class MangaDetailsController :
override fun onDestroyActionMode(mode: ActionMode?) {
actionMode = null
setStatusBarAndToolbar()
if (startingRangeChapterPos != null && rangeMode == RangeMode.Download) {
if (startingRangeChapterPos != null && rangeMode in setOf(RangeMode.Download, RangeMode.RemoveDownload)) {
val item = adapter?.getItem(startingRangeChapterPos!!) as? ChapterItem
(binding.recycler.findViewHolderForAdapterPosition(startingRangeChapterPos!!) as? ChapterHolder)?.notifyStatus(
item?.status ?: Download.State.NOT_DOWNLOADED,

View file

@ -34,6 +34,7 @@ import eu.kanade.tachiyomi.data.track.EnhancedTrackService
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.data.track.TrackService
import eu.kanade.tachiyomi.domain.manga.models.Manga
import eu.kanade.tachiyomi.network.HttpException
import eu.kanade.tachiyomi.network.NetworkPreferences
import eu.kanade.tachiyomi.source.LocalSource
import eu.kanade.tachiyomi.source.Source
@ -60,6 +61,7 @@ import eu.kanade.tachiyomi.util.manga.MangaUtil
import eu.kanade.tachiyomi.util.shouldDownloadNewChapters
import eu.kanade.tachiyomi.util.storage.DiskUtil
import eu.kanade.tachiyomi.util.system.ImageUtil
import eu.kanade.tachiyomi.util.system.e
import eu.kanade.tachiyomi.util.system.launchIO
import eu.kanade.tachiyomi.util.system.launchNonCancellableIO
import eu.kanade.tachiyomi.util.system.launchNow
@ -72,8 +74,11 @@ import java.io.FileOutputStream
import java.io.OutputStream
import java.util.Locale
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
@ -108,7 +113,8 @@ class MangaDetailsPresenter(
private val downloadManager: DownloadManager = Injekt.get(),
private val chapterFilter: ChapterFilter = Injekt.get(),
private val storageManager: StorageManager = Injekt.get(),
) : BaseCoroutinePresenter<MangaDetailsController>(), DownloadQueue.DownloadListener {
) : BaseCoroutinePresenter<MangaDetailsController>(),
DownloadQueue.Listener {
private val getAvailableScanlators: GetAvailableScanlators by injectLazy()
private val getCategories: GetCategories by injectLazy()
private val getChapter: GetChapter by injectLazy()
@ -174,6 +180,9 @@ class MangaDetailsPresenter(
var allChapterScanlators: Set<String> = emptySet()
override val progressJobs: MutableMap<Download, Job> = mutableMapOf()
override val queueListenerScope get() = presenterScope
override fun onCreate() {
val controller = view ?: return
@ -181,10 +190,24 @@ class MangaDetailsPresenter(
if (!::manga.isInitialized) runBlocking { refreshMangaFromDb() }
syncData()
downloadManager.addListener(this)
presenterScope.launchUI {
downloadManager.statusFlow()
.filter { it.manga.id == mangaId }
.catch { error -> Logger.e(error) }
.collect(::onStatusChange)
}
presenterScope.launchUI {
downloadManager.progressFlow()
.filter { it.manga.id == mangaId }
.catch { error -> Logger.e(error) }
.collect(::onQueueUpdate)
}
presenterScope.launchIO {
downloadManager.queueState.collectLatest(::onQueueUpdate)
}
runBlocking {
tracks = getTrack.awaitAllByMangaId(manga.id!!)
tracks = getTrack.awaitAllByMangaId(mangaId)
}
}
@ -199,36 +222,35 @@ class MangaDetailsPresenter(
.onEach { onUpdateManga() }
.launchIn(presenterScope)
if (manga.isLocal()) {
refreshAll()
} else if (!manga.initialized) {
isLoading = true
controller.setRefresh(true)
controller.updateHeader()
refreshAll()
} else {
runBlocking { getChapters() }
controller.updateChapters(this.chapters)
getHistory()
}
val fetchMangaNeeded = !manga.initialized || manga.isLocal()
val fetchChaptersNeeded = runBlocking { getChaptersNow() }.isEmpty() || manga.isLocal()
presenterScope.launch {
isLoading = true
withUIContext {
controller.updateHeader()
}
val tasks = listOf(
async { if (fetchMangaNeeded) fetchMangaFromSource() },
async { if (fetchChaptersNeeded) fetchChaptersFromSource(false) },
)
tasks.awaitAll()
isLoading = false
withUIContext {
controller.updateChapters()
}
setTrackItems()
}
refreshTracking(false)
}
override fun onDestroy() {
super.onDestroy()
downloadManager.removeListener(this)
}
fun fetchChapters(andTracking: Boolean = true) {
presenterScope.launch {
getChapters()
if (andTracking) fetchTracks()
withContext(Dispatchers.Main) { view?.updateChapters(chapters) }
withUIContext { view?.updateChapters() }
getHistory()
}
}
@ -252,12 +274,12 @@ class MangaDetailsPresenter(
return chapters
}
private suspend fun getChapters() {
private suspend fun getChapters(queue: List<Download> = downloadManager.queueState.value) {
val chapters = getChapter.awaitAll(mangaId, isScanlatorFiltered()).map { it.toModel() }
allChapters = if (!isScanlatorFiltered()) chapters else getChapter.awaitAll(mangaId, false).map { it.toModel() }
// Find downloaded chapters
setDownloadedChapters(chapters)
setDownloadedChapters(chapters, queue)
allChapterScanlators = allChapters.mapNotNull { it.chapter.scanlator }.toSet()
this.chapters = applyChapterFilters(chapters)
@ -274,33 +296,17 @@ class MangaDetailsPresenter(
*
* @param chapters the list of chapter from the database.
*/
private fun setDownloadedChapters(chapters: List<ChapterItem>) {
private fun setDownloadedChapters(chapters: List<ChapterItem>, queue: List<Download>) {
for (chapter in chapters) {
if (downloadManager.isChapterDownloaded(chapter, manga)) {
chapter.status = Download.State.DOWNLOADED
} else if (downloadManager.hasQueue()) {
chapter.status = downloadManager.queue.find { it.chapter.id == chapter.id }
} else if (queue.isNotEmpty()) {
chapter.status = queue.find { it.chapter.id == chapter.id }
?.status ?: Download.State.default
}
}
}
override fun updateDownload(download: Download) {
chapters.find { it.id == download.chapter.id }?.download = download
presenterScope.launchUI {
view?.updateChapterDownload(download)
}
}
override fun updateDownloads() {
presenterScope.launch(Dispatchers.Default) {
getChapters()
withContext(Dispatchers.Main) {
view?.updateChapters(chapters)
}
}
}
/**
* Converts a chapter from the database to an extended model, allowing to store new fields.
*/
@ -310,7 +316,7 @@ class MangaDetailsPresenter(
model.isLocked = isLockedFromSearch
// Find an active download for this chapter.
val download = downloadManager.queue.find { it.chapter.id == id }
val download = downloadManager.queueState.value.find { it.chapter.id == id }
if (download != null) {
// If there's an active download, assign it.
@ -387,14 +393,15 @@ class MangaDetailsPresenter(
* @param chapter the chapter to delete.
*/
fun deleteChapter(chapter: ChapterItem) {
downloadManager.deleteChapters(listOf(chapter), manga, source, true)
this.chapters.find { it.id == chapter.id }?.apply {
if (chapter.chapter.bookmark && !preferences.removeBookmarkedChapters().get()) return@apply
status = Download.State.QUEUE
status = Download.State.NOT_DOWNLOADED
download = null
}
view?.updateChapters(this.chapters)
view?.updateChapters()
downloadManager.deleteChapters(listOf(chapter), manga, source, true)
}
/**
@ -402,22 +409,21 @@ class MangaDetailsPresenter(
* @param chapters the list of chapters to delete.
*/
fun deleteChapters(chapters: List<ChapterItem>, update: Boolean = true, isEverything: Boolean = false) {
launchIO {
if (isEverything) {
downloadManager.deleteManga(manga, source)
} else {
downloadManager.deleteChapters(chapters, manga, source)
}
}
chapters.forEach { chapter ->
this.chapters.find { it.id == chapter.id }?.apply {
if (chapter.chapter.bookmark && !preferences.removeBookmarkedChapters().get() && !isEverything) return@apply
status = Download.State.QUEUE
status = Download.State.NOT_DOWNLOADED
download = null
}
}
if (update) view?.updateChapters(this.chapters)
if (update) view?.updateChapters()
if (isEverything) {
downloadManager.deleteManga(manga, source)
} else {
downloadManager.deleteChapters(chapters, manga, source)
}
}
suspend fun refreshMangaFromDb(): Manga {
@ -426,39 +432,17 @@ class MangaDetailsPresenter(
return dbManga
}
/** Refresh Manga Info and Chapter List (not tracking) */
fun refreshAll() {
if (view?.isNotOnline() == true && !manga.isLocal()) return
presenterScope.launch {
isLoading = true
var mangaError: java.lang.Exception? = null
var chapterError: java.lang.Exception? = null
val chapters = async(Dispatchers.IO) {
try {
source.getChapterList(manga.copy())
} catch (e: Exception) {
chapterError = e
emptyList()
}
}
val nManga = async(Dispatchers.IO) {
try {
source.getMangaDetails(manga.copy())
} catch (e: java.lang.Exception) {
mangaError = e
null
}
}
val networkManga = nManga.await()
if (networkManga != null) {
private suspend fun fetchMangaFromSource() {
try {
withIOContext {
val networkManga = source.getMangaDetails(manga.copy())
manga.prepareCoverUpdate(coverCache, networkManga, false)
manga.copyFrom(networkManga)
manga.initialized = true
updateManga.await(manga.toMangaUpdate())
launchIO {
presenterScope.launchNonCancellableIO {
val request =
ImageRequest.Builder(preferences.context).data(manga.cover())
.memoryCachePolicy(CachePolicy.DISABLED)
@ -466,90 +450,68 @@ class MangaDetailsPresenter(
.build()
if (preferences.context.imageLoader.execute(request) is SuccessResult) {
withContext(Dispatchers.Main) {
withUIContext {
view?.setPaletteColor()
}
}
}
}
val finChapters = chapters.await()
if (finChapters.isNotEmpty()) {
val newChapters = withIOContext { syncChaptersWithSource(finChapters, manga, source) }
if (newChapters.first.isNotEmpty()) {
if (manga.shouldDownloadNewChapters(preferences)) {
} catch (e: Exception) {
if (e is HttpException && e.code == 103) return
withUIContext {
view?.showError(trimException(e))
}
}
}
private suspend fun fetchChaptersFromSource(manualFetch: Boolean = true) {
try {
withIOContext {
val chapters = source.getChapterList(manga.copy())
val (added, removed) = withIOContext { syncChaptersWithSource(chapters, manga, source) }
if (added.isNotEmpty()) {
if (manga.shouldDownloadNewChapters(preferences) && manualFetch) {
downloadChapters(
newChapters.first.sortedBy { it.chapter_number }
added.sortedBy { it.chapter_number }
.map { it.toModel() },
)
}
view?.view?.context?.let { mangaShortcutManager.updateShortcuts(it) }
}
if (newChapters.second.isNotEmpty()) {
val removedChaptersId = newChapters.second.map { it.id }
if (removed.isNotEmpty()) {
val removedChaptersId = removed.map { it.id }
val removedChapters = this@MangaDetailsPresenter.chapters.filter {
it.id in removedChaptersId && it.isDownloaded
}
if (removedChapters.isNotEmpty()) {
withContext(Dispatchers.Main) {
view?.showChaptersRemovedPopup(
removedChapters,
)
withUIContext {
view?.showChaptersRemovedPopup(removedChapters)
}
}
}
getChapters()
getHistory()
}
isLoading = false
if (chapterError == null) {
withContext(Dispatchers.Main) {
view?.updateChapters(this@MangaDetailsPresenter.chapters)
}
} catch (e: Exception) {
withUIContext {
view?.showError(trimException(e))
}
if (chapterError != null) {
withContext(Dispatchers.Main) {
view?.showError(
trimException(chapterError!!),
)
}
return@launch
} else if (mangaError != null) {
withContext(Dispatchers.Main) {
view?.showError(
trimException(mangaError!!),
)
}
}
getHistory()
}
}
/**
* Requests an updated list of chapters from the source.
*/
fun fetchChaptersFromSource() {
hasRequested = true
isLoading = true
presenterScope.launch(Dispatchers.IO) {
val chapters = try {
source.getChapterList(manga.copy())
} catch (e: Exception) {
withContext(Dispatchers.Main) { view?.showError(trimException(e)) }
return@launch
}
/** Refresh Manga Info and Chapter List (not tracking) */
fun refreshAll() {
if (view?.isNotOnline() == true && !manga.isLocal()) return
presenterScope.launch {
isLoading = true
val tasks = listOf(
async { fetchMangaFromSource() },
async { fetchChaptersFromSource() },
)
tasks.awaitAll()
isLoading = false
try {
syncChaptersWithSource(chapters, manga, source)
getChapters()
withContext(Dispatchers.Main) {
view?.updateChapters(this@MangaDetailsPresenter.chapters)
}
getHistory()
} catch (e: java.lang.Exception) {
withContext(Dispatchers.Main) {
view?.showError(trimException(e))
}
withUIContext {
view?.updateChapters()
}
}
}
@ -579,7 +541,7 @@ class MangaDetailsPresenter(
}
updateChapter.awaitAll(updates)
getChapters()
withContext(Dispatchers.Main) { view?.updateChapters(chapters) }
withUIContext { view?.updateChapters() }
}
}
@ -609,7 +571,7 @@ class MangaDetailsPresenter(
deleteChapters(selectedChapters, false)
}
getChapters()
withContext(Dispatchers.Main) { view?.updateChapters(chapters) }
withUIContext { view?.updateChapters() }
if (read && deleteNow) {
val latestReadChapter = selectedChapters.maxByOrNull { it.chapter_number.toInt() }?.chapter
updateTrackChapterMarkedAsRead(preferences, latestReadChapter, manga.id) {
@ -741,7 +703,7 @@ class MangaDetailsPresenter(
private suspend fun asyncUpdateMangaAndChapters(justChapters: Boolean = false) {
if (!justChapters) updateManga.await(MangaUpdate(manga.id!!, chapterFlags = manga.chapter_flags))
getChapters()
withUIContext { view?.updateChapters(chapters) }
withUIContext { view?.updateChapters() }
}
private fun isScanlatorFiltered() = manga.filtered_scanlators?.isNotEmpty() == true
@ -1150,6 +1112,32 @@ class MangaDetailsPresenter(
return if (date <= 0L) null else date
}
override fun onStatusChange(download: Download) {
super.onStatusChange(download)
chapters.find { it.id == download.chapter.id }?.status = download.status
onPageProgressUpdate(download)
}
private suspend fun onQueueUpdate(queue: List<Download>) = withIOContext {
getChapters(queue)
withUIContext {
view?.updateChapters()
}
}
override fun onQueueUpdate(download: Download) {
// already handled by onStatusChange
}
override fun onProgressUpdate(download: Download) {
// already handled by onStatusChange
}
override fun onPageProgressUpdate(download: Download) {
chapters.find { it.id == download.chapter.id }?.download = download
view?.updateChapterDownload(download)
}
companion object {
const val MULTIPLE_VOLUMES = 1
const val TENS_OF_CHAPTERS = 2

View file

@ -356,8 +356,8 @@ class ReaderActivity : BaseActivity<ReaderActivityBinding>() {
if (viewModel.needsInit()) {
fromUrl = handleIntentAction(intent)
if (!fromUrl) {
val manga = intent.extras!!.getLong("manga", -1)
val chapter = intent.extras!!.getLong("chapter", -1)
val manga = intent.extras?.getLong("manga", -1L) ?: -1L
val chapter = intent.extras?.getLong("chapter", -1L) ?: -1L
if (manga == -1L || chapter == -1L) {
finish()
return

View file

@ -68,10 +68,7 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import rx.Completable
import rx.schedulers.Schedulers
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
@ -158,22 +155,7 @@ class ReaderViewModel(
private var finished = false
private var chapterToDownload: Download? = null
/**
* Chapter list for the active manga. It's retrieved lazily and should be accessed for the first
* time in a background thread to avoid blocking the UI.
*/
private val chapterList by lazy {
val manga = manga!!
val dbChapters = runBlocking { getChapter.awaitAll(manga) }
val selectedChapter = dbChapters.find { it.id == chapterId }
?: error("Requested chapter of id $chapterId not found in chapter list")
val chaptersForReader =
chapterFilter.filterChaptersForReader(dbChapters, manga, selectedChapter)
val chapterSort = ChapterSort(manga, chapterFilter, preferences)
chaptersForReader.sortedWith(chapterSort.sortComparator(true)).map(::ReaderChapter)
}
private lateinit var chapterList: List<ReaderChapter>
private var chapterItems = emptyList<ReaderChapterItem>()
@ -231,7 +213,7 @@ class ReaderViewModel(
* Whether this presenter is initialized yet.
*/
fun needsInit(): Boolean {
return manga == null
return manga == null || !this::chapterList.isInitialized
}
/**
@ -261,7 +243,8 @@ class ReaderViewModel(
val context = Injekt.get<Application>()
loader = ChapterLoader(context, downloadManager, downloadProvider, manga, source)
loadChapter(loader!!, chapterList.first { chapterId == it.chapter.id })
chapterList = getChapterList()
loadChapter(loader!!, chapterList!!.first { chapterId == it.chapter.id })
Result.success(true)
} else {
// Unlikely but okay
@ -276,11 +259,24 @@ class ReaderViewModel(
}
}
private suspend fun getChapterList(): List<ReaderChapter> {
val manga = manga!!
val dbChapters = getChapter.awaitAll(manga.id!!, true)
val selectedChapter = dbChapters.find { it.id == chapterId }
?: error("Requested chapter of id $chapterId not found in chapter list")
val chaptersForReader =
chapterFilter.filterChaptersForReader(dbChapters, manga, selectedChapter)
val chapterSort = ChapterSort(manga, chapterFilter, preferences)
return chaptersForReader.sortedWith(chapterSort.sortComparator(true)).map(::ReaderChapter)
}
suspend fun getChapters(): List<ReaderChapterItem> {
val manga = manga ?: return emptyList()
chapterItems = withContext(Dispatchers.IO) {
val chapterSort = ChapterSort(manga, chapterFilter, preferences)
val dbChapters = runBlocking { getChapter.awaitAll(manga) }
val dbChapters = getChapter.awaitAll(manga)
chapterSort.getChaptersSorted(
dbChapters,
filterForReader = true,
@ -404,7 +400,7 @@ class ReaderViewModel(
): ViewerChapters {
loader.loadChapter(chapter)
val chapterPos = chapterList.indexOf(chapter)
val chapterPos = chapterList.indexOf(chapter) ?: -1
val newChapters = ViewerChapters(
chapter,
chapterList.getOrNull(chapterPos - 1),
@ -544,24 +540,26 @@ class ReaderViewModel(
private fun downloadNextChapters() {
val manga = manga ?: return
if (getCurrentChapter()?.pageLoader !is DownloadPageLoader) return
val nextChapter = state.value.viewerChapters?.nextChapter?.chapter ?: return
val chaptersNumberToDownload = preferences.autoDownloadWhileReading().get()
if (chaptersNumberToDownload == 0 || !manga.favorite) return
val isNextChapterDownloaded = downloadManager.isChapterDownloaded(nextChapter, manga)
if (isNextChapterDownloaded) {
downloadAutoNextChapters(chaptersNumberToDownload, nextChapter.id)
viewModelScope.launchNonCancellableIO {
if (getCurrentChapter()?.pageLoader !is DownloadPageLoader) return@launchNonCancellableIO
val nextChapter = state.value.viewerChapters?.nextChapter?.chapter ?: return@launchNonCancellableIO
val chaptersNumberToDownload = preferences.autoDownloadWhileReading().get()
if (chaptersNumberToDownload == 0 || !manga.favorite) return@launchNonCancellableIO
val isNextChapterDownloaded = downloadManager.isChapterDownloaded(nextChapter, manga)
if (isNextChapterDownloaded) {
downloadAutoNextChapters(chaptersNumberToDownload, nextChapter.id)
}
}
}
private fun downloadAutoNextChapters(choice: Int, nextChapterId: Long?) {
private suspend fun downloadAutoNextChapters(choice: Int, nextChapterId: Long?) {
val chaptersToDownload = getNextUnreadChaptersSorted(nextChapterId).take(choice - 1)
if (chaptersToDownload.isNotEmpty()) {
downloadChapters(chaptersToDownload)
}
}
private fun getNextUnreadChaptersSorted(nextChapterId: Long?): List<ChapterItem> {
private suspend fun getNextUnreadChaptersSorted(nextChapterId: Long?): List<ChapterItem> {
val chapterSort = ChapterSort(manga!!, chapterFilter, preferences)
return chapterList.map { ChapterItem(it.chapter, manga!!) }
.filter { !it.read || it.id == nextChapterId }
@ -1011,13 +1009,9 @@ class ReaderViewModel(
if (!chapter.chapter.read) return
val manga = manga ?: return
Completable
.fromCallable {
downloadManager.enqueueDeleteChapters(listOf(chapter.chapter), manga)
}
.onErrorComplete()
.subscribeOn(Schedulers.io())
.subscribe()
viewModelScope.launchNonCancellableIO {
downloadManager.enqueueDeleteChapters(listOf(chapter.chapter), manga)
}
}
/**
@ -1025,10 +1019,9 @@ class ReaderViewModel(
* are ignored.
*/
private fun deletePendingChapters() {
Completable.fromCallable { downloadManager.deletePendingChapters() }
.onErrorComplete()
.subscribeOn(Schedulers.io())
.subscribe()
viewModelScope.launchNonCancellableIO {
downloadManager.deletePendingChapters()
}
}
data class State(

View file

@ -234,7 +234,7 @@ open class ReaderPageImageView @JvmOverloads constructor(
is BufferedSource -> {
// SSIV doesn't tile bitmaps, so if the image exceeded max texture size it won't load regardless.
if (!isWebtoon || ImageUtil.isMaxTextureSizeExceeded(data)) {
setHardwareConfig(!ImageUtil.isMaxTextureSizeExceeded(data))
setHardwareConfig(!ImageUtil.isHardwareThresholdExceeded(data))
setImage(ImageSource.inputStream(data.inputStream()))
isVisible = true
return@apply

View file

@ -11,6 +11,7 @@ import android.graphics.drawable.ColorDrawable
import android.graphics.drawable.Drawable
import android.os.Build
import android.view.LayoutInflater
import androidx.core.os.postDelayed
import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import co.touchlab.kermit.Logger
@ -301,34 +302,31 @@ class PagerPageHolder(
private fun SubsamplingScaleImageView.landscapeZoom(forward: Boolean?) {
forward ?: return
if (viewer.config.landscapeZoom && viewer.config.imageScaleType == SubsamplingScaleImageView.SCALE_TYPE_CENTER_INSIDE && sWidth > sHeight && scale == minScale) {
handler.postDelayed(
{
val point = when (viewer.config.imageZoomType) {
ZoomType.Left -> if (forward) PointF(0F, 0F) else PointF(sWidth.toFloat(), 0F)
ZoomType.Right -> if (forward) PointF(sWidth.toFloat(), 0F) else PointF(0F, 0F)
ZoomType.Center -> center.also { it?.y = 0F }
}
handler.postDelayed(500) {
val point = when (viewer.config.imageZoomType) {
ZoomType.Left -> if (forward) PointF(0F, 0F) else PointF(sWidth.toFloat(), 0F)
ZoomType.Right -> if (forward) PointF(sWidth.toFloat(), 0F) else PointF(0F, 0F)
ZoomType.Center -> center.also { it?.y = 0F }
}
val rootInsets = viewer.activity.window.decorView.rootWindowInsets
val topInsets = if (viewer.activity.isSplitScreen) {
0f
} else {
rootInsets?.topCutoutInset()?.toFloat() ?: 0f
}
val bottomInsets = if (viewer.activity.isSplitScreen) {
0f
} else {
rootInsets?.bottomCutoutInset()?.toFloat() ?: 0f
}
val targetScale = (height.toFloat() - topInsets - bottomInsets) / sHeight.toFloat()
animateScaleAndCenter(min(targetScale, minScale * 2), point)!!
.withDuration(500)
.withEasing(SubsamplingScaleImageView.EASE_IN_OUT_QUAD)
.withInterruptible(true)
.start()
},
500,
)
val rootInsets = viewer.activity.window.decorView.rootWindowInsets
val topInsets = if (viewer.activity.isSplitScreen) {
0f
} else {
rootInsets?.topCutoutInset()?.toFloat() ?: 0f
}
val bottomInsets = if (viewer.activity.isSplitScreen) {
0f
} else {
rootInsets?.bottomCutoutInset()?.toFloat() ?: 0f
}
val targetScale = (height.toFloat() - topInsets - bottomInsets) / sHeight.toFloat()
(animateScaleAndCenter(min(targetScale, minScale * 2), point) ?: return@postDelayed)
.withDuration(500)
.withEasing(SubsamplingScaleImageView.EASE_IN_OUT_QUAD)
.withInterruptible(true)
.start()
}
}
}

View file

@ -92,7 +92,6 @@ import eu.kanade.tachiyomi.util.view.withFadeTransaction
import eu.kanade.tachiyomi.widget.LinearLayoutManagerAccurateOffset
import java.util.Locale
import kotlin.math.max
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import yokai.i18n.MR
import yokai.util.lang.getString
@ -390,7 +389,7 @@ class RecentsController(bundle: Bundle? = null) :
},
)
viewScope.launch {
LibraryUpdateJob.isRunningFlow(view.context).collectLatest {
LibraryUpdateJob.isRunningFlow(view.context).collect {
binding.swipeRefresh.isRefreshing = it
}
}
@ -629,13 +628,8 @@ class RecentsController(bundle: Bundle? = null) :
}
}
fun updateChapterDownload(download: Download, updateDLSheet: Boolean = true) {
if (view == null) return
if (updateDLSheet) {
binding.downloadBottomSheet.dlBottomSheet.update(!presenter.downloadManager.isPaused())
binding.downloadBottomSheet.dlBottomSheet.onUpdateProgress(download)
binding.downloadBottomSheet.dlBottomSheet.onUpdateDownloadedPages(download)
}
fun updateChapterDownload(download: Download) {
if (view == null || !this::adapter.isInitialized) return
val id = download.chapter.id ?: return
val item = adapter.getItemByChapterId(id) ?: return
val holder = binding.recycler.findViewHolderForItemId(item.id!!) as? RecentMangaHolder ?: return

View file

@ -5,7 +5,6 @@ import eu.kanade.tachiyomi.data.database.models.ChapterHistory
import eu.kanade.tachiyomi.data.database.models.History
import eu.kanade.tachiyomi.data.database.models.HistoryImpl
import eu.kanade.tachiyomi.data.database.models.MangaChapterHistory
import eu.kanade.tachiyomi.data.download.DownloadJob
import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.data.download.model.DownloadQueue
@ -31,6 +30,7 @@ import kotlin.math.abs
import kotlin.math.roundToInt
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
@ -55,7 +55,8 @@ class RecentsPresenter(
val preferences: PreferencesHelper = Injekt.get(),
val downloadManager: DownloadManager = Injekt.get(),
private val chapterFilter: ChapterFilter = Injekt.get(),
) : BaseCoroutinePresenter<RecentsController>(), DownloadQueue.DownloadListener {
) : BaseCoroutinePresenter<RecentsController>(),
DownloadQueue.Listener {
private val handler: DatabaseHandler by injectLazy()
private val getChapter: GetChapter by injectLazy()
@ -99,10 +100,27 @@ class RecentsPresenter(
private val isOnFirstPage: Boolean
get() = pageOffset == 0
override val progressJobs = mutableMapOf<Download, Job>()
override val queueListenerScope get() = presenterScope
override fun onCreate() {
super.onCreate()
downloadManager.addListener(this)
DownloadJob.downloadFlow.onEach(::downloadStatusChanged).launchIn(presenterScope)
presenterScope.launchUI {
downloadManager.statusFlow().collect(::onStatusChange)
}
presenterScope.launchUI {
downloadManager.progressFlow().collect(::onProgressUpdate)
}
presenterScope.launchIO {
downloadManager.queueState.collectLatest {
setDownloadedChapters(recentItems, it)
withUIContext {
view?.showLists(recentItems, true)
view?.updateDownloadStatus(!downloadManager.isPaused())
}
}
}
downloadManager.isDownloaderRunning.onEach(::downloadStatusChanged).launchIn(presenterScope)
LibraryUpdateJob.updateFlow.onEach(::onUpdateManga).launchIn(presenterScope)
if (lastRecents != null) {
if (recentItems.isEmpty()) {
@ -466,21 +484,21 @@ class RecentsPresenter(
}
private suspend fun getNextChapter(manga: Manga): Chapter? {
val chapters = getChapter.awaitAll(manga)
return ChapterSort(manga, chapterFilter, preferences).getNextUnreadChapter(chapters, false)
val mangaId = manga.id ?: return null
val chapters = getChapter.awaitUnread(mangaId, true)
return ChapterSort(manga, chapterFilter, preferences).getNextChapter(chapters, false)
}
private suspend fun getFirstUpdatedChapter(manga: Manga, chapter: Chapter): Chapter? {
val chapters = getChapter.awaitAll(manga)
return chapters
.sortedWith(ChapterSort(manga, chapterFilter, preferences).sortComparator(true)).find {
!it.read && abs(it.date_fetch - chapter.date_fetch) <= TimeUnit.HOURS.toMillis(12)
}
val mangaId = manga.id ?: return null
val chapters = getChapter.awaitUnread(mangaId, true)
return chapters.sortedWith(ChapterSort(manga, chapterFilter, preferences).sortComparator(true)).find {
abs(it.date_fetch - chapter.date_fetch) <= TimeUnit.HOURS.toMillis(12)
}
}
override fun onDestroy() {
super.onDestroy()
downloadManager.removeListener(this)
lastRecents = recentItems
}
@ -498,12 +516,12 @@ class RecentsPresenter(
*
* @param chapters the list of chapter from the database.
*/
private fun setDownloadedChapters(chapters: List<RecentMangaItem>) {
private fun setDownloadedChapters(chapters: List<RecentMangaItem>, queue: List<Download> = downloadManager.queueState.value) {
for (item in chapters.filter { it.chapter.id != null }) {
if (downloadManager.isChapterDownloaded(item.chapter, item.mch.manga)) {
item.status = Download.State.DOWNLOADED
} else if (downloadManager.hasQueue()) {
item.download = downloadManager.queue.find { it.chapter.id == item.chapter.id }
} else if (queue.isNotEmpty()) {
item.download = queue.find { it.chapter.id == item.chapter.id }
item.status = item.download?.status ?: Download.State.default
}
@ -512,8 +530,8 @@ class RecentsPresenter(
downloadInfo.chapterId = chapter.id
if (downloadManager.isChapterDownloaded(chapter, item.mch.manga)) {
downloadInfo.status = Download.State.DOWNLOADED
} else if (downloadManager.hasQueue()) {
downloadInfo.download = downloadManager.queue.find { it.chapter.id == chapter.id }
} else if (queue.isNotEmpty()) {
downloadInfo.download = queue.find { it.chapter.id == chapter.id }
downloadInfo.status = downloadInfo.download?.status ?: Download.State.default
}
downloadInfo
@ -521,32 +539,6 @@ class RecentsPresenter(
}
}
override fun updateDownload(download: Download) {
recentItems.find {
download.chapter.id == it.chapter.id ||
download.chapter.id in it.mch.extraChapters.map { ch -> ch.id }
}?.apply {
if (chapter.id != download.chapter.id) {
val downloadInfo = downloadInfo.find { it.chapterId == download.chapter.id }
?: return@apply
downloadInfo.download = download
} else {
this.download = download
}
}
presenterScope.launchUI { view?.updateChapterDownload(download) }
}
override fun updateDownloads() {
presenterScope.launch {
setDownloadedChapters(recentItems)
withContext(Dispatchers.Main) {
view?.showLists(recentItems, true)
view?.updateDownloadStatus(!downloadManager.isPaused())
}
}
}
private fun downloadStatusChanged(downloading: Boolean) {
presenterScope.launchUI {
view?.updateDownloadStatus(downloading)
@ -702,6 +694,18 @@ class RecentsPresenter(
}
}
override fun onProgressUpdate(download: Download) {
// don't do anything
}
override fun onQueueUpdate(download: Download) {
view?.updateChapterDownload(download)
}
override fun onPageProgressUpdate(download: Download) {
view?.updateChapterDownload(download)
}
enum class GroupType {
BySeries,
ByWeek,

View file

@ -58,6 +58,7 @@ import eu.kanade.tachiyomi.ui.setting.switchPreference
import eu.kanade.tachiyomi.util.CrashLogUtil
import eu.kanade.tachiyomi.util.lang.addBetaTag
import eu.kanade.tachiyomi.util.system.GLUtil
import eu.kanade.tachiyomi.util.system.ImageUtil
import eu.kanade.tachiyomi.util.system.disableItems
import eu.kanade.tachiyomi.util.system.isPackageInstalled
import eu.kanade.tachiyomi.util.system.launchIO
@ -416,7 +417,8 @@ class SettingsAdvancedController : SettingsLegacyController() {
entries = entryMap.values.toList()
entryValues = entryMap.keys.toList()
isVisible = GLUtil.DEVICE_TEXTURE_LIMIT > GLUtil.SAFE_TEXTURE_LIMIT
isVisible = !ImageUtil.HARDWARE_BITMAP_UNSUPPORTED &&
GLUtil.DEVICE_TEXTURE_LIMIT > GLUtil.SAFE_TEXTURE_LIMIT
basePreferences.hardwareBitmapThreshold().changesIn(viewScope) { threshold ->
summary = context.getString(MR.strings.pref_hardware_bitmap_threshold_summary, entryMap[threshold].orEmpty())

View file

@ -43,8 +43,10 @@ import eu.kanade.tachiyomi.util.addOrRemoveToFavorites
import eu.kanade.tachiyomi.util.system.connectivityManager
import eu.kanade.tachiyomi.util.system.dpToPx
import eu.kanade.tachiyomi.util.system.e
import eu.kanade.tachiyomi.util.system.launchIO
import eu.kanade.tachiyomi.util.system.openInBrowser
import eu.kanade.tachiyomi.util.system.toast
import eu.kanade.tachiyomi.util.system.withUIContext
import eu.kanade.tachiyomi.util.view.activityBinding
import eu.kanade.tachiyomi.util.view.applyBottomAnimatedInsets
import eu.kanade.tachiyomi.util.view.fullAppBarHeight
@ -59,7 +61,12 @@ import eu.kanade.tachiyomi.widget.AutofitRecyclerView
import eu.kanade.tachiyomi.widget.EmptyView
import eu.kanade.tachiyomi.widget.LinearLayoutManagerAccurateOffset
import kotlin.math.roundToInt
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import uy.kohesive.injekt.injectLazy
import yokai.domain.manga.interactor.GetManga
import yokai.i18n.MR
import yokai.util.lang.getString
@ -99,6 +106,8 @@ open class BrowseSourceController(bundle: Bundle) :
},
)
private val getManga: GetManga by injectLazy()
/**
* Preferences helper.
*/
@ -131,6 +140,9 @@ open class BrowseSourceController(bundle: Bundle) :
private val isBehindGlobalSearch: Boolean
get() = router.backstackSize >= 2 && router.backstack[router.backstackSize - 2].controller is GlobalSearchController
/** Watch for manga data changes */
private var watchJob: Job? = null
init {
setHasOptionsMenu(true)
}
@ -163,7 +175,7 @@ open class BrowseSourceController(bundle: Bundle) :
super.onViewCreated(view)
// Initialize adapter, scroll listener and recycler views
adapter = FlexibleAdapter(null, this)
adapter = FlexibleAdapter(null, this, true)
setupRecycler(view)
binding.fab.isVisible = presenter.sourceFilters.isNotEmpty()
@ -180,11 +192,10 @@ open class BrowseSourceController(bundle: Bundle) :
}
return
}
if (presenter.items.isNotEmpty()) {
onAddPage(1, presenter.items)
} else {
binding.progress.isVisible = true
}
binding.progress.isVisible = true
presenter.restartPager()
}
override fun onDestroyView(view: View) {
@ -654,7 +665,7 @@ open class BrowseSourceController(bundle: Bundle) :
* @param manga the manga initialized
*/
fun onMangaInitialized(manga: Manga) {
getHolder(manga)?.setImage(manga)
getHolder(manga.id!!)?.setImage(manga)
}
/**
@ -710,12 +721,12 @@ open class BrowseSourceController(bundle: Bundle) :
* @param manga the manga to find.
* @return the holder of the manga or null if it's not bound.
*/
private fun getHolder(manga: Manga): BrowseSourceHolder? {
private fun getHolder(mangaId: Long): BrowseSourceHolder? {
val adapter = adapter ?: return null
adapter.allBoundViewHolders.forEach { holder ->
val item = adapter.getItem(holder.flexibleAdapterPosition) as? BrowseSourceItem
if (item != null && item.manga.id!! == manga.id!!) {
if (item != null && item.mangaId == mangaId) {
return holder as BrowseSourceHolder
}
}
@ -741,6 +752,28 @@ open class BrowseSourceController(bundle: Bundle) :
binding.progress.isVisible = false
}
fun unsubscribe() {
watchJob?.cancel()
watchJob = null
}
/**
* Workaround to fix data state de-sync issues when controller detached,
* and attaching flow directly into Item caused some flickering issues.
*
* FIXME: Could easily be fixed by migrating to Compose.
*/
private fun BrowseSourceItem.subscribe() {
watchJob?.cancel()
watchJob = viewScope.launch {
getManga.subscribeByUrlAndSource(manga.url, manga.source).collectLatest {
if (it == null) return@collectLatest
val holder = getHolder(mangaId) ?: return@collectLatest
updateManga(holder, it)
}
}
}
/**
* Called when a manga is clicked.
*
@ -749,6 +782,7 @@ open class BrowseSourceController(bundle: Bundle) :
*/
override fun onItemClick(view: View?, position: Int): Boolean {
val item = adapter?.getItem(position) as? BrowseSourceItem ?: return false
item.subscribe()
router.pushController(MangaDetailsController(item.manga, true).withFadeTransaction())
lastPosition = position
return false
@ -767,22 +801,27 @@ open class BrowseSourceController(bundle: Bundle) :
val manga = (adapter?.getItem(position) as? BrowseSourceItem?)?.manga ?: return
val view = view ?: return
val activity = activity ?: return
snack?.dismiss()
snack = manga.addOrRemoveToFavorites(
preferences,
view,
activity,
presenter.sourceManager,
this,
onMangaAdded = {
adapter?.notifyItemChanged(position)
snack = view.snack(MR.strings.added_to_library)
},
onMangaMoved = { adapter?.notifyItemChanged(position) },
onMangaDeleted = { presenter.confirmDeletion(manga) },
)
if (snack?.duration == Snackbar.LENGTH_INDEFINITE) {
(activity as? MainActivity)?.setUndoSnackBar(snack)
viewScope.launchIO {
withUIContext { snack?.dismiss() }
snack = manga.addOrRemoveToFavorites(
preferences,
view,
activity,
presenter.sourceManager,
this@BrowseSourceController,
onMangaAdded = {
adapter?.notifyItemChanged(position)
snack = view.snack(MR.strings.added_to_library)
},
onMangaMoved = { adapter?.notifyItemChanged(position) },
onMangaDeleted = { presenter.confirmDeletion(manga) },
scope = viewScope,
)
if (snack?.duration == Snackbar.LENGTH_INDEFINITE) {
withUIContext {
(activity as? MainActivity)?.setUndoSnackBar(snack)
}
}
}
}

View file

@ -19,14 +19,19 @@ import eu.kanade.tachiyomi.ui.library.LibraryItem
import eu.kanade.tachiyomi.ui.library.setBGAndFG
import eu.kanade.tachiyomi.widget.AutofitRecyclerView
// FIXME: Migrate to compose
class BrowseSourceItem(
val manga: Manga,
initialManga: Manga,
private val catalogueAsList: Preference<Boolean>,
private val catalogueListType: Preference<Int>,
private val outlineOnCovers: Preference<Boolean>,
) :
AbstractFlexibleItem<BrowseSourceHolder>() {
val mangaId: Long = initialManga.id!!
var manga: Manga = initialManga
private set
override fun getLayoutRes(): Int {
return if (catalogueAsList.get()) {
R.layout.manga_list_item
@ -70,6 +75,16 @@ class BrowseSourceItem(
}
}
fun updateManga(
holder: BrowseSourceHolder,
manga: Manga,
) {
if (manga.id != mangaId) return
this.manga = manga
holder.onSetValues(manga)
}
override fun bindViewHolder(
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>,
holder: BrowseSourceHolder,
@ -82,12 +97,12 @@ class BrowseSourceItem(
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other is BrowseSourceItem) {
return manga.id!! == other.manga.id!!
return this.mangaId == other.mangaId
}
return false
}
override fun hashCode(): Int {
return manga.id!!.hashCode()
return mangaId.hashCode()
}
}

View file

@ -33,6 +33,7 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
@ -45,6 +46,7 @@ import yokai.domain.manga.interactor.UpdateManga
import yokai.domain.manga.models.MangaUpdate
import yokai.domain.ui.UiPreferences
// FIXME: Migrate to Compose
/**
* Presenter of [BrowseSourceController].
*/
@ -71,7 +73,6 @@ open class BrowseSourcePresenter(
var filtersChanged = false
var items = mutableListOf<BrowseSourceItem>()
val page: Int
get() = pager.currentPage
@ -128,7 +129,6 @@ open class BrowseSourcePresenter(
}
}
filtersChanged = false
restartPager()
}
}
@ -162,8 +162,7 @@ open class BrowseSourcePresenter(
// Create a new pager.
pager = createPager(
query,
filters.takeIf { it.isNotEmpty() || query.isBlank() }
?: source.getFilterList(),
filters.takeIf { it.isNotEmpty() || query.isBlank() } ?: source.getFilterList(),
)
val sourceId = source.id
@ -171,30 +170,39 @@ open class BrowseSourcePresenter(
val browseAsList = preferences.browseAsList()
val sourceListType = preferences.libraryLayout()
val outlineCovers = uiPreferences.outlineOnCovers()
items.clear()
view?.unsubscribe()
// Prepare the pager.
pagerJob?.cancel()
pagerJob = presenterScope.launchIO {
pager.results().onEach { (page, second) ->
try {
val mangas = second
pager.asFlow()
.map { (first, second) ->
first to second
.map { networkToLocalManga(it, sourceId) }
.filter { !preferences.hideInLibraryItems().get() || !it.favorite }
if (mangas.isEmpty() && page == 1) {
withUIContext { view?.onAddPageError(NoResultsException()) }
return@onEach
}
.onEach { initializeMangas(it.second) }
.map { (first, second) ->
first to second.map {
BrowseSourceItem(
it,
browseAsList,
sourceListType,
outlineCovers,
)
}
initializeMangas(mangas)
val items = mangas.map {
BrowseSourceItem(it, browseAsList, sourceListType, outlineCovers)
}
this@BrowseSourcePresenter.items.addAll(items)
withUIContext { view?.onAddPage(page, items) }
} catch (error: Exception) {
}
.catch { error ->
Logger.e(error) { "Unable to prepare a page" }
}
}.collect()
.collectLatest { (page, mangas) ->
if (mangas.isEmpty() && page == 1) {
withUIContext { view?.onAddPageError(NoResultsException()) }
return@collectLatest
}
withUIContext { view?.onAddPage(page, mangas) }
}
}
// Request first page.

View file

@ -16,7 +16,7 @@ abstract class Pager(var currentPage: Int = 1) {
protected val results = MutableSharedFlow<Pair<Int, List<SManga>>>()
fun results(): SharedFlow<Pair<Int, List<SManga>>> {
fun asFlow(): SharedFlow<Pair<Int, List<SManga>>> {
return results.asSharedFlow()
}

View file

@ -24,7 +24,9 @@ import eu.kanade.tachiyomi.ui.manga.MangaDetailsController
import eu.kanade.tachiyomi.ui.source.browse.BrowseSourceController
import eu.kanade.tachiyomi.util.addOrRemoveToFavorites
import eu.kanade.tachiyomi.util.system.extensionIntentForText
import eu.kanade.tachiyomi.util.system.launchIO
import eu.kanade.tachiyomi.util.system.rootWindowInsetsCompat
import eu.kanade.tachiyomi.util.system.withUIContext
import eu.kanade.tachiyomi.util.view.activityBinding
import eu.kanade.tachiyomi.util.view.isControllerVisible
import eu.kanade.tachiyomi.util.view.scrollViewWith
@ -114,34 +116,39 @@ open class GlobalSearchController(
val view = view ?: return
val activity = activity ?: return
snack?.dismiss()
snack = manga.addOrRemoveToFavorites(
preferences,
view,
activity,
presenter.sourceManager,
this,
onMangaAdded = { migrationInfo ->
migrationInfo?.let { (source, stillFaved) ->
val index = this.adapter
?.currentItems?.indexOfFirst { it.source.id == source } ?: return@let
val item = this.adapter?.getItem(index) ?: return@let
val oldMangaIndex = item.results?.indexOfFirst {
it.manga.title.lowercase() == manga.title.lowercase()
} ?: return@let
val oldMangaItem = item.results.getOrNull(oldMangaIndex)
oldMangaItem?.manga?.favorite = stillFaved
val holder = binding.recycler.findViewHolderForAdapterPosition(index) as? GlobalSearchHolder
holder?.updateManga(oldMangaIndex)
viewScope.launchIO {
withUIContext { snack?.dismiss() }
snack = manga.addOrRemoveToFavorites(
preferences,
view,
activity,
presenter.sourceManager,
this@GlobalSearchController,
onMangaAdded = { migrationInfo ->
migrationInfo?.let { (source, stillFaved) ->
val index = this@GlobalSearchController.adapter
?.currentItems?.indexOfFirst { it.source.id == source } ?: return@let
val item = this@GlobalSearchController.adapter?.getItem(index) ?: return@let
val oldMangaIndex = item.results?.indexOfFirst {
it.manga.title.lowercase() == manga.title.lowercase()
} ?: return@let
val oldMangaItem = item.results.getOrNull(oldMangaIndex)
oldMangaItem?.manga?.favorite = stillFaved
val holder = binding.recycler.findViewHolderForAdapterPosition(index) as? GlobalSearchHolder
holder?.updateManga(oldMangaIndex)
}
adapter.notifyItemChanged(position)
snack = view.snack(MR.strings.added_to_library)
},
onMangaMoved = { adapter.notifyItemChanged(position) },
onMangaDeleted = { presenter.confirmDeletion(manga) },
scope = viewScope,
)
if (snack?.duration == Snackbar.LENGTH_INDEFINITE) {
withUIContext {
(activity as? MainActivity)?.setUndoSnackBar(snack)
}
adapter.notifyItemChanged(position)
snack = view.snack(MR.strings.added_to_library)
},
onMangaMoved = { adapter.notifyItemChanged(position) },
onMangaDeleted = { presenter.confirmDeletion(manga) },
)
if (snack?.duration == Snackbar.LENGTH_INDEFINITE) {
(activity as? MainActivity)?.setUndoSnackBar(snack)
}
}
}

View file

@ -7,8 +7,23 @@ import eu.davidea.flexibleadapter.items.AbstractFlexibleItem
import eu.davidea.flexibleadapter.items.IFlexible
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.domain.manga.models.Manga
import kotlinx.coroutines.Job
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
class GlobalSearchMangaItem(val manga: Manga) : AbstractFlexibleItem<GlobalSearchMangaHolder>() {
// FIXME: Migrate to compose
class GlobalSearchMangaItem(
initialManga: Manga,
private val mangaFlow: Flow<Manga?>,
) : AbstractFlexibleItem<GlobalSearchMangaHolder>() {
val mangaId: Long? = initialManga.id
var manga: Manga = initialManga
private set
private val scope = MainScope()
private var job: Job? = null
override fun getLayoutRes(): Int {
return R.layout.source_global_search_controller_card_item
@ -24,17 +39,33 @@ class GlobalSearchMangaItem(val manga: Manga) : AbstractFlexibleItem<GlobalSearc
position: Int,
payloads: MutableList<Any?>?,
) {
holder.bind(manga)
if (job == null) holder.bind(manga)
job?.cancel()
job = scope.launch {
mangaFlow.collectLatest {
manga = it ?: return@collectLatest
holder.bind(manga)
}
}
}
override fun unbindViewHolder(
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>?,
holder: GlobalSearchMangaHolder?,
position: Int
) {
job?.cancel()
job = null
}
override fun equals(other: Any?): Boolean {
if (other is GlobalSearchMangaItem) {
return manga.id == other.manga.id
return mangaId == other.mangaId
}
return false
}
override fun hashCode(): Int {
return manga.id?.toInt() ?: 0
return mangaId?.toInt() ?: 0
}
}

View file

@ -192,7 +192,12 @@ open class GlobalSearchPresenter(
}
val result = createCatalogueSearchItem(
source,
mangas.map { GlobalSearchMangaItem(it) },
mangas.map {
GlobalSearchMangaItem(
it,
getManga.subscribeByUrlAndSource(it.url, it.source),
)
},
)
items = items
.map { item -> if (item.source == result.source) result else item }

View file

@ -41,8 +41,9 @@ import eu.kanade.tachiyomi.util.view.withFadeTransaction
import eu.kanade.tachiyomi.widget.TriStateCheckBox
import java.util.Date
import java.util.Locale
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import yokai.domain.category.interactor.GetCategories
@ -84,69 +85,69 @@ suspend fun Manga.shouldDownloadNewChapters(prefs: PreferencesHelper, getCategor
return categoriesForManga.any { it in includedCategories }
}
fun Manga.moveCategories(activity: Activity, onMangaMoved: () -> Unit) {
suspend fun Manga.moveCategories(activity: Activity, onMangaMoved: () -> Unit) {
moveCategories(activity, false, onMangaMoved)
}
fun Manga.moveCategories(
suspend fun Manga.moveCategories(
activity: Activity,
addingToLibrary: Boolean,
onMangaMoved: () -> Unit,
) {
val getCategories: GetCategories = Injekt.get()
// FIXME: Don't do blocking
val categories = runBlocking { getCategories.await() }
val categoriesForManga = runBlocking {
this@moveCategories.id?.let { mangaId -> getCategories.awaitByMangaId(mangaId) }
.orEmpty()
}
val categories = getCategories.await()
val categoriesForManga = this.id?.let { mangaId -> getCategories.awaitByMangaId(mangaId) }.orEmpty()
val ids = categoriesForManga.mapNotNull { it.id }.toTypedArray()
SetCategoriesSheet(
activity,
this,
categories.toMutableList(),
ids,
addingToLibrary,
) {
onMangaMoved()
if (addingToLibrary) {
autoAddTrack(onMangaMoved)
}
}.show()
withUIContext {
SetCategoriesSheet(
activity,
this@moveCategories,
categories.toMutableList(),
ids,
addingToLibrary,
) {
onMangaMoved()
if (addingToLibrary) {
autoAddTrack(onMangaMoved)
}
}.show()
}
}
fun List<Manga>.moveCategories(
suspend fun List<Manga>.moveCategories(
activity: Activity,
onMangaMoved: () -> Unit,
) {
if (this.isEmpty()) return
val getCategories: GetCategories = Injekt.get()
// FIXME: Don't do blocking
val categories = runBlocking { getCategories.await() }
val categories = getCategories.await()
val mangaCategories = map { manga ->
manga.id?.let { mangaId -> runBlocking { getCategories.awaitByMangaId(mangaId) } }.orEmpty()
manga.id?.let { mangaId -> getCategories.awaitByMangaId(mangaId) }.orEmpty()
}
val commonCategories = mangaCategories.reduce { set1, set2 -> set1.intersect(set2.toSet()).toMutableList() }.toSet()
val mixedCategories = mangaCategories.flatten().distinct().subtract(commonCategories).toMutableList()
SetCategoriesSheet(
activity,
this,
categories.toMutableList(),
categories.map {
when (it) {
in commonCategories -> TriStateCheckBox.State.CHECKED
in mixedCategories -> TriStateCheckBox.State.IGNORE
else -> TriStateCheckBox.State.UNCHECKED
}
}.toTypedArray(),
false,
) {
onMangaMoved()
}.show()
withUIContext {
SetCategoriesSheet(
activity,
this@moveCategories,
categories.toMutableList(),
categories.map {
when (it) {
in commonCategories -> TriStateCheckBox.State.CHECKED
in mixedCategories -> TriStateCheckBox.State.IGNORE
else -> TriStateCheckBox.State.UNCHECKED
}
}.toTypedArray(),
false,
) {
onMangaMoved()
}.show()
}
}
fun Manga.addOrRemoveToFavorites(
suspend fun Manga.addOrRemoveToFavorites(
preferences: PreferencesHelper,
view: View,
activity: Activity,
@ -160,15 +161,12 @@ fun Manga.addOrRemoveToFavorites(
setMangaCategories: SetMangaCategories = Injekt.get(),
getManga: GetManga = Injekt.get(),
updateManga: UpdateManga = Injekt.get(),
@OptIn(DelicateCoroutinesApi::class)
scope: CoroutineScope = GlobalScope,
): Snackbar? {
if (!favorite) {
if (checkForDupes) {
val duplicateManga = runBlocking(Dispatchers.IO) {
getManga.awaitDuplicateFavorite(
this@addOrRemoveToFavorites.title,
this@addOrRemoveToFavorites.source,
)
}
val duplicateManga = getManga.awaitDuplicateFavorite(this.title, this.source)
if (duplicateManga != null) {
showAddDuplicateDialog(
this,
@ -187,18 +185,19 @@ fun Manga.addOrRemoveToFavorites(
onMangaAdded,
onMangaMoved,
onMangaDeleted,
scope = scope,
)
},
migrateManga = { source, faved ->
onMangaAdded(source to faved)
},
scope = scope,
)
return null
}
}
// FIXME: Don't do blocking
val categories = runBlocking { getCategories.await() }
val categories = getCategories.await()
val defaultCategoryId = preferences.defaultCategory().get()
val defaultCategory = categories.find { it.id == defaultCategoryId }
val lastUsedCategories = Category.lastCategoriesAddedTo.mapNotNull { catId ->
@ -209,22 +208,23 @@ fun Manga.addOrRemoveToFavorites(
favorite = true
date_added = Date().time
autoAddTrack(onMangaMoved)
// FIXME: Don't do blocking
runBlocking {
updateManga.await(
MangaUpdate(
id = this@addOrRemoveToFavorites.id!!,
favorite = true,
dateAdded = this@addOrRemoveToFavorites.date_added,
)
updateManga.await(
MangaUpdate(
id = this@addOrRemoveToFavorites.id!!,
favorite = true,
dateAdded = this@addOrRemoveToFavorites.date_added,
)
setMangaCategories.await(this@addOrRemoveToFavorites.id!!, listOf(defaultCategory.id!!.toLong()))
}
(activity as? MainActivity)?.showNotificationPermissionPrompt()
onMangaMoved()
return view.snack(activity.getString(MR.strings.added_to_, defaultCategory.name)) {
setAction(MR.strings.change) {
moveCategories(activity, onMangaMoved)
)
setMangaCategories.await(this@addOrRemoveToFavorites.id!!, listOf(defaultCategory.id!!.toLong()))
return withUIContext {
onMangaMoved()
(activity as? MainActivity)?.showNotificationPermissionPrompt()
view.snack(activity.getString(MR.strings.added_to_, defaultCategory.name)) {
setAction(MR.strings.change) {
scope.launchIO {
moveCategories(activity, onMangaMoved)
}
}
}
}
}
@ -235,35 +235,36 @@ fun Manga.addOrRemoveToFavorites(
favorite = true
date_added = Date().time
autoAddTrack(onMangaMoved)
// FIXME: Don't do blocking
runBlocking {
updateManga.await(
MangaUpdate(
id = this@addOrRemoveToFavorites.id!!,
favorite = true,
dateAdded = this@addOrRemoveToFavorites.date_added,
)
updateManga.await(
MangaUpdate(
id = this@addOrRemoveToFavorites.id!!,
favorite = true,
dateAdded = this@addOrRemoveToFavorites.date_added,
)
setMangaCategories.await(this@addOrRemoveToFavorites.id!!, lastUsedCategories.map { it.id!!.toLong() })
}
(activity as? MainActivity)?.showNotificationPermissionPrompt()
onMangaMoved()
return view.snack(
activity.getString(
MR.strings.added_to_,
when (lastUsedCategories.size) {
0 -> activity.getString(MR.strings.default_category).lowercase(Locale.ROOT)
1 -> lastUsedCategories.firstOrNull()?.name ?: ""
else -> activity.getString(
MR.plurals.category_plural,
lastUsedCategories.size,
lastUsedCategories.size,
)
},
),
) {
setAction(MR.strings.change) {
moveCategories(activity, onMangaMoved)
)
setMangaCategories.await(this@addOrRemoveToFavorites.id!!, lastUsedCategories.map { it.id!!.toLong() })
return withUIContext {
onMangaMoved()
(activity as? MainActivity)?.showNotificationPermissionPrompt()
view.snack(
activity.getString(
MR.strings.added_to_,
when (lastUsedCategories.size) {
0 -> activity.getString(MR.strings.default_category).lowercase(Locale.ROOT)
1 -> lastUsedCategories.firstOrNull()?.name ?: ""
else -> activity.getString(
MR.plurals.category_plural,
lastUsedCategories.size,
lastUsedCategories.size,
)
},
),
) {
setAction(MR.strings.change) {
scope.launchIO {
moveCategories(activity, onMangaMoved)
}
}
}
}
}
@ -271,27 +272,28 @@ fun Manga.addOrRemoveToFavorites(
favorite = true
date_added = Date().time
autoAddTrack(onMangaMoved)
// FIXME: Don't do blocking
runBlocking {
updateManga.await(
MangaUpdate(
id = this@addOrRemoveToFavorites.id!!,
favorite = true,
dateAdded = this@addOrRemoveToFavorites.date_added,
)
updateManga.await(
MangaUpdate(
id = this@addOrRemoveToFavorites.id!!,
favorite = true,
dateAdded = this@addOrRemoveToFavorites.date_added,
)
setMangaCategories.await(this@addOrRemoveToFavorites.id!!, emptyList())
}
onMangaMoved()
(activity as? MainActivity)?.showNotificationPermissionPrompt()
return if (categories.isNotEmpty()) {
view.snack(activity.getString(MR.strings.added_to_, activity.getString(MR.strings.default_value))) {
setAction(MR.strings.change) {
moveCategories(activity, onMangaMoved)
)
setMangaCategories.await(this@addOrRemoveToFavorites.id!!, emptyList())
return withUIContext {
onMangaMoved()
(activity as? MainActivity)?.showNotificationPermissionPrompt()
if (categories.isNotEmpty()) {
view.snack(activity.getString(MR.strings.added_to_, activity.getString(MR.strings.default_value))) {
setAction(MR.strings.change) {
scope.launchIO {
moveCategories(activity, onMangaMoved)
}
}
}
} else {
view.snack(MR.strings.added_to_library)
}
} else {
view.snack(MR.strings.added_to_library)
}
}
else -> { // Always ask
@ -302,81 +304,82 @@ fun Manga.addOrRemoveToFavorites(
val lastAddedDate = date_added
favorite = false
date_added = 0
// FIXME: Don't do blocking
runBlocking {
updateManga.await(
MangaUpdate(
id = this@addOrRemoveToFavorites.id!!,
favorite = false,
dateAdded = 0,
)
updateManga.await(
MangaUpdate(
id = this@addOrRemoveToFavorites.id!!,
favorite = false,
dateAdded = 0,
)
}
onMangaMoved()
return view.snack(view.context.getString(MR.strings.removed_from_library), Snackbar.LENGTH_INDEFINITE) {
setAction(MR.strings.undo) {
favorite = true
date_added = lastAddedDate
// FIXME: Don't do blocking
runBlocking {
updateManga.await(
MangaUpdate(
id = this@addOrRemoveToFavorites.id!!,
favorite = true,
dateAdded = lastAddedDate,
)
return withUIContext {
onMangaMoved()
view.snack(view.context.getString(MR.strings.removed_from_library), Snackbar.LENGTH_INDEFINITE) {
setAction(MR.strings.undo) {
favorite = true
date_added = lastAddedDate
scope.launchIO {
updateManga.await(
MangaUpdate(
id = this@addOrRemoveToFavorites.id!!,
favorite = true,
dateAdded = lastAddedDate,
)
)
)
}
onMangaMoved()
}
addCallback(
object : BaseTransientBottomBar.BaseCallback<Snackbar>() {
override fun onDismissed(transientBottomBar: Snackbar?, event: Int) {
super.onDismissed(transientBottomBar, event)
if (!favorite) {
onMangaDeleted()
}
}
},
)
onMangaMoved()
}
addCallback(
object : BaseTransientBottomBar.BaseCallback<Snackbar>() {
override fun onDismissed(transientBottomBar: Snackbar?, event: Int) {
super.onDismissed(transientBottomBar, event)
if (!favorite) {
onMangaDeleted()
}
}
},
)
}
}
}
return null
}
private fun Manga.showSetCategoriesSheet(
private suspend fun Manga.showSetCategoriesSheet(
activity: Activity,
categories: List<Category>,
onMangaAdded: (Pair<Long, Boolean>?) -> Unit,
onMangaMoved: () -> Unit,
getCategories: GetCategories = Injekt.get(),
) {
// FIXME: Don't do blocking
val categoriesForManga = runBlocking { getCategories.awaitByMangaId(this@showSetCategoriesSheet.id!!) }
val categoriesForManga = getCategories.awaitByMangaId(this.id!!)
val ids = categoriesForManga.mapNotNull { it.id }.toTypedArray()
SetCategoriesSheet(
activity,
this,
categories.toMutableList(),
ids,
true,
) {
(activity as? MainActivity)?.showNotificationPermissionPrompt()
onMangaAdded(null)
autoAddTrack(onMangaMoved)
}.show()
withUIContext {
SetCategoriesSheet(
activity,
this@showSetCategoriesSheet,
categories.toMutableList(),
ids,
true,
) {
(activity as? MainActivity)?.showNotificationPermissionPrompt()
onMangaAdded(null)
autoAddTrack(onMangaMoved)
}.show()
}
}
private fun showAddDuplicateDialog(
private suspend fun showAddDuplicateDialog(
newManga: Manga,
libraryManga: Manga,
activity: Activity,
sourceManager: SourceManager,
controller: Controller,
addManga: () -> Unit,
addManga: suspend () -> Unit,
migrateManga: (Long, Boolean) -> Unit,
) {
@OptIn(DelicateCoroutinesApi::class)
scope: CoroutineScope = GlobalScope,
) = withUIContext {
val source = sourceManager.getOrStub(libraryManga.source)
val titles by lazy { MigrationFlags.titles(activity, libraryManga) }
@ -385,7 +388,7 @@ private fun showAddDuplicateDialog(
val enabled = titles.indices.map { listView.isItemChecked(it) }.toTypedArray()
val flags = MigrationFlags.getFlagsFromPositions(enabled, libraryManga)
val enhancedServices by lazy { Injekt.get<TrackManager>().services.filterIsInstance<EnhancedTrackService>() }
launchUI {
scope.launchUI {
MigrationProcessAdapter.migrateMangaInternal(
flags,
enhancedServices,
@ -415,7 +418,7 @@ private fun showAddDuplicateDialog(
MangaDetailsController(libraryManga)
.withFadeTransaction(),
)
1 -> addManga()
1 -> scope.launchIO { addManga() }
2 -> {
if (!newManga.initialized) {
activity.toast(MR.strings.must_view_details_before_migration, Toast.LENGTH_LONG)

View file

@ -30,6 +30,14 @@ class ChapterSort(val manga: Manga, val chapterFilter: ChapterFilter = Injekt.ge
return chapters.sortedWith(sortComparator())
}
fun <T : Chapter> getNextChapter(rawChapters: List<T>, andFiltered: Boolean = true): T? {
val chapters = when {
andFiltered -> chapterFilter.filterChapters(rawChapters, manga)
else -> rawChapters
}
return chapters.sortedWith(sortComparator(true)).firstOrNull()
}
fun <T : Chapter> getNextUnreadChapter(rawChapters: List<T>, andFiltered: Boolean = true): T? {
val chapters = when {
andFiltered -> chapterFilter.filterChapters(rawChapters, manga)

View file

@ -65,105 +65,107 @@ suspend fun syncChaptersWithSource(
// Chapters whose metadata have changed.
val toChange = mutableListOf<ChapterUpdate>()
for (sourceChapter in sourceChapters) {
val dbChapter = dbChapters.find { it.url == sourceChapter.url }
// Add the chapter if not in db already, or update if the metadata changed.
if (dbChapter == null) {
toAdd.add(sourceChapter)
} else {
// this forces metadata update for the main viewable things in the chapter list
if (source is HttpSource) {
source.prepareNewChapter(sourceChapter, manga)
}
sourceChapter.chapter_number =
ChapterRecognition.parseChapterNumber(sourceChapter.name, manga.title, sourceChapter.chapter_number)
if (shouldUpdateDbChapter(dbChapter, sourceChapter)) {
if ((dbChapter.name != sourceChapter.name || dbChapter.scanlator != sourceChapter.scanlator) &&
downloadManager.isChapterDownloaded(dbChapter, manga)
) {
downloadManager.renameChapter(source, manga, dbChapter, sourceChapter)
}
val update = ChapterUpdate(
dbChapter.id!!,
scanlator = sourceChapter.scanlator,
name = sourceChapter.name,
dateUpload = sourceChapter.date_upload,
chapterNumber = sourceChapter.chapter_number.toDouble(),
sourceOrder = sourceChapter.source_order.toLong(),
)
toChange.add(update)
}
val duplicates = dbChapters.groupBy { it.url }
.filter { it.value.size > 1 }
.flatMap { (_, chapters) ->
chapters.drop(1)
}
}
// Recognize number for new chapters.
toAdd.forEach {
if (source is HttpSource) {
source.prepareNewChapter(it, manga)
}
it.chapter_number = ChapterRecognition.parseChapterNumber(it.name, manga.title, it.chapter_number)
}
// Chapters from the db not in the source.
val toDelete = dbChapters.filterNot { dbChapter ->
val notInSource = dbChapters.filterNot { dbChapter ->
sourceChapters.any { sourceChapter ->
dbChapter.url == sourceChapter.url
}
}
val toDelete = duplicates + notInSource
val managedUrls = mutableListOf<String>()
for (sourceChapter in sourceChapters) {
val chapter = sourceChapter
if (chapter.url in managedUrls) continue
if (source is HttpSource) {
source.prepareNewChapter(chapter, manga)
}
chapter.chapter_number = ChapterRecognition.parseChapterNumber(chapter.name, manga.title, chapter.chapter_number)
val dbChapter = dbChapters.find { it.url == chapter.url }
// Add the chapter if not in db already, or update if the metadata changed.
if (dbChapter == null) {
toAdd.add(chapter)
} else {
if (shouldUpdateDbChapter(dbChapter, chapter)) {
if ((dbChapter.name != chapter.name || dbChapter.scanlator != chapter.scanlator) &&
downloadManager.isChapterDownloaded(dbChapter, manga)
) {
downloadManager.renameChapter(source, manga, dbChapter, chapter)
}
val update = ChapterUpdate(
dbChapter.id!!,
scanlator = chapter.scanlator,
name = chapter.name,
dateUpload = chapter.date_upload,
chapterNumber = chapter.chapter_number.toDouble(),
sourceOrder = chapter.source_order.toLong(),
)
toChange.add(update)
}
}
managedUrls.add(chapter.url)
}
// Return if there's nothing to add, delete or change, avoid unnecessary db transactions.
if (toAdd.isEmpty() && toDelete.isEmpty() && toChange.isEmpty()) {
val newestDate = dbChapters.maxOfOrNull { it.date_upload } ?: 0L
if (newestDate != 0L && newestDate != manga.last_update) {
manga.last_update = newestDate
val update = MangaUpdate(manga.id!!, lastUpdate = manga.last_update)
updateManga.await(update)
}
// TODO: Predict when the next chapter gonna release
return Pair(emptyList(), emptyList())
}
val readded = mutableListOf<Chapter>()
val reAdded = mutableListOf<Chapter>()
val deletedChapterNumbers = TreeSet<Float>()
val deletedReadChapterNumbers = TreeSet<Float>()
if (toDelete.isNotEmpty()) {
for (c in toDelete) {
if (c.read) {
deletedReadChapterNumbers.add(c.chapter_number)
}
deletedChapterNumbers.add(c.chapter_number)
}
deleteChapter.awaitAll(toDelete)
val deletedBookmarkedChapterNumbers = TreeSet<Float>()
toDelete.forEach {
if (it.read) deletedReadChapterNumbers.add(it.chapter_number)
if (it.bookmark) deletedBookmarkedChapterNumbers.add(it.chapter_number)
deletedChapterNumbers.add(it.chapter_number)
}
if (toAdd.isNotEmpty()) {
// Set the date fetch for new items in reverse order to allow another sorting method.
// Sources MUST return the chapters from most to less recent, which is common.
var now = Date().time
val now = Date().time
for (i in toAdd.indices.reversed()) {
val chapter = toAdd[i]
chapter.date_fetch = now++
if (chapter.isRecognizedNumber && chapter.chapter_number in deletedChapterNumbers) {
// Try to mark already read chapters as read when the source deletes them
if (chapter.chapter_number in deletedReadChapterNumbers) {
chapter.read = true
}
// Try to to use the fetch date it originally had to not pollute 'Updates' tab
toDelete.filter { it.chapter_number == chapter.chapter_number }
.minByOrNull { it.date_fetch }?.let {
chapter.date_fetch = it.date_fetch
}
// Date fetch is set in such a way that the upper ones will have bigger value than the lower ones
// Sources MUST return the chapters from most to less recent, which is common.
var itemCount = toAdd.size
var updatedToAdd = toAdd.map { toAddItem ->
val chapter: Chapter = toAddItem.copy()
readded.add(chapter)
chapter.date_fetch = now + itemCount--
if (!chapter.isRecognizedNumber || chapter.chapter_number !in deletedChapterNumbers) return@map chapter
chapter.read = chapter.chapter_number in deletedReadChapterNumbers
chapter.bookmark = chapter.chapter_number in deletedBookmarkedChapterNumbers
// Try to use the fetch date it originally had to not pollute 'Updates' tab
toDelete.filter { it.chapter_number == chapter.chapter_number }
.minByOrNull { it.date_fetch }?.let {
chapter.date_fetch = it.date_fetch
}
}
toAdd.forEach { chapter ->
chapter.id = insertChapter.await(chapter)
}
reAdded.add(chapter)
chapter
}
if (toDelete.isNotEmpty()) {
val idsToDelete = toDelete.mapNotNull { it.id }
deleteChapter.awaitAllById(idsToDelete)
}
if (updatedToAdd.isNotEmpty()) {
updatedToAdd = insertChapter.awaitBulk(updatedToAdd)
}
if (toChange.isNotEmpty()) {
@ -182,36 +184,25 @@ suspend fun syncChaptersWithSource(
}
}
var mangaUpdate: MangaUpdate? = null
// TODO: Predict when the next chapter gonna release
// Set this manga as updated since chapters were changed
val newestChapterDate = getChapter.awaitAll(manga, false)
.maxOfOrNull { it.date_upload } ?: 0L
if (newestChapterDate == 0L) {
if (toAdd.isNotEmpty()) {
manga.last_update = Date().time
mangaUpdate = MangaUpdate(manga.id!!, lastUpdate = manga.last_update)
}
} else {
manga.last_update = newestChapterDate
mangaUpdate = MangaUpdate(manga.id!!, lastUpdate = manga.last_update)
}
mangaUpdate?.let { updateManga.await(it) }
// Note that last_update actually represents last time the chapter list changed at all
// Those changes already checked beforehand, so we can proceed to updating the manga
manga.last_update = Date().time
updateManga.await(MangaUpdate(manga.id!!, lastUpdate = manga.last_update))
val reAddedUrls = reAdded.map { it.url }.toHashSet()
val filteredScanlators = ChapterUtil.getScanlators(manga.filtered_scanlators).toHashSet()
val reAddedSet = readded.toSet()
return Pair(
toAdd.subtract(reAddedSet).toList().filterChaptersByScanlators(manga),
toDelete - reAddedSet,
updatedToAdd.filterNot {
it.url in reAddedUrls || it.scanlator in filteredScanlators
},
toDelete.filterNot { it.url in reAddedUrls },
)
}
private fun List<Chapter>.filterChaptersByScanlators(manga: Manga): List<Chapter> {
if (manga.filtered_scanlators.isNullOrBlank()) return this
return this.filter { chapter ->
!ChapterUtil.getScanlators(manga.filtered_scanlators).contains(chapter.scanlator)
}
}
// checks if the chapter in db needs updated
private fun shouldUpdateDbChapter(dbChapter: Chapter, sourceChapter: Chapter): Boolean {
return dbChapter.scanlator != sourceChapter.scanlator ||

View file

@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.util.chapter
import android.content.Context
import android.content.res.ColorStateList
import android.widget.TextView
import androidx.core.graphics.ColorUtils
import androidx.core.widget.TextViewCompat
import androidx.vectordrawable.graphics.drawable.VectorDrawableCompat
import eu.kanade.tachiyomi.R
@ -102,12 +103,11 @@ class ChapterUtil {
private fun readColor(context: Context): Int = context.contextCompatColor(R.color.read_chapter)
private fun unreadColor(context: Context, secondary: Boolean = false): Int =
if (!secondary) {
context.getResourceColor(R.attr.colorOnBackground)
} else {
context.getResourceColor(android.R.attr.textColorSecondary)
}
private fun unreadColor(context: Context, secondary: Boolean = false): Int {
val color = context.getResourceColor(R.attr.colorOnSurface)
// 78% alpha for chapter details, 100% for chapter number/title
return ColorUtils.setAlphaComponent(color, if (secondary) 198 else 255)
}
private fun bookmarkedColor(context: Context): Int = context.getResourceColor(R.attr.colorSecondary)

View file

@ -776,20 +776,132 @@ object ImageUtil {
return options
}
fun isMaxTextureSizeExceeded(source: BufferedSource): Boolean =
extractImageOptions(source).let { opts -> isMaxTextureSizeExceeded(opts.outWidth, opts.outHeight) }
fun isHardwareThresholdExceeded(source: BufferedSource): Boolean = extractImageOptions(source).let { opts ->
isHardwareThresholdExceeded(opts.outWidth, opts.outHeight)
}
fun isMaxTextureSizeExceeded(drawable: BitmapDrawable): Boolean =
isMaxTextureSizeExceeded(drawable.bitmap)
fun isHardwareThresholdExceeded(drawable: BitmapDrawable): Boolean =
isHardwareThresholdExceeded(drawable.bitmap)
fun isMaxTextureSizeExceeded(bitmap: Bitmap): Boolean =
isMaxTextureSizeExceeded(bitmap.width, bitmap.height)
fun isHardwareThresholdExceeded(bitmap: Bitmap): Boolean =
isHardwareThresholdExceeded(bitmap.width, bitmap.height)
var hardwareBitmapThreshold: Int = GLUtil.SAFE_TEXTURE_LIMIT
private fun isMaxTextureSizeExceeded(width: Int, height: Int): Boolean {
private fun isHardwareThresholdExceeded(width: Int, height: Int): Boolean {
if (HARDWARE_BITMAP_UNSUPPORTED) return true
if (minOf(width, height) <= 0) return false
return maxOf(width, height) > hardwareBitmapThreshold
}
/**
* Taken from Coil
* (https://github.com/coil-kt/coil/blob/1674d3516f061aeacbe749a435b1924f9648fd41/coil-core/src/androidMain/kotlin/coil3/util/hardwareBitmaps.kt)
* ---
* Maintains a list of devices with broken/incomplete/unstable hardware bitmap implementations.
*
* Model names are retrieved from
* [Google's official device list](https://support.google.com/googleplay/answer/1727131?hl=en).
*
*/
val HARDWARE_BITMAP_UNSUPPORTED = when (Build.VERSION.SDK_INT) {
26 -> run {
val model = Build.MODEL ?: return@run false
// Samsung Galaxy (ALL)
if (model.removePrefix("SAMSUNG-").startsWith("SM-")) return@run true
val device = Build.DEVICE ?: return@run false
return@run device in arrayOf(
"nora", "nora_8917", "nora_8917_n", // Moto E5
"james", "rjames_f", "rjames_go", "pettyl", // Moto E5 Play
"hannah", "ahannah", "rhannah", // Moto E5 Plus
"ali", "ali_n", // Moto G6
"aljeter", "aljeter_n", "jeter", // Moto G6 Play
"evert", "evert_n", "evert_nt", // Moto G6 Plus
"G3112", "G3116", "G3121", "G3123", "G3125", // Xperia XA1
"G3412", "G3416", "G3421", "G3423", "G3426", // Xperia XA1 Plus
"G3212", "G3221", "G3223", "G3226", // Xperia XA1 Ultra
"BV6800Pro", // BlackView BV6800Pro
"CatS41", // Cat S41
"Hi9Pro", // CHUWI Hi9 Pro
"manning", // Lenovo K8 Note
"N5702L", // NUU Mobile G3
)
}
27 -> run {
val device = Build.DEVICE ?: return@run false
return@run device in arrayOf(
"mcv1s", // LG Tribute Empire
"mcv3", // LG K11
"mcv5a", // LG Q7
"mcv7a", // LG Stylo 4
"A30ATMO", // T-Mobile REVVL 2
"A70AXLTMO", // T-Mobile REVVL 2 PLUS
"A3A_8_4G_TMO", // Alcatel 9027W
"Edison_CKT", // Alcatel ONYX
"EDISON_TF", // Alcatel TCL XL2
"FERMI_TF", // Alcatel A501DL
"U50A_ATT", // Alcatel TETRA
"U50A_PLUS_ATT", // Alcatel 5059R
"U50A_PLUS_TF", // Alcatel TCL LX
"U50APLUSTMO", // Alcatel 5059Z
"U5A_PLUS_4G", // Alcatel 1X
"RCT6513W87DK5e", // RCA Galileo Pro
"RCT6873W42BMF9A", // RCA Voyager
"RCT6A03W13", // RCA 10 Viking
"RCT6B03W12", // RCA Atlas 10 Pro
"RCT6B03W13", // RCA Atlas 10 Pro+
"RCT6T06E13", // RCA Artemis 10
"A3_Pro", // Umidigi A3 Pro
"One", // Umidigi One
"One_Max", // Umidigi One Max
"One_Pro", // Umidigi One Pro
"Z2", // Umidigi Z2
"Z2_PRO", // Umidigi Z2 Pro
"Armor_3", // Ulefone Armor 3
"Armor_6", // Ulefone Armor 6
"Blackview", // Blackview BV6000
"BV9500", // Blackview BV9500
"BV9500Pro", // Blackview BV9500Pro
"A6L-C", // Nuu A6L-C
"N5002LA", // Nuu A7L
"N5501LA", // Nuu A5L
"Power_2_Pro", // Leagoo Power 2 Pro
"Power_5", // Leagoo Power 5
"Z9", // Leagoo Z9
"V0310WW", // Blu VIVO VI+
"V0330WW", // Blu VIVO XI
"A3", // BenQ A3
"ASUS_X018_4", // Asus ZenFone Max Plus M1 (ZB570TL)
"C210AE", // Wiko Life
"fireball", // DROID Incredible 4G LTE
"ILA_X1", // iLA X1
"Infinix-X605_sprout", // Infinix NOTE 5 Stylus
"j7maxlte", // Samsung Galaxy J7 Max
"KING_KONG_3", // Cubot King Kong 3
"M10500", // Packard Bell M10500
"S70", // Altice ALTICE S70
"S80Lite", // Doogee S80Lite
"SGINO6", // SGiNO 6
"st18c10bnn", // Barnes and Noble BNTV650
"TECNO-CA8", // Tecno CAMON X Pro,
"SHIFT6m", // SHIFT 6m
)
}
else -> false
}
fun isMaxTextureSizeExceeded(source: BufferedSource): Boolean = extractImageOptions(source).let { opts ->
isMaxTextureSizeExceeded(opts.outWidth, opts.outHeight)
}
fun isMaxTextureSizeExceeded(bitmap: Bitmap): Boolean =
isMaxTextureSizeExceeded(bitmap.width, bitmap.height)
private fun isMaxTextureSizeExceeded(width: Int, height: Int): Boolean {
if (minOf(width, height) <= 0) return false
return maxOf(width, height) > GLUtil.DEVICE_TEXTURE_LIMIT
}
}

View file

@ -11,6 +11,7 @@ import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.util.system.launchIO
import eu.kanade.tachiyomi.util.system.withIOContext
import java.io.IOException
import java.io.OutputStream
import java.text.DateFormat
import java.text.SimpleDateFormat
import java.util.Date
@ -39,6 +40,7 @@ import kotlinx.coroutines.newSingleThreadContext
class RollingUniFileLogWriter(
private val logPath: UniFile,
private val rollOnSize: Long = 10 * 1024 * 1024, // 10MB
private val maxRolledLogFiles: Int = 5,
private val maxLogFiles: Int = 5,
private val messageStringFormatter: MessageStringFormatter = DefaultFormatter,
private val messageDateFormat: DateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.getDefault())
@ -95,11 +97,11 @@ class RollingUniFileLogWriter(
}
private fun rollLogs() {
if (pathForLogIndex(maxLogFiles - 1)?.exists() == true) {
pathForLogIndex(maxLogFiles - 1)?.delete()
if (pathForLogIndex(maxRolledLogFiles - 1)?.exists() == true) {
pathForLogIndex(maxRolledLogFiles - 1)?.delete()
}
(0..<(maxLogFiles - 1)).reversed().forEach {
(0..<(maxRolledLogFiles - 1)).reversed().forEach {
val sourcePath = pathForLogIndex(it)
val targetFileName = fileNameForLogIndex(it + 1)
if (sourcePath?.exists() == true) {
@ -115,7 +117,8 @@ class RollingUniFileLogWriter(
private fun fileNameForLogIndex(index: Int): String {
val date = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(Date())
return if (index == 0) "${date}-${BuildConfig.BUILD_TYPE}.log" else "${date}-${BuildConfig.BUILD_TYPE} (${index}).log"
val name = "${date}-${BuildConfig.BUILD_TYPE}"
return if (index == 0) "${name}.log" else "$name (${index}).log"
}
private fun pathForLogIndex(index: Int, create: Boolean = false): UniFile? {
@ -129,7 +132,27 @@ class RollingUniFileLogWriter(
maybeRollLogs(fileSize(logFilePath))
}
fun openNewOutput() = pathForLogIndex(0, true)?.openOutputStream(true)
fun openNewOutput(): OutputStream? {
val newLog = pathForLogIndex(0, true)
val dupes = mutableMapOf<String, List<UniFile>>()
logPath
.listFiles { file, filename ->
val match = LOG_FILE_REGEX.find(filename)
match?.groupValues?.get(1)?.let { key ->
dupes["${key}.log"] = dupes["${key}.log"].orEmpty() + listOf(file)
}
match == null
}
.orEmpty()
.sortedByDescending { it.name }
.drop(maxLogFiles)
.forEach {
it.delete()
dupes[it.name]?.forEach { f -> f.delete() }
}
return newLog?.openOutputStream(true)
}
var currentLogSink = openNewOutput()
@ -157,4 +180,8 @@ class RollingUniFileLogWriter(
}
private fun fileSize(path: UniFile?) = path?.length() ?: -1L
companion object {
private val LOG_FILE_REGEX = """(\d+-\d+-\d+-${BuildConfig.BUILD_TYPE}) \(\d+\)\.log""".toRegex()
}
}

View file

@ -23,7 +23,7 @@ class ChapterRepositoryImpl(private val handler: DatabaseHandler) : ChapterRepos
handler.awaitList { chaptersQueries.getChaptersByUrl(url, filterScanlators.toInt().toLong(), Chapter::mapper) }
override suspend fun getChapterByUrl(url: String, filterScanlators: Boolean): Chapter? =
handler.awaitOneOrNull { chaptersQueries.getChaptersByUrl(url, filterScanlators.toInt().toLong(), Chapter::mapper) }
handler.awaitFirstOrNull { chaptersQueries.getChaptersByUrl(url, filterScanlators.toInt().toLong(), Chapter::mapper) }
override suspend fun getChaptersByUrlAndMangaId(
url: String,
@ -39,10 +39,15 @@ class ChapterRepositoryImpl(private val handler: DatabaseHandler) : ChapterRepos
mangaId: Long,
filterScanlators: Boolean
): Chapter? =
handler.awaitOneOrNull {
handler.awaitFirstOrNull {
chaptersQueries.getChaptersByUrlAndMangaId(url, mangaId, filterScanlators.toInt().toLong(), Chapter::mapper)
}
override suspend fun getUnread(mangaId: Long, filterScanlators: Boolean): List<Chapter> =
handler.awaitList {
chaptersQueries.findUnreadByMangaId(mangaId, filterScanlators.toInt().toLong(), Chapter::mapper)
}
override suspend fun getRecents(filterScanlators: Boolean, search: String, limit: Long, offset: Long): List<MangaChapter> =
handler.awaitList { chaptersQueries.getRecents(search, filterScanlators.toInt().toLong(), limit, offset, MangaChapter::mapper) }
@ -54,27 +59,26 @@ class ChapterRepositoryImpl(private val handler: DatabaseHandler) : ChapterRepos
override suspend fun delete(chapter: Chapter) =
try {
partialDelete(chapter)
partialDelete(chapter.id!!)
true
} catch (e: Exception) {
Logger.e(e) { "Failed to delete chapter with id '${chapter.id}'" }
false
}
override suspend fun deleteAll(chapters: List<Chapter>) =
override suspend fun deleteAllById(chapters: List<Long>) =
try {
partialDelete(*chapters.toTypedArray())
partialDelete(*chapters.toLongArray())
true
} catch (e: Exception) {
Logger.e(e) { "Failed to bulk delete chapters" }
false
}
private suspend fun partialDelete(vararg chapters: Chapter) {
private suspend fun partialDelete(vararg chapterIds: Long) {
handler.await(inTransaction = true) {
chapters.forEach { chapter ->
if (chapter.id == null) return@forEach
chaptersQueries.delete(chapter.id!!)
chapterIds.forEach { chapterId ->
chaptersQueries.delete(chapterId)
}
}
}
@ -143,7 +147,7 @@ class ChapterRepositoryImpl(private val handler: DatabaseHandler) : ChapterRepos
override suspend fun insertBulk(chapters: List<Chapter>) =
handler.await(true) {
chapters.forEach { chapter ->
chapters.map { chapter ->
chaptersQueries.insert(
mangaId = chapter.manga_id!!,
url = chapter.url,
@ -158,6 +162,8 @@ class ChapterRepositoryImpl(private val handler: DatabaseHandler) : ChapterRepos
dateFetch = chapter.date_fetch,
dateUpload = chapter.date_upload,
)
val lastInsertId = chaptersQueries.selectLastInsertedRowId().executeAsOne()
chapter.copy().apply { id = lastInsertId }
}
}
}

View file

@ -16,7 +16,7 @@ class MangaRepositoryImpl(private val handler: DatabaseHandler) : MangaRepositor
handler.awaitList { mangasQueries.findAll(Manga::mapper) }
override suspend fun getMangaByUrlAndSource(url: String, source: Long): Manga? =
handler.awaitOneOrNull { mangasQueries.findByUrlAndSource(url, source, Manga::mapper) }
handler.awaitFirstOrNull { mangasQueries.findByUrlAndSource(url, source, Manga::mapper) }
override suspend fun getMangaById(id: Long): Manga? =
handler.awaitOneOrNull { mangasQueries.findById(id, Manga::mapper) }
@ -30,6 +30,9 @@ class MangaRepositoryImpl(private val handler: DatabaseHandler) : MangaRepositor
override fun getMangaListAsFlow(): Flow<List<Manga>> =
handler.subscribeToList { mangasQueries.findAll(Manga::mapper) }
override fun getMangaByUrlAndSourceAsFlow(url: String, source: Long): Flow<Manga?> =
handler.subscribeToFirstOrNull { mangasQueries.findByUrlAndSource(url, source, Manga::mapper) }
override suspend fun getLibraryManga(): List<LibraryManga> =
handler.awaitList { library_viewQueries.findAll(LibraryManga::mapper) }
@ -37,7 +40,7 @@ class MangaRepositoryImpl(private val handler: DatabaseHandler) : MangaRepositor
handler.subscribeToList { library_viewQueries.findAll(LibraryManga::mapper) }
override suspend fun getDuplicateFavorite(title: String, source: Long): Manga? =
handler.awaitOneOrNull { mangasQueries.findDuplicateFavorite(title.lowercase(), source, Manga::mapper) }
handler.awaitFirstOrNull { mangasQueries.findDuplicateFavorite(title.lowercase(), source, Manga::mapper) }
override suspend fun update(update: MangaUpdate): Boolean {
return try {

View file

@ -21,17 +21,21 @@ data class Version(
// On nightly we only care about build number
if (type == Type.NIGHTLY) return build.compareTo(other.build)
var rt = (major.compareTo(other.major) +
minor.compareTo(other.minor) +
patch.compareTo(other.patch)).compareTo(0)
// check if it's a hotfix (1.2.3 vs 1.2.3.1)
if (rt == 0) rt = hotfix.compareTo(other.hotfix)
// if semver is equal, check version stage (release (3) > beta (2) > alpha (1))
if (rt == 0) rt = stage.weight.compareTo(other.stage.weight)
// if everything are equal, we compare build number. This only matters on unstable (beta and nightly) releases
if (rt == 0) rt = build.compareTo(other.build)
val currentVer = listOf(major, minor, patch, hotfix, stage.weight, build)
val otherVer = listOf(other.major, other.minor, other.patch, other.hotfix, other.stage.weight, other.build)
return rt
// In case my brain fried and left out a value
if (currentVer.size != otherVer.size) throw RuntimeException("Version lists' size must be the same")
for (i in 1..currentVer.size) {
when (currentVer[i - 1].compareTo(otherVer[i - 1])) {
0 -> if (i == currentVer.size) return 0 else continue
1 -> return 1
else -> return -1
}
}
return 0
}
override fun toString(): String {

View file

@ -16,6 +16,7 @@ interface ChapterRepository {
suspend fun getChaptersByUrlAndMangaId(url: String, mangaId: Long, filterScanlators: Boolean): List<Chapter>
suspend fun getChapterByUrlAndMangaId(url: String, mangaId: Long, filterScanlators: Boolean): Chapter?
suspend fun getUnread(mangaId: Long, filterScanlators: Boolean): List<Chapter>
suspend fun getRecents(filterScanlators: Boolean, search: String = "", limit: Long = 25L, offset: Long = 0L): List<MangaChapter>
@ -23,11 +24,11 @@ interface ChapterRepository {
fun getScanlatorsByChapterAsFlow(mangaId: Long): Flow<List<String>>
suspend fun delete(chapter: Chapter): Boolean
suspend fun deleteAll(chapters: List<Chapter>): Boolean
suspend fun deleteAllById(chapters: List<Long>): Boolean
suspend fun update(update: ChapterUpdate): Boolean
suspend fun updateAll(updates: List<ChapterUpdate>): Boolean
suspend fun insert(chapter: Chapter): Long?
suspend fun insertBulk(chapters: List<Chapter>)
suspend fun insertBulk(chapters: List<Chapter>): List<Chapter>
}

View file

@ -7,5 +7,5 @@ class DeleteChapter(
private val chapterRepository: ChapterRepository,
) {
suspend fun await(chapter: Chapter) = chapterRepository.delete(chapter)
suspend fun awaitAll(chapters: List<Chapter>) = chapterRepository.deleteAll(chapters)
suspend fun awaitAllById(chapterIds: List<Long>) = chapterRepository.deleteAllById(chapterIds)
}

View file

@ -11,6 +11,9 @@ class GetChapter(
suspend fun awaitAll(manga: Manga, filterScanlators: Boolean? = null) =
awaitAll(manga.id!!, filterScanlators ?: (manga.filtered_scanlators?.isNotEmpty() == true))
suspend fun awaitUnread(mangaId: Long, filterScanlators: Boolean) =
chapterRepository.getUnread(mangaId, filterScanlators)
suspend fun awaitById(id: Long) = chapterRepository.getChapterById(id)
suspend fun awaitAllByUrl(chapterUrl: String, filterScanlators: Boolean) =

View file

@ -9,6 +9,7 @@ import yokai.domain.manga.models.MangaUpdate
interface MangaRepository {
suspend fun getMangaList(): List<Manga>
suspend fun getMangaByUrlAndSource(url: String, source: Long): Manga?
fun getMangaByUrlAndSourceAsFlow(url: String, source: Long): Flow<Manga?>
suspend fun getMangaById(id: Long): Manga?
suspend fun getFavorites(): List<Manga>
suspend fun getReadNotFavorites(): List<Manga>

View file

@ -7,6 +7,7 @@ class GetManga (
) {
suspend fun awaitAll() = mangaRepository.getMangaList()
fun subscribeAll() = mangaRepository.getMangaListAsFlow()
fun subscribeByUrlAndSource(url: String, source: Long) = mangaRepository.getMangaByUrlAndSourceAsFlow(url, source)
suspend fun awaitByUrlAndSource(url: String, source: Long) = mangaRepository.getMangaByUrlAndSource(url, source)
suspend fun awaitById(id: Long) = mangaRepository.getMangaById(id)

View file

@ -6,7 +6,9 @@ import coil3.ImageLoader
import coil3.imageLoader
import coil3.request.Disposable
import coil3.request.ImageRequest
import coil3.request.maxBitmapSize
import coil3.size.Precision
import coil3.size.Size
import coil3.size.SizeResolver
import coil3.target.ImageViewTarget
import eu.kanade.tachiyomi.data.coil.CoverViewTarget
@ -15,6 +17,8 @@ import eu.kanade.tachiyomi.domain.manga.models.Manga
import yokai.domain.manga.models.MangaCover
import yokai.domain.manga.models.cover
private const val MAX_BITMAP_SIZE = 2048
fun ImageView.loadManga(
manga: Manga,
imageLoader: ImageLoader = context.imageLoader,
@ -25,6 +29,7 @@ fun ImageView.loadManga(
.target(LibraryMangaImageTarget(this, manga))
.precision(Precision.INEXACT)
.size(SizeResolver.ORIGINAL)
.maxBitmapSize(Size(MAX_BITMAP_SIZE, MAX_BITMAP_SIZE))
.apply(builder)
.build()
return imageLoader.enqueue(request)
@ -44,6 +49,7 @@ fun ImageView.loadManga(
.target(target ?: CoverViewTarget(this, progress))
.precision(Precision.INEXACT)
.size(SizeResolver.ORIGINAL)
.maxBitmapSize(Size(MAX_BITMAP_SIZE, MAX_BITMAP_SIZE))
.apply(builder)
.build()
return imageLoader.enqueue(request)

View file

@ -33,11 +33,11 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.unit.dp
import com.kevinnzou.accompanist.web.AccompanistWebViewClient
import com.kevinnzou.accompanist.web.LoadingState
import com.kevinnzou.accompanist.web.WebView
import com.kevinnzou.accompanist.web.rememberWebViewNavigator
import com.kevinnzou.accompanist.web.rememberWebViewState
import com.kevinnzou.web.AccompanistWebViewClient
import com.kevinnzou.web.LoadingState
import com.kevinnzou.web.WebView
import com.kevinnzou.web.rememberWebViewNavigator
import com.kevinnzou.web.rememberWebViewState
import dev.icerock.moko.resources.compose.stringResource
import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.util.system.extensionIntentForText

View file

@ -76,6 +76,8 @@ class AppUpdateCheckerTest {
@Test
fun `Prod should get latest Prod build (Check for Betas)`() {
assertTrue(isNewVersion("1.2.4-b1", "1.2.3"))
assertTrue(isNewVersion("1.3.0-b1", "1.2.3"))
assertTrue(isNewVersion("2.0.0-b1", "1.2.3"))
}
@Test