mirror of
https://github.com/null2264/yokai.git
synced 2025-07-17 22:36:55 +00:00
Merge branch 'master' into dev/refactor-library
This commit is contained in:
commit
7836c26c6e
93 changed files with 2289 additions and 1486 deletions
|
@ -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 {
|
||||
|
|
|
@ -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() }
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -90,4 +90,8 @@ interface Chapter : SChapter, Serializable {
|
|||
source_order = other.source_order
|
||||
copyFrom(other as SChapter)
|
||||
}
|
||||
|
||||
fun copy() = ChapterImpl().apply {
|
||||
copyFrom(this@Chapter)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
*
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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())
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 ||
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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>
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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) =
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue