diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/LocalSource.kt b/app/src/main/java/eu/kanade/tachiyomi/source/LocalSource.kt index 6404ec1353..a4ecd52d6b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/LocalSource.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/LocalSource.kt @@ -1,8 +1,11 @@ package eu.kanade.tachiyomi.source import android.content.Context +import androidx.core.net.toUri import com.github.junrar.Archive +import com.hippo.unifile.UniFile import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.cache.CoverCache import eu.kanade.tachiyomi.source.model.Filter import eu.kanade.tachiyomi.source.model.FilterList import eu.kanade.tachiyomi.source.model.MangasPage @@ -30,6 +33,8 @@ import logcat.LogPriority import rx.Observable import tachiyomi.source.model.ChapterInfo import tachiyomi.source.model.MangaInfo +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get import uy.kohesive.injekt.injectLazy import java.io.File import java.io.FileInputStream @@ -37,130 +42,104 @@ import java.io.InputStream import java.util.concurrent.TimeUnit import java.util.zip.ZipFile -class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSource { - - companion object { - const val ID = 0L - const val HELP_URL = "https://tachiyomi.org/help/guides/local-manga/" - - private const val COVER_NAME = "cover.jpg" - private val LATEST_THRESHOLD = TimeUnit.MILLISECONDS.convert(7, TimeUnit.DAYS) - - fun updateCover(context: Context, manga: SManga, input: InputStream): File? { - val dir = getBaseDirectories(context).firstOrNull() - if (dir == null) { - input.close() - return null - } - var cover = getCoverFile(File("${dir.absolutePath}/${manga.url}")) - if (cover == null) { - cover = File("${dir.absolutePath}/${manga.url}", COVER_NAME) - } - // It might not exist if using the external SD card - cover.parentFile?.mkdirs() - input.use { - cover.outputStream().use { - input.copyTo(it) - } - } - manga.thumbnail_url = cover.absolutePath - return cover - } - - /** - * Returns valid cover file inside [parent] directory. - */ - private fun getCoverFile(parent: File): File? { - return parent.listFiles()?.find { it.nameWithoutExtension == "cover" }?.takeIf { - it.isFile && ImageUtil.isImage(it.name) { it.inputStream() } - } - } - - private fun getBaseDirectories(context: Context): List { - val c = context.getString(R.string.app_name) + File.separator + "local" - return DiskUtil.getExternalStorages(context).map { File(it.absolutePath, c) } - } - } +class LocalSource( + private val context: Context, + private val coverCache: CoverCache = Injekt.get(), +) : CatalogueSource, UnmeteredSource { private val json: Json by injectLazy() - override val id = ID - override val name = context.getString(R.string.local_source) - override val lang = "other" - override val supportsLatest = true + override val name: String = context.getString(R.string.local_source) + + override val id: Long = ID + + override val lang: String = "other" override fun toString() = name + override val supportsLatest: Boolean = true + + // Browse related override fun fetchPopularManga(page: Int) = fetchSearchManga(page, "", POPULAR_FILTERS) - override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable { - val baseDirs = getBaseDirectories(context) + override fun fetchLatestUpdates(page: Int) = fetchSearchManga(page, "", LATEST_FILTERS) - val time = if (filters === LATEST_FILTERS) System.currentTimeMillis() - LATEST_THRESHOLD else 0L - var mangaDirs = baseDirs - .asSequence() - .mapNotNull { it.listFiles()?.toList() } - .flatten() - .filter { it.isDirectory } - .filterNot { it.name.startsWith('.') } - .filter { if (time == 0L) it.name.contains(query, ignoreCase = true) else it.lastModified() >= time } + override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable { + val baseDirsFiles = getBaseDirectoriesFiles(context) + + var mangaDirs = baseDirsFiles + // Filter out files that are hidden and is not a folder + .filter { it.isDirectory && !it.name.startsWith('.') } .distinctBy { it.name } - val state = ((if (filters.isEmpty()) POPULAR_FILTERS else filters)[0] as OrderBy).state - when (state?.index) { - 0 -> { - mangaDirs = if (state.ascending) { - mangaDirs.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER, { it.name })) - } else { - mangaDirs.sortedWith(compareByDescending(String.CASE_INSENSITIVE_ORDER, { it.name })) - } - } - 1 -> { - mangaDirs = if (state.ascending) { - mangaDirs.sortedBy(File::lastModified) - } else { - mangaDirs.sortedByDescending(File::lastModified) - } + val lastModifiedLimit = if (filters === LATEST_FILTERS) System.currentTimeMillis() - LATEST_THRESHOLD else 0L + // Filter by query or last modified + mangaDirs = mangaDirs.filter { + if (lastModifiedLimit == 0L) { + it.name.contains(query, ignoreCase = true) + } else { + it.lastModified() >= lastModifiedLimit } } + filters.forEach { filter -> + when (filter) { + is OrderBy -> { + when (filter.state!!.index) { + 0 -> { + mangaDirs = if (filter.state!!.ascending) { + mangaDirs.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name }) + } else { + mangaDirs.sortedWith(compareByDescending(String.CASE_INSENSITIVE_ORDER) { it.name }) + } + } + 1 -> { + mangaDirs = if (filter.state!!.ascending) { + mangaDirs.sortedBy(File::lastModified) + } else { + mangaDirs.sortedByDescending(File::lastModified) + } + } + } + } + + else -> { /* Do nothing */ } + } + } + + // Transform mangaDirs to list of SManga val mangas = mangaDirs.map { mangaDir -> SManga.create().apply { title = mangaDir.name url = mangaDir.name // Try to find the cover - for (dir in baseDirs) { - val cover = getCoverFile(File("${dir.absolutePath}/$url")) - if (cover != null && cover.exists()) { - thumbnail_url = cover.absolutePath - break - } + val cover = getCoverFile(mangaDir.name, baseDirsFiles) + if (cover != null && cover.exists()) { + thumbnail_url = cover.absolutePath } + } + } - val sManga = this - val mangaInfo = this.toMangaInfo() - runBlocking { - val chapters = getChapterList(mangaInfo) - if (chapters.isNotEmpty()) { - val chapter = chapters.last().toSChapter() - val format = getFormat(chapter) - if (format is Format.Epub) { - EpubFile(format.file).use { epub -> - epub.fillMangaMetadata(sManga) - } - } + // Fetch chapters of all the manga + mangas.forEach { manga -> + val mangaInfo = manga.toMangaInfo() + runBlocking { + val chapters = getChapterList(mangaInfo) + if (chapters.isNotEmpty()) { + val chapter = chapters.last().toSChapter() + val format = getFormat(chapter) - // Copy the cover from the first chapter found. - if (thumbnail_url == null) { - try { - val dest = updateCover(chapter, sManga) - thumbnail_url = dest?.absolutePath - } catch (e: Exception) { - logcat(LogPriority.ERROR, e) - } + if (format is Format.Epub) { + EpubFile(format.file).use { epub -> + epub.fillMangaMetadata(manga) } } + + // Copy the cover from the first chapter found if not available + if (manga.thumbnail_url == null) { + updateCover(chapter, manga) + } } } } @@ -168,38 +147,44 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour return Observable.just(MangasPage(mangas.toList(), false)) } - override fun fetchLatestUpdates(page: Int) = fetchSearchManga(page, "", LATEST_FILTERS) - + // Manga details related override suspend fun getMangaDetails(manga: MangaInfo): MangaInfo { - val localDetails = getBaseDirectories(context) - .asSequence() - .mapNotNull { File(it, manga.key).listFiles()?.toList() } - .flatten() + var mangaInfo = manga + + val baseDirsFile = getBaseDirectoriesFiles(context) + + val coverFile = getCoverFile(manga.key, baseDirsFile) + + coverFile?.let { + mangaInfo = mangaInfo.copy(cover = it.absolutePath) + } + + val localDetails = getMangaDirsFiles(manga.key, baseDirsFile) .firstOrNull { it.extension.equals("json", ignoreCase = true) } - return if (localDetails != null) { + if (localDetails != null) { val obj = json.decodeFromStream(localDetails.inputStream()) - manga.copy( - title = obj["title"]?.jsonPrimitive?.contentOrNull ?: manga.title, - author = obj["author"]?.jsonPrimitive?.contentOrNull ?: manga.author, - artist = obj["artist"]?.jsonPrimitive?.contentOrNull ?: manga.artist, - description = obj["description"]?.jsonPrimitive?.contentOrNull ?: manga.description, - genres = obj["genre"]?.jsonArray?.map { it.jsonPrimitive.content } ?: manga.genres, - status = obj["status"]?.jsonPrimitive?.intOrNull ?: manga.status, + mangaInfo = mangaInfo.copy( + title = obj["title"]?.jsonPrimitive?.contentOrNull ?: mangaInfo.title, + author = obj["author"]?.jsonPrimitive?.contentOrNull ?: mangaInfo.author, + artist = obj["artist"]?.jsonPrimitive?.contentOrNull ?: mangaInfo.artist, + description = obj["description"]?.jsonPrimitive?.contentOrNull ?: mangaInfo.description, + genres = obj["genre"]?.jsonArray?.map { it.jsonPrimitive.content } ?: mangaInfo.genres, + status = obj["status"]?.jsonPrimitive?.intOrNull ?: mangaInfo.status, ) - } else { - manga } + + return mangaInfo } + // Chapters override suspend fun getChapterList(manga: MangaInfo): List { val sManga = manga.toSManga() - val chapters = getBaseDirectories(context) - .asSequence() - .mapNotNull { File(it, manga.key).listFiles()?.toList() } - .flatten() + val baseDirsFile = getBaseDirectoriesFiles(context) + return getMangaDirsFiles(manga.key, baseDirsFile) + // Only keep supported formats .filter { it.isDirectory || isSupportedFile(it.extension) } .map { chapterFile -> SChapter.create().apply { @@ -211,14 +196,14 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour } date_upload = chapterFile.lastModified() + ChapterRecognition.parseChapterNumber(this, sManga) + val format = getFormat(chapterFile) if (format is Format.Epub) { EpubFile(format.file).use { epub -> epub.fillChapterMetadata(this) } } - - ChapterRecognition.parseChapterNumber(this, sManga) } } .map { it.toChapterInfo() } @@ -227,12 +212,24 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour if (c == 0) c2.name.compareToCaseInsensitiveNaturalOrder(c1.name) else c } .toList() - - return chapters } - override suspend fun getPageList(chapter: ChapterInfo) = throw Exception("Unused") + // Filters + override fun getFilterList() = FilterList(OrderBy(context)) + private val POPULAR_FILTERS = FilterList(OrderBy(context)) + private val LATEST_FILTERS = FilterList(OrderBy(context).apply { state = Filter.Sort.Selection(1, false) }) + + private class OrderBy(context: Context) : Filter.Sort( + context.getString(R.string.local_filter_order_by), + arrayOf(context.getString(R.string.title), context.getString(R.string.date)), + Selection(0, true), + ) + + // Unused stuff + override suspend fun getPageList(chapter: ChapterInfo) = throw UnsupportedOperationException("Unused") + + // Miscellaneous private fun isSupportedFile(extension: String): Boolean { return extension.lowercase() in SUPPORTED_ARCHIVE_TYPES } @@ -296,25 +293,90 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour } } } + .also { coverCache.clearMemoryCache() } } - override fun getFilterList() = POPULAR_FILTERS - - private val POPULAR_FILTERS = FilterList(OrderBy(context)) - private val LATEST_FILTERS = FilterList(OrderBy(context).apply { state = Filter.Sort.Selection(1, false) }) - - private class OrderBy(context: Context) : Filter.Sort( - context.getString(R.string.local_filter_order_by), - arrayOf(context.getString(R.string.title), context.getString(R.string.date)), - Selection(0, true), - ) - sealed class Format { data class Directory(val file: File) : Format() data class Zip(val file: File) : Format() data class Rar(val file: File) : Format() data class Epub(val file: File) : Format() } + + companion object { + const val ID = 0L + const val HELP_URL = "https://tachiyomi.org/help/guides/local-manga/" + + private const val DEFAULT_COVER_NAME = "cover.jpg" + private val LATEST_THRESHOLD = TimeUnit.MILLISECONDS.convert(7, TimeUnit.DAYS) + + private fun getBaseDirectories(context: Context): Sequence { + val localFolder = context.getString(R.string.app_name) + File.separator + "local" + return DiskUtil.getExternalStorages(context) + .map { File(it.absolutePath, localFolder) } + .asSequence() + } + + private fun getBaseDirectoriesFiles(context: Context): Sequence { + return getBaseDirectories(context) + // Get all the files inside all baseDir + .flatMap { it.listFiles().orEmpty().toList() } + } + + private fun getMangaDir(mangaUrl: String, baseDirsFile: Sequence): File? { + return baseDirsFile + // Get the first mangaDir or null + .firstOrNull { it.isDirectory && it.name == mangaUrl } + } + + private fun getMangaDirsFiles(mangaUrl: String, baseDirsFile: Sequence): Sequence { + return baseDirsFile + // Filter out ones that are not related to the manga and is not a directory + .filter { it.isDirectory && it.name == mangaUrl } + // Get all the files inside the filtered folders + .flatMap { it.listFiles().orEmpty().toList() } + } + + private fun getCoverFile(mangaUrl: String, baseDirsFile: Sequence): File? { + return getMangaDirsFiles(mangaUrl, baseDirsFile) + // Get all file whose names start with 'cover' + .filter { it.isFile && it.nameWithoutExtension.equals("cover", ignoreCase = true) } + // Get the first actual image + .firstOrNull { + ImageUtil.isImage(it.name) { it.inputStream() } + } + } + + fun updateCover(context: Context, manga: SManga, inputStream: InputStream): File? { + val baseDirsFiles = getBaseDirectoriesFiles(context) + + val mangaDir = getMangaDir(manga.url, baseDirsFiles) + if (mangaDir == null) { + inputStream.close() + return null + } + + var coverFile = getCoverFile(manga.url, baseDirsFiles) + if (coverFile == null) { + coverFile = File(mangaDir.absolutePath, DEFAULT_COVER_NAME) + + } + + // It might not exist at this point + coverFile.parentFile?.mkdirs() + inputStream.use { input -> + coverFile.outputStream().use { output -> + input.copyTo(output) + } + } + + // Create a .nomedia file + DiskUtil.createNoMediaFile(UniFile.fromFile(mangaDir), context) + + manga.thumbnail_url = coverFile.absolutePath + return coverFile + } + } } private val SUPPORTED_ARCHIVE_TYPES = listOf("zip", "cbz", "rar", "cbr", "epub") diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderPresenter.kt index 7a66b2fcba..a68a840eea 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderPresenter.kt @@ -4,7 +4,6 @@ import android.app.Application import android.net.Uri import android.os.Bundle import com.jakewharton.rxrelay.BehaviorRelay -import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.cache.CoverCache import eu.kanade.tachiyomi.data.database.DatabaseHelper import eu.kanade.tachiyomi.data.database.models.History @@ -675,20 +674,22 @@ class ReaderPresenter( Observable .fromCallable { - if (manga.isLocal()) { - val context = Injekt.get() - LocalSource.updateCover(context, manga, stream()) - manga.updateCoverLastModified(db) - R.string.cover_updated - SetAsCoverResult.Success - } else { - if (manga.favorite) { - coverCache.setCustomCoverToCache(manga, stream()) + stream().use { + if (manga.isLocal()) { + val context = Injekt.get() + LocalSource.updateCover(context, manga, it) manga.updateCoverLastModified(db) coverCache.clearMemoryCache() SetAsCoverResult.Success } else { - SetAsCoverResult.AddToLibraryFirst + if (manga.favorite) { + coverCache.setCustomCoverToCache(manga, it) + manga.updateCoverLastModified(db) + coverCache.clearMemoryCache() + SetAsCoverResult.Success + } else { + SetAsCoverResult.AddToLibraryFirst + } } } }