diff --git a/app/build.gradle b/app/build.gradle index 44d2a9aab..ba2d9d769 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -15,8 +15,8 @@ android { applicationId 'org.koitharu.kotatsu' minSdkVersion 21 targetSdkVersion 33 - versionCode 521 - versionName '4.4.5' + versionCode 530 + versionName '5.0-a1' generatedDensities = [] testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" diff --git a/app/src/main/java/org/koitharu/kotatsu/base/domain/MangaDataRepository.kt b/app/src/main/java/org/koitharu/kotatsu/base/domain/MangaDataRepository.kt index c1d2a0af4..2c9d78960 100644 --- a/app/src/main/java/org/koitharu/kotatsu/base/domain/MangaDataRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/base/domain/MangaDataRepository.kt @@ -128,7 +128,7 @@ class MangaDataRepository @Inject constructor( .url(url) .get() .tag(MangaSource::class.java, page.source) - .cacheControl(CommonHeaders.CACHE_CONTROL_DISABLED) + .cacheControl(CommonHeaders.CACHE_CONTROL_NO_STORE) .build() okHttpClient.newCall(request).await().use { runInterruptible(Dispatchers.IO) { diff --git a/app/src/main/java/org/koitharu/kotatsu/core/network/CommonHeaders.kt b/app/src/main/java/org/koitharu/kotatsu/core/network/CommonHeaders.kt index b7608a9c8..943e08f2e 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/network/CommonHeaders.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/network/CommonHeaders.kt @@ -14,6 +14,6 @@ object CommonHeaders { const val ACCEPT_ENCODING = "Accept-Encoding" const val AUTHORIZATION = "Authorization" - val CACHE_CONTROL_DISABLED: CacheControl + val CACHE_CONTROL_NO_STORE: CacheControl get() = CacheControl.Builder().noStore().build() } diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt index 19b807a4d..ffe2bbd72 100644 --- a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt @@ -187,7 +187,7 @@ class DetailsViewModel @Inject constructor( return } launchLoadingJob(Dispatchers.Default) { - val manga = if (m.source == MangaSource.LOCAL) m else localMangaRepository.findSavedManga(m) + val manga = if (m.source == MangaSource.LOCAL) m else localMangaRepository.findSavedManga(m)?.manga checkNotNull(manga) { "Cannot find saved manga for ${m.title}" } val original = localMangaRepository.getRemoteManga(manga) localMangaRepository.delete(manga) || throw IOException("Unable to delete file") diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/MangaDetailsDelegate.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/MangaDetailsDelegate.kt index 4130a98be..b0072f4a8 100644 --- a/app/src/main/java/org/koitharu/kotatsu/details/ui/MangaDetailsDelegate.kt +++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/MangaDetailsDelegate.kt @@ -53,7 +53,7 @@ class MangaDetailsDelegate @Inject constructor( val m = localMangaRepository.getRemoteManga(manga) ?: return@runCatchingCancellable null mangaRepositoryFactory.create(m.source).getDetails(m) } else { - localMangaRepository.findSavedManga(manga) + localMangaRepository.findSavedManga(manga)?.manga } }.onFailure { error -> error.printStackTraceDebug() diff --git a/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt b/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt index ba7e6b445..5f39a198f 100644 --- a/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt +++ b/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt @@ -8,7 +8,6 @@ import androidx.lifecycle.lifecycleScope import coil.ImageLoader import coil.request.ImageRequest import coil.size.Scale -import dagger.hilt.android.lifecycle.RetainedLifecycle import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.scopes.ServiceScoped import kotlinx.coroutines.CancellationException @@ -31,12 +30,12 @@ import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.download.ui.service.PausingHandle import org.koitharu.kotatsu.local.data.PagesCache -import org.koitharu.kotatsu.local.domain.CbzMangaOutput +import org.koitharu.kotatsu.local.data.input.LocalMangaInput +import org.koitharu.kotatsu.local.data.output.LocalMangaOutput import org.koitharu.kotatsu.local.domain.LocalMangaRepository import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.util.await -import org.koitharu.kotatsu.utils.RetainedLifecycleCoroutineScope import org.koitharu.kotatsu.utils.ext.copyToSuspending import org.koitharu.kotatsu.utils.ext.deleteAwait import org.koitharu.kotatsu.utils.ext.printStackTraceDebug @@ -111,7 +110,7 @@ class DownloadManager @Inject constructor( val destination = localMangaRepository.getOutputDir() checkNotNull(destination) { context.getString(R.string.cannot_find_available_storage) } val tempFileName = "${manga.id}_$startId.tmp" - var output: CbzMangaOutput? = null + var output: LocalMangaOutput? = null try { if (manga.source == MangaSource.LOCAL) { manga = localMangaRepository.getRemoteManga(manga) @@ -120,9 +119,9 @@ class DownloadManager @Inject constructor( val repo = mangaRepositoryFactory.create(manga.source) outState.value = DownloadState.Preparing(startId, manga, cover) val data = if (manga.chapters.isNullOrEmpty()) repo.getDetails(manga) else manga - output = CbzMangaOutput.get(destination, data) + output = LocalMangaOutput.getOrCreate(destination, data) val coverUrl = data.largeCoverUrl ?: data.coverUrl - downloadFile(coverUrl, data.publicUrl, destination, tempFileName, repo.source).let { file -> + downloadFile(coverUrl, destination, tempFileName, repo.source).let { file -> output.addCover(file, MimeTypeMap.getFileExtensionFromUrl(coverUrl)) } val chapters = checkNotNull( @@ -144,7 +143,7 @@ class DownloadManager @Inject constructor( runFailsafe(outState, pausingHandle) { val url = repo.getPageUrl(page) val file = cache.get(url) - ?: downloadFile(url, page.referer, destination, tempFileName, repo.source) + ?: downloadFile(url, destination, tempFileName, repo.source) output.addPage( chapter = chapter, file = file, @@ -166,11 +165,12 @@ class DownloadManager @Inject constructor( delay(SLOWDOWN_DELAY) } } + output.flushChapter(chapter) } outState.value = DownloadState.PostProcessing(startId, data, cover) output.mergeWithExisting() output.finish() - val localManga = localMangaRepository.getFromFile(output.file) + val localManga = LocalMangaInput.of(output.rootFile).getManga().manga outState.value = DownloadState.Done(startId, data, cover, localManga) } catch (e: CancellationException) { outState.value = DownloadState.Cancelled(startId, manga, cover) @@ -216,16 +216,14 @@ class DownloadManager @Inject constructor( private suspend fun downloadFile( url: String, - referer: String, destination: File, tempFileName: String, source: MangaSource, ): File { val request = Request.Builder() .url(url) - .header(CommonHeaders.REFERER, referer) .tag(MangaSource::class.java, source) - .cacheControl(CommonHeaders.CACHE_CONTROL_DISABLED) + .cacheControl(CommonHeaders.CACHE_CONTROL_NO_STORE) .get() .build() val call = okHttp.newCall(request) diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/CbzFetcher.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/CbzFetcher.kt index 3db7b7038..ecc5791b1 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/data/CbzFetcher.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/data/CbzFetcher.kt @@ -12,6 +12,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runInterruptible import okio.buffer import okio.source +import org.koitharu.kotatsu.local.data.util.withExtraCloseable import java.util.zip.ZipFile class CbzFetcher( diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/CbzFilter.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/CbzFilter.kt index 5fafe31a1..f74d30258 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/data/CbzFilter.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/data/CbzFilter.kt @@ -1,15 +1,20 @@ package org.koitharu.kotatsu.local.data import java.io.File +import java.io.FileFilter import java.io.FilenameFilter -import java.util.* +import java.util.Locale -class CbzFilter : FilenameFilter { +class CbzFilter : FileFilter, FilenameFilter { override fun accept(dir: File, name: String): Boolean { return isFileSupported(name) } + override fun accept(pathname: File?): Boolean { + return isFileSupported(pathname?.name ?: return false) + } + companion object { fun isFileSupported(name: String): Boolean { diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/FlowFileObserver.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/FlowFileObserver.kt deleted file mode 100644 index a5bec126f..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/local/data/FlowFileObserver.kt +++ /dev/null @@ -1,27 +0,0 @@ -package org.koitharu.kotatsu.local.data - -import android.os.FileObserver -import java.io.File -import kotlinx.coroutines.channels.ProducerScope -import kotlinx.coroutines.channels.awaitClose -import kotlinx.coroutines.channels.trySendBlocking -import kotlinx.coroutines.flow.callbackFlow - -@Suppress("DEPRECATION") -class FlowFileObserver( - private val producerScope: ProducerScope, - private val file: File, -) : FileObserver(file.absolutePath, CREATE or DELETE or CLOSE_WRITE) { - - override fun onEvent(event: Int, path: String?) { - producerScope.trySendBlocking( - if (path == null) file else file.resolve(path), - ) - } -} - -fun File.observe() = callbackFlow { - val observer = FlowFileObserver(this, this@observe) - observer.startWatching() - awaitClose { observer.stopWatching() } -} diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/ImageFileFilter.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/ImageFileFilter.kt new file mode 100644 index 000000000..29b946b0b --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/local/data/ImageFileFilter.kt @@ -0,0 +1,29 @@ +package org.koitharu.kotatsu.local.data + +import java.io.File +import java.io.FileFilter +import java.io.FilenameFilter +import java.util.Locale +import java.util.zip.ZipEntry + +class ImageFileFilter : FilenameFilter, FileFilter { + + override fun accept(dir: File, name: String): Boolean { + val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT) + return isExtensionValid(ext) + } + + override fun accept(pathname: File?): Boolean { + val ext = pathname?.extension?.lowercase(Locale.ROOT) ?: return false + return isExtensionValid(ext) + } + + fun accept(entry: ZipEntry): Boolean { + val ext = entry.name.substringAfterLast('.', "").lowercase(Locale.ROOT) + return isExtensionValid(ext) + } + + private fun isExtensionValid(ext: String): Boolean { + return ext == "png" || ext == "jpg" || ext == "jpeg" || ext == "webp" + } +} diff --git a/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalManga.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/LocalManga.kt similarity index 62% rename from app/src/main/java/org/koitharu/kotatsu/local/domain/LocalManga.kt rename to app/src/main/java/org/koitharu/kotatsu/local/data/LocalManga.kt index 722a70411..a59f041a4 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalManga.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/data/LocalManga.kt @@ -1,15 +1,16 @@ -package org.koitharu.kotatsu.local.domain +package org.koitharu.kotatsu.local.data -import java.io.File import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaTag +import java.io.File class LocalManga( - val manga: Manga, val file: File, + val manga: Manga, ) { var createdAt: Long = -1L + private set get() { if (field == -1L) { field = file.lastModified() @@ -17,6 +18,15 @@ class LocalManga( return field } + fun isMatchesQuery(query: String): Boolean { + return manga.title.contains(query, ignoreCase = true) || + manga.altTitle?.contains(query, ignoreCase = true) == true + } + + fun containsTags(tags: Set): Boolean { + return manga.tags.containsAll(tags) + } + override fun equals(other: Any?): Boolean { if (this === other) return true if (javaClass != other?.javaClass) return false @@ -34,15 +44,8 @@ class LocalManga( result = 31 * result + file.hashCode() return result } -} -fun Collection.unwrap(): List = map { it.manga } - -fun LocalManga.isMatchesQuery(query: String): Boolean { - return manga.title.contains(query, ignoreCase = true) || - manga.altTitle?.contains(query, ignoreCase = true) == true -} - -fun LocalManga.containsTags(tags: Set): Boolean { - return manga.tags.containsAll(tags) + override fun toString(): String { + return "LocalManga(${file.path}: ${manga.title})" + } } diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/LocalStorageManager.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/LocalStorageManager.kt index 5c138869c..b473aa6bb 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/data/LocalStorageManager.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/data/LocalStorageManager.kt @@ -5,9 +5,6 @@ import android.content.Context import android.os.StatFs import androidx.annotation.WorkerThread import dagger.hilt.android.qualifiers.ApplicationContext -import java.io.File -import javax.inject.Inject -import javax.inject.Singleton import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.asFlow @@ -17,9 +14,13 @@ import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.withContext import okhttp3.Cache import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.local.data.util.observe import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.utils.ext.computeSize import org.koitharu.kotatsu.utils.ext.getStorageName +import java.io.File +import javax.inject.Inject +import javax.inject.Singleton private const val DIR_NAME = "manga" private const val CACHE_DISK_PERCENTAGE = 0.02 diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/MangaIndex.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/MangaIndex.kt index ee570e826..abcac17fe 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/data/MangaIndex.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/data/MangaIndex.kt @@ -1,15 +1,21 @@ package org.koitharu.kotatsu.local.data +import androidx.annotation.WorkerThread import org.json.JSONArray import org.json.JSONObject import org.koitharu.kotatsu.BuildConfig -import org.koitharu.kotatsu.parsers.model.* +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaChapter +import org.koitharu.kotatsu.parsers.model.MangaSource +import org.koitharu.kotatsu.parsers.model.MangaState +import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.util.json.getBooleanOrDefault import org.koitharu.kotatsu.parsers.util.json.getLongOrDefault import org.koitharu.kotatsu.parsers.util.json.getStringOrNull import org.koitharu.kotatsu.parsers.util.json.mapJSONToSet import org.koitharu.kotatsu.parsers.util.toTitleCase import org.koitharu.kotatsu.utils.AlphanumComparator +import java.io.File class MangaIndex(source: String?) { @@ -151,4 +157,18 @@ class MangaIndex(source: String?) { } else { json.toString() } + + companion object { + + @WorkerThread + fun read(file: File): MangaIndex? { + if (file.exists() && file.canRead()) { + val text = file.readText() + if (text.length > 2) { + return MangaIndex(text) + } + } + return null + } + } } diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/input/LocalMangaDirInput.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/input/LocalMangaDirInput.kt new file mode 100644 index 000000000..79b98be28 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/local/data/input/LocalMangaDirInput.kt @@ -0,0 +1,146 @@ +package org.koitharu.kotatsu.local.data.input + +import androidx.core.net.toFile +import androidx.core.net.toUri +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runInterruptible +import org.koitharu.kotatsu.local.data.CbzFilter +import org.koitharu.kotatsu.local.data.ImageFileFilter +import org.koitharu.kotatsu.local.data.LocalManga +import org.koitharu.kotatsu.local.data.MangaIndex +import org.koitharu.kotatsu.local.data.output.LocalMangaOutput +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaChapter +import org.koitharu.kotatsu.parsers.model.MangaPage +import org.koitharu.kotatsu.parsers.model.MangaSource +import org.koitharu.kotatsu.parsers.util.toCamelCase +import org.koitharu.kotatsu.utils.AlphanumComparator +import org.koitharu.kotatsu.utils.ext.listFilesRecursive +import org.koitharu.kotatsu.utils.ext.longHashCode +import org.koitharu.kotatsu.utils.ext.toListSorted +import java.io.File +import java.util.zip.ZipFile + +/** + * Manga {Folder} + * |--- index.json (optional) + * |--- Chapter 1.cbz + * |--- Page 1.png + * : + * L--- Page x.png + * |--- Chapter 2.cbz + * : + * L--- Chapter x.cbz + */ +class LocalMangaDirInput(root: File) : LocalMangaInput(root) { + + override suspend fun getManga(): LocalManga = runInterruptible(Dispatchers.IO) { + val index = MangaIndex.read(File(root, LocalMangaOutput.ENTRY_NAME_INDEX)) + val mangaUri = root.toUri().toString() + val chapterFiles = getChaptersFiles() + val info = index?.getMangaInfo() + val manga = info?.copy2( + source = MangaSource.LOCAL, + url = mangaUri, + coverUrl = fileUri( + root, + index.getCoverEntry() ?: findFirstImageEntry().orEmpty(), + ), + chapters = info.chapters?.mapIndexed { i, c -> + c.copy(url = chapterFiles[i].toUri().toString(), source = MangaSource.LOCAL) + }, + ) ?: Manga( + id = root.absolutePath.longHashCode(), + title = root.name.toHumanReadable(), + url = mangaUri, + publicUrl = mangaUri, + source = MangaSource.LOCAL, + coverUrl = findFirstImageEntry().orEmpty(), + chapters = chapterFiles.mapIndexed { i, f -> + MangaChapter( + id = "$i${f.name}".longHashCode(), + name = f.nameWithoutExtension.toHumanReadable(), + number = i + 1, + source = MangaSource.LOCAL, + uploadDate = f.lastModified(), + url = f.toUri().toString(), + scanlator = null, + branch = null, + ) + }, + altTitle = null, + rating = -1f, + isNsfw = false, + tags = setOf(), + state = null, + author = null, + largeCoverUrl = null, + description = null, + ) + LocalManga(root, manga) + } + + override suspend fun getMangaInfo(): Manga? = runInterruptible(Dispatchers.IO) { + val index = MangaIndex.read(File(root, LocalMangaOutput.ENTRY_NAME_INDEX)) + index?.getMangaInfo() + } + + override suspend fun getPages(chapter: MangaChapter): List = runInterruptible(Dispatchers.IO) { + val file = chapter.url.toUri().toFile() + if (file.isDirectory) { + file.listFilesRecursive(ImageFileFilter()) + .toListSorted(compareBy(AlphanumComparator()) { x -> x.name }) + .map { + val pageUri = it.toUri().toString() + MangaPage( + id = pageUri.longHashCode(), + url = pageUri, + preview = null, + referer = chapter.url, + source = MangaSource.LOCAL, + ) + } + } else { + ZipFile(file).use { zip -> + zip.entries() + .asSequence() + .filter { x -> !x.isDirectory } + .map { it.name } + .toListSorted(AlphanumComparator()) + .map { + val pageUri = zipUri(file, it) + MangaPage( + id = pageUri.longHashCode(), + url = pageUri, + preview = null, + referer = chapter.url, + source = MangaSource.LOCAL, + ) + } + } + } + } + + private fun String.toHumanReadable() = replace("_", " ").toCamelCase() + + private fun getChaptersFiles(): List = root.listFilesRecursive(CbzFilter()) + .toListSorted(compareBy(AlphanumComparator()) { x -> x.name }) + + private fun findFirstImageEntry(): String? { + val filter = ImageFileFilter() + root.listFilesRecursive(filter).firstOrNull()?.let { + return it.toUri().toString() + } + val cbz = root.listFilesRecursive(CbzFilter()).firstOrNull() ?: return null + return ZipFile(cbz).use { zip -> + val filter = ImageFileFilter() + zip.entries().asSequence() + .firstOrNull { x -> !x.isDirectory && filter.accept(x) } + ?.let { entry -> zipUri(cbz, entry.name) } + } + } + + private fun fileUri(base: File, name: String): String { + return File(base, name).toUri().toString() + } +} diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/input/LocalMangaInput.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/input/LocalMangaInput.kt new file mode 100644 index 000000000..7fc903a13 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/local/data/input/LocalMangaInput.kt @@ -0,0 +1,75 @@ +package org.koitharu.kotatsu.local.data.input + +import android.net.Uri +import androidx.core.net.toFile +import org.koitharu.kotatsu.local.data.LocalManga +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaChapter +import org.koitharu.kotatsu.parsers.model.MangaPage +import org.koitharu.kotatsu.parsers.model.MangaSource +import java.io.File + +abstract class LocalMangaInput( + protected val root: File, +) { + + abstract suspend fun getManga(): LocalManga + + abstract suspend fun getMangaInfo(): Manga? + + abstract suspend fun getPages(chapter: MangaChapter): List + + companion object { + + fun of(manga: Manga): LocalMangaInput = of(Uri.parse(manga.url).toFile()) + + fun of(chapter: MangaChapter): LocalMangaInput = of(Uri.parse(chapter.url).toFile()) + + fun of(file: File): LocalMangaInput = when { + file.isDirectory -> LocalMangaDirInput(file) + else -> LocalMangaZipInput(file) + } + + @JvmStatic + protected fun zipUri(file: File, entryName: String) = "cbz://${file.path}#$entryName" + + @JvmStatic + protected fun Manga.copy2( + url: String = this.url, + coverUrl: String = this.coverUrl, + chapters: List? = this.chapters, + source: MangaSource = this.source, + ) = Manga( + id = id, + title = title, + altTitle = altTitle, + url = url, + publicUrl = publicUrl, + rating = rating, + isNsfw = isNsfw, + coverUrl = coverUrl, + tags = tags, + state = state, + author = author, + largeCoverUrl = largeCoverUrl, + description = description, + chapters = chapters, + source = source, + ) + + @JvmStatic + protected fun MangaChapter.copy( + url: String = this.url, + source: MangaSource = this.source, + ) = MangaChapter( + id = id, + name = name, + number = number, + url = url, + scanlator = scanlator, + uploadDate = uploadDate, + branch = branch, + source = source, + ) + } +} diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/input/LocalMangaZipInput.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/input/LocalMangaZipInput.kt new file mode 100644 index 000000000..9f79bb3ba --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/local/data/input/LocalMangaZipInput.kt @@ -0,0 +1,152 @@ +package org.koitharu.kotatsu.local.data.input + +import android.net.Uri +import android.webkit.MimeTypeMap +import androidx.collection.ArraySet +import androidx.core.net.toFile +import androidx.core.net.toUri +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runInterruptible +import org.koitharu.kotatsu.local.data.LocalManga +import org.koitharu.kotatsu.local.data.MangaIndex +import org.koitharu.kotatsu.local.data.output.LocalMangaOutput +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaChapter +import org.koitharu.kotatsu.parsers.model.MangaPage +import org.koitharu.kotatsu.parsers.model.MangaSource +import org.koitharu.kotatsu.parsers.util.toCamelCase +import org.koitharu.kotatsu.utils.AlphanumComparator +import org.koitharu.kotatsu.utils.ext.longHashCode +import org.koitharu.kotatsu.utils.ext.readText +import org.koitharu.kotatsu.utils.ext.toListSorted +import java.io.File +import java.util.Enumeration +import java.util.zip.ZipEntry +import java.util.zip.ZipFile + +/** + * Manga archive {.cbz or .zip file} + * |--- index.json (optional) + * |--- Page 1.png + * |--- Page 2.png + * : + * L--- Page x.png + */ +class LocalMangaZipInput(root: File) : LocalMangaInput(root) { + + override suspend fun getManga(): LocalManga { + val manga = runInterruptible(Dispatchers.IO) { + ZipFile(root).use { zip -> + val fileUri = root.toUri().toString() + val entry = zip.getEntry(LocalMangaOutput.ENTRY_NAME_INDEX) + val index = entry?.let(zip::readText)?.let(::MangaIndex) + val info = index?.getMangaInfo() + if (info != null) { + return@use info.copy2( + source = MangaSource.LOCAL, + url = fileUri, + coverUrl = zipUri( + root, + entryName = index.getCoverEntry() + ?: findFirstImageEntry(zip.entries())?.name.orEmpty(), + ), + chapters = info.chapters?.map { c -> + c.copy(url = fileUri, source = MangaSource.LOCAL) + }, + ) + } + // fallback + val title = root.nameWithoutExtension.replace("_", " ").toCamelCase() + val chapters = ArraySet() + for (x in zip.entries()) { + if (!x.isDirectory) { + chapters += x.name.substringBeforeLast(File.separatorChar, "") + } + } + val uriBuilder = root.toUri().buildUpon() + Manga( + id = root.absolutePath.longHashCode(), + title = title, + url = fileUri, + publicUrl = fileUri, + source = MangaSource.LOCAL, + coverUrl = zipUri(root, findFirstImageEntry(zip.entries())?.name.orEmpty()), + chapters = chapters.sortedWith(AlphanumComparator()).mapIndexed { i, s -> + MangaChapter( + id = "$i$s".longHashCode(), + name = s.ifEmpty { title }, + number = i + 1, + source = MangaSource.LOCAL, + uploadDate = 0L, + url = uriBuilder.fragment(s).build().toString(), + scanlator = null, + branch = null, + ) + }, + altTitle = null, + rating = -1f, + isNsfw = false, + tags = setOf(), + state = null, + author = null, + largeCoverUrl = null, + description = null, + ) + } + } + return LocalManga(root, manga) + } + + override suspend fun getMangaInfo(): Manga? = runInterruptible(Dispatchers.IO) { + ZipFile(root).use { zip -> + val entry = zip.getEntry(LocalMangaOutput.ENTRY_NAME_INDEX) + val index = entry?.let(zip::readText)?.let(::MangaIndex) + index?.getMangaInfo() + } + } + + override suspend fun getPages(chapter: MangaChapter): List { + return runInterruptible(Dispatchers.IO) { + val uri = Uri.parse(chapter.url) + val file = uri.toFile() + val zip = ZipFile(file) + val index = zip.getEntry(LocalMangaOutput.ENTRY_NAME_INDEX)?.let(zip::readText)?.let(::MangaIndex) + var entries = zip.entries().asSequence() + entries = if (index != null) { + val pattern = index.getChapterNamesPattern(chapter) + entries.filter { x -> !x.isDirectory && x.name.substringBefore('.').matches(pattern) } + } else { + val parent = uri.fragment.orEmpty() + entries.filter { x -> + !x.isDirectory && x.name.substringBeforeLast( + File.separatorChar, + "", + ) == parent + } + } + entries + .toListSorted(compareBy(AlphanumComparator()) { x -> x.name }) + .map { x -> + val entryUri = zipUri(file, x.name) + MangaPage( + id = entryUri.longHashCode(), + url = entryUri, + preview = null, + referer = chapter.url, + source = MangaSource.LOCAL, + ) + } + } + } + + private fun findFirstImageEntry(entries: Enumeration): ZipEntry? { + val list = entries.toList() + .filterNot { it.isDirectory } + .sortedWith(compareBy(AlphanumComparator()) { x -> x.name }) + val map = MimeTypeMap.getSingleton() + return list.firstOrNull { + map.getMimeTypeFromExtension(it.name.substringAfterLast('.')) + ?.startsWith("image/") == true + } + } +} diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/output/LocalMangaDirOutput.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/output/LocalMangaDirOutput.kt new file mode 100644 index 000000000..04ddd7f10 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/local/data/output/LocalMangaDirOutput.kt @@ -0,0 +1,114 @@ +package org.koitharu.kotatsu.local.data.output + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runInterruptible +import org.koitharu.kotatsu.core.zip.ZipOutput +import org.koitharu.kotatsu.local.data.MangaIndex +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaChapter +import org.koitharu.kotatsu.parsers.util.toFileNameSafe +import org.koitharu.kotatsu.utils.ext.deleteAwait +import org.koitharu.kotatsu.utils.ext.takeIfReadable +import java.io.File + +class LocalMangaDirOutput( + rootFile: File, + manga: Manga, +) : LocalMangaOutput(rootFile) { + + private val chaptersOutput = HashMap() + private val index = MangaIndex(File(rootFile, ENTRY_NAME_INDEX).takeIfReadable()?.readText()) + + init { + index.setMangaInfo(manga, append = true) + } + + override suspend fun mergeWithExisting() = Unit + + override suspend fun addCover(file: File, ext: String) { + val name = buildString { + append("cover") + if (ext.isNotEmpty() && ext.length <= 4) { + append('.') + append(ext) + } + } + runInterruptible(Dispatchers.IO) { + file.copyTo(File(rootFile, name)) + } + index.setCoverEntry(name) + } + + override suspend fun addPage(chapter: MangaChapter, file: File, pageNumber: Int, ext: String) { + val output = chaptersOutput.getOrPut(chapter) { + ZipOutput(File(rootFile, chapterFileName(chapter) + SUFFIX_TMP)) + } + val name = buildString { + append(FILENAME_PATTERN.format(chapter.branch.hashCode(), chapter.number, pageNumber)) + if (ext.isNotEmpty() && ext.length <= 4) { + append('.') + append(ext) + } + } + runInterruptible(Dispatchers.IO) { + output.put(name, file) + } + index.addChapter(chapter) + } + + override suspend fun flushChapter(chapter: MangaChapter) { + val output = chaptersOutput.remove(chapter) ?: return + output.flushAndFinish() + } + + override suspend fun finish() { + runInterruptible(Dispatchers.IO) { + File(rootFile, ENTRY_NAME_INDEX).writeText(index.toString()) + } + for (output in chaptersOutput.values) { + output.flushAndFinish() + } + chaptersOutput.clear() + } + + override suspend fun cleanup() { + for (output in chaptersOutput.values) { + output.file.deleteAwait() + } + } + + override fun close() { + for (output in chaptersOutput.values) { + output.close() + } + } + + override fun sortChaptersByName() { + index.sortChaptersByName() + } + + suspend fun deleteChapter(chapterId: Long) { + val chapter = checkNotNull(index.getMangaInfo()?.chapters) { + "No chapters found" + }.first { it.id == chapterId } + val chapterDir = File(rootFile, chapterFileName(chapter)) + chapterDir.deleteAwait() + index.removeChapter(chapterId) + } + + private suspend fun ZipOutput.flushAndFinish() = runInterruptible(Dispatchers.IO) { + finish() + close() + val resFile = File(file.absolutePath.removeSuffix(SUFFIX_TMP)) + file.renameTo(resFile) + } + + private fun chapterFileName(chapter: MangaChapter): String { + return "${chapter.number}_${chapter.name.toFileNameSafe()}".take(18) + ".cbz" + } + + companion object { + + private const val FILENAME_PATTERN = "%08d_%03d%03d" + } +} diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/output/LocalMangaOutput.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/output/LocalMangaOutput.kt new file mode 100644 index 000000000..272687b5e --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/local/data/output/LocalMangaOutput.kt @@ -0,0 +1,58 @@ +package org.koitharu.kotatsu.local.data.output + +import okio.Closeable +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaChapter +import org.koitharu.kotatsu.parsers.util.toFileNameSafe +import java.io.File + +abstract class LocalMangaOutput( + val rootFile: File, +) : Closeable { + + abstract suspend fun mergeWithExisting() + + abstract suspend fun addCover(file: File, ext: String) + + abstract suspend fun addPage(chapter: MangaChapter, file: File, pageNumber: Int, ext: String) + + abstract suspend fun flushChapter(chapter: MangaChapter) + + abstract suspend fun finish() + + abstract suspend fun cleanup() + + abstract fun sortChaptersByName() + + companion object { + + const val ENTRY_NAME_INDEX = "index.json" + const val SUFFIX_TMP = ".tmp" + + fun getOrCreate(root: File, manga: Manga): LocalMangaOutput { + return checkNotNull(getImpl(root, manga, onlyIfExists = false)) + } + + fun get(root: File, manga: Manga): LocalMangaOutput? { + return getImpl(root, manga, onlyIfExists = true) + } + + private fun getImpl(root: File, manga: Manga, onlyIfExists: Boolean): LocalMangaOutput? { + val name = manga.title.toFileNameSafe() + val file = File(root, name) + return if (file.exists()) { + if (file.isDirectory) { + LocalMangaDirOutput(file, manga) + } else { + LocalMangaZipOutput(file, manga) + } + } else { + if (onlyIfExists) { + null + } else { + LocalMangaDirOutput(file, manga) + } + } + } + } +} diff --git a/app/src/main/java/org/koitharu/kotatsu/local/domain/CbzMangaOutput.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/output/LocalMangaZipOutput.kt similarity index 74% rename from app/src/main/java/org/koitharu/kotatsu/local/domain/CbzMangaOutput.kt rename to app/src/main/java/org/koitharu/kotatsu/local/data/output/LocalMangaZipOutput.kt index c96d58817..3ef46c52a 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/domain/CbzMangaOutput.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/data/output/LocalMangaZipOutput.kt @@ -1,40 +1,38 @@ -package org.koitharu.kotatsu.local.domain +package org.koitharu.kotatsu.local.data.output import androidx.annotation.WorkerThread -import java.io.File -import java.util.zip.ZipFile import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runInterruptible -import okio.Closeable import org.koitharu.kotatsu.core.zip.ZipOutput import org.koitharu.kotatsu.local.data.MangaIndex import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaChapter -import org.koitharu.kotatsu.parsers.util.toFileNameSafe import org.koitharu.kotatsu.utils.ext.deleteAwait import org.koitharu.kotatsu.utils.ext.readText +import java.io.File +import java.util.zip.ZipFile -class CbzMangaOutput( - val file: File, +class LocalMangaZipOutput( + rootFile: File, manga: Manga, -) : Closeable { +) : LocalMangaOutput(rootFile) { - private val output = ZipOutput(File(file.path + ".tmp")) + private val output = ZipOutput(File(rootFile.path + ".tmp")) private val index = MangaIndex(null) init { index.setMangaInfo(manga, false) } - suspend fun mergeWithExisting() { - if (file.exists()) { + override suspend fun mergeWithExisting() { + if (rootFile.exists()) { runInterruptible(Dispatchers.IO) { - mergeWith(file) + mergeWith(rootFile) } } } - suspend fun addCover(file: File, ext: String) { + override suspend fun addCover(file: File, ext: String) { val name = buildString { append(FILENAME_PATTERN.format(0, 0, 0)) if (ext.isNotEmpty() && ext.length <= 4) { @@ -48,7 +46,7 @@ class CbzMangaOutput( index.setCoverEntry(name) } - suspend fun addPage(chapter: MangaChapter, file: File, pageNumber: Int, ext: String) { + override suspend fun addPage(chapter: MangaChapter, file: File, pageNumber: Int, ext: String) { val name = buildString { append(FILENAME_PATTERN.format(chapter.branch.hashCode(), chapter.number, pageNumber)) if (ext.isNotEmpty() && ext.length <= 4) { @@ -62,17 +60,19 @@ class CbzMangaOutput( index.addChapter(chapter) } - suspend fun finish() { + override suspend fun flushChapter(chapter: MangaChapter) = Unit + + override suspend fun finish() { runInterruptible(Dispatchers.IO) { output.put(ENTRY_NAME_INDEX, index.toString()) output.finish() output.close() } - file.deleteAwait() - output.file.renameTo(file) + rootFile.deleteAwait() + output.file.renameTo(rootFile) } - suspend fun cleanup() { + override suspend fun cleanup() { output.file.deleteAwait() } @@ -80,7 +80,7 @@ class CbzMangaOutput( output.close() } - fun sortChaptersByName() { + override fun sortChaptersByName() { index.sortChaptersByName() } @@ -111,17 +111,9 @@ class CbzMangaOutput( private const val FILENAME_PATTERN = "%08d_%03d%03d" - const val ENTRY_NAME_INDEX = "index.json" - - fun get(root: File, manga: Manga): CbzMangaOutput { - val name = manga.title.toFileNameSafe() + ".cbz" - val file = File(root, name) - return CbzMangaOutput(file, manga) - } - @WorkerThread - fun filterChapters(subject: CbzMangaOutput, idsToRemove: Set) { - ZipFile(subject.file).use { zip -> + fun filterChapters(subject: LocalMangaZipOutput, idsToRemove: Set) { + ZipFile(subject.rootFile).use { zip -> val index = MangaIndex(zip.readText(zip.getEntry(ENTRY_NAME_INDEX))) idsToRemove.forEach { id -> index.removeChapter(id) } val patterns = requireNotNull(index.getMangaInfo()?.chapters).map { @@ -133,12 +125,15 @@ class CbzMangaOutput( entry.name == ENTRY_NAME_INDEX -> { subject.output.put(ENTRY_NAME_INDEX, index.toString()) } + entry.isDirectory -> { subject.output.addDirectory(entry.name) } + entry.name == coverEntryName -> { subject.output.copyEntryFrom(zip, entry) } + else -> { val name = entry.name.substringBefore('.') if (patterns.any { it.matches(name) }) { @@ -149,8 +144,8 @@ class CbzMangaOutput( } subject.output.finish() subject.output.close() - subject.file.delete() - subject.output.file.renameTo(subject.file) + subject.rootFile.delete() + subject.output.file.renameTo(subject.rootFile) } } } diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/ExtraCloseableSource.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/util/ExtraCloseableSource.kt similarity index 90% rename from app/src/main/java/org/koitharu/kotatsu/local/data/ExtraCloseableSource.kt rename to app/src/main/java/org/koitharu/kotatsu/local/data/util/ExtraCloseableSource.kt index 342c6bac8..b83867e5c 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/data/ExtraCloseableSource.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/data/util/ExtraCloseableSource.kt @@ -1,4 +1,4 @@ -package org.koitharu.kotatsu.local.data +package org.koitharu.kotatsu.local.data.util import okhttp3.internal.closeQuietly import okio.Closeable diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/util/FlowFileObserver.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/util/FlowFileObserver.kt new file mode 100644 index 000000000..c167b87c1 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/local/data/util/FlowFileObserver.kt @@ -0,0 +1,48 @@ +package org.koitharu.kotatsu.local.data.util + +import android.os.Build +import android.os.FileObserver +import androidx.annotation.RequiresApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.ProducerScope +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.channels.trySendBlocking +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.flowOn +import java.io.File + +fun File.observe() = callbackFlow { + val observer = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + FlowFileObserverQ(this, this@observe) + } else { + FlowFileObserver(this, this@observe) + } + observer.startWatching() + awaitClose { observer.stopWatching() } +}.flowOn(Dispatchers.IO) + +@RequiresApi(Build.VERSION_CODES.Q) +private class FlowFileObserverQ( + private val producerScope: ProducerScope, + private val file: File, +) : FileObserver(file, CREATE or DELETE or CLOSE_WRITE) { + + override fun onEvent(event: Int, path: String?) { + producerScope.trySendBlocking( + if (path == null) file else file.resolve(path), + ) + } +} + +@Suppress("DEPRECATION") +private class FlowFileObserver( + private val producerScope: ProducerScope, + private val file: File, +) : FileObserver(file.absolutePath, CREATE or DELETE or CLOSE_WRITE) { + + override fun onEvent(event: Int, path: String?) { + producerScope.trySendBlocking( + if (path == null) file else file.resolve(path), + ) + } +} diff --git a/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt b/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt index 4122cc814..77a720357 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt @@ -1,14 +1,7 @@ package org.koitharu.kotatsu.local.domain -import android.annotation.SuppressLint import android.net.Uri -import android.webkit.MimeTypeMap -import androidx.annotation.WorkerThread -import androidx.collection.ArraySet import androidx.core.net.toFile -import androidx.core.net.toUri -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll @@ -17,30 +10,29 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.filterNot import kotlinx.coroutines.runInterruptible import org.koitharu.kotatsu.core.parser.MangaRepository -import org.koitharu.kotatsu.local.data.CbzFilter +import org.koitharu.kotatsu.local.data.LocalManga import org.koitharu.kotatsu.local.data.LocalStorageManager import org.koitharu.kotatsu.local.data.MangaIndex import org.koitharu.kotatsu.local.data.TempFileFilter +import org.koitharu.kotatsu.local.data.input.LocalMangaInput +import org.koitharu.kotatsu.local.data.output.LocalMangaDirOutput +import org.koitharu.kotatsu.local.data.output.LocalMangaOutput +import org.koitharu.kotatsu.local.data.output.LocalMangaZipOutput import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.SortOrder -import org.koitharu.kotatsu.parsers.util.toCamelCase import org.koitharu.kotatsu.utils.AlphanumComparator import org.koitharu.kotatsu.utils.CompositeMutex import org.koitharu.kotatsu.utils.ext.deleteAwait -import org.koitharu.kotatsu.utils.ext.longHashCode import org.koitharu.kotatsu.utils.ext.readText import org.koitharu.kotatsu.utils.ext.runCatchingCancellable import java.io.File -import java.util.Enumeration -import java.util.zip.ZipEntry import java.util.zip.ZipFile import javax.inject.Inject import javax.inject.Singleton -import kotlin.coroutines.CoroutineContext private const val MAX_PARALLELISM = 4 @@ -48,7 +40,6 @@ private const val MAX_PARALLELISM = 4 class LocalMangaRepository @Inject constructor(private val storageManager: LocalStorageManager) : MangaRepository { override val source = MangaSource.LOCAL - private val filenameFilter = CbzFilter() private val locks = CompositeMutex() override suspend fun getList(offset: Int, query: String): List { @@ -82,47 +73,16 @@ class LocalMangaRepository @Inject constructor(private val storageManager: Local return list.unwrap() } - override suspend fun getDetails(manga: Manga) = when { - manga.source != MangaSource.LOCAL -> requireNotNull(findSavedManga(manga)) { + override suspend fun getDetails(manga: Manga): Manga = when { + manga.source != MangaSource.LOCAL -> requireNotNull(findSavedManga(manga)?.manga) { "Manga is not local or saved" } - else -> getFromFile(Uri.parse(manga.url).toFile()) + else -> LocalMangaInput.of(manga).getManga().manga } override suspend fun getPages(chapter: MangaChapter): List { - return runInterruptible(Dispatchers.IO) { - val uri = Uri.parse(chapter.url) - val file = uri.toFile() - val zip = ZipFile(file) - val index = zip.getEntry(CbzMangaOutput.ENTRY_NAME_INDEX)?.let(zip::readText)?.let(::MangaIndex) - var entries = zip.entries().asSequence() - entries = if (index != null) { - val pattern = index.getChapterNamesPattern(chapter) - entries.filter { x -> !x.isDirectory && x.name.substringBefore('.').matches(pattern) } - } else { - val parent = uri.fragment.orEmpty() - entries.filter { x -> - !x.isDirectory && x.name.substringBeforeLast( - File.separatorChar, - "", - ) == parent - } - } - entries - .toList() - .sortedWith(compareBy(AlphanumComparator()) { x -> x.name }) - .map { x -> - val entryUri = zipUri(file, x.name) - MangaPage( - id = entryUri.longHashCode(), - url = entryUri, - preview = null, - referer = chapter.url, - source = MangaSource.LOCAL, - ) - } - } + return LocalMangaInput.of(chapter).getPages(chapter) } suspend fun delete(manga: Manga): Boolean { @@ -133,109 +93,49 @@ class LocalMangaRepository @Inject constructor(private val storageManager: Local suspend fun deleteChapters(manga: Manga, ids: Set) { lockManga(manga.id) try { - runInterruptible(Dispatchers.IO) { - val uri = Uri.parse(manga.url) - val file = uri.toFile() - val cbz = CbzMangaOutput(file, manga) - CbzMangaOutput.filterChapters(cbz, ids) + val uri = Uri.parse(manga.url) + val file = uri.toFile() + if (file.isDirectory) { + LocalMangaDirOutput(file, manga).use { output -> + for (id in ids) { + output.deleteChapter(id) + } + output.finish() + } + } else { + runInterruptible(Dispatchers.IO) { + val cbz = LocalMangaZipOutput(file, manga) + LocalMangaZipOutput.filterChapters(cbz, ids) + } } } finally { unlockManga(manga.id) } } - @WorkerThread - @SuppressLint("DefaultLocale") - fun getFromFile(file: File): Manga = ZipFile(file).use { zip -> - val fileUri = file.toUri().toString() - val entry = zip.getEntry(CbzMangaOutput.ENTRY_NAME_INDEX) - val index = entry?.let(zip::readText)?.let(::MangaIndex) - val info = index?.getMangaInfo() - if (index != null && info != null) { - return info.copy2( - source = MangaSource.LOCAL, - url = fileUri, - coverUrl = zipUri( - file, - entryName = index.getCoverEntry() ?: findFirstImageEntry(zip.entries())?.name.orEmpty(), - ), - chapters = info.chapters?.map { c -> - c.copy(url = fileUri, source = MangaSource.LOCAL) - }, - ) - } - // fallback - val title = file.nameWithoutExtension.replace("_", " ").toCamelCase() - val chapters = ArraySet() - for (x in zip.entries()) { - if (!x.isDirectory) { - chapters += x.name.substringBeforeLast(File.separatorChar, "") - } - } - val uriBuilder = file.toUri().buildUpon() - Manga( - id = file.absolutePath.longHashCode(), - title = title, - url = fileUri, - publicUrl = fileUri, - source = MangaSource.LOCAL, - coverUrl = zipUri(file, findFirstImageEntry(zip.entries())?.name.orEmpty()), - chapters = chapters.sortedWith(AlphanumComparator()).mapIndexed { i, s -> - MangaChapter( - id = "$i$s".longHashCode(), - name = s.ifEmpty { title }, - number = i + 1, - source = MangaSource.LOCAL, - uploadDate = 0L, - url = uriBuilder.fragment(s).build().toString(), - scanlator = null, - branch = null, - ) - }, - altTitle = null, - rating = -1f, - isNsfw = false, - tags = setOf(), - state = null, - author = null, - largeCoverUrl = null, - description = null, - ) - } - suspend fun getRemoteManga(localManga: Manga): Manga? { val file = runCatching { Uri.parse(localManga.url).toFile() }.getOrNull() ?: return null return runInterruptible(Dispatchers.IO) { ZipFile(file).use { zip -> - val entry = zip.getEntry(CbzMangaOutput.ENTRY_NAME_INDEX) + val entry = zip.getEntry(LocalMangaOutput.ENTRY_NAME_INDEX) val index = entry?.let(zip::readText)?.let(::MangaIndex) index?.getMangaInfo() } } } - suspend fun findSavedManga(remoteManga: Manga): Manga? { + suspend fun findSavedManga(remoteManga: Manga): LocalManga? { val files = getAllFiles() - return runInterruptible(Dispatchers.IO) { - for (file in files) { - val index = ZipFile(file).use { zip -> - val entry = zip.getEntry(CbzMangaOutput.ENTRY_NAME_INDEX) - entry?.let(zip::readText)?.let(::MangaIndex) - } ?: continue - val info = index.getMangaInfo() ?: continue - if (info.id == remoteManga.id) { - val fileUri = file.toUri().toString() - return@runInterruptible info.copy2( - source = MangaSource.LOCAL, - url = fileUri, - chapters = info.chapters?.map { c -> c.copy(url = fileUri) }, - ) - } + val input = files.firstNotNullOfOrNull { file -> + LocalMangaInput.of(file).takeIf { + runCatchingCancellable { + it.getMangaInfo() + }.getOrNull()?.id == remoteManga.id } - null } + return input?.getManga() } suspend fun watchReadableDirs(): Flow { @@ -245,28 +145,6 @@ class LocalMangaRepository @Inject constructor(private val storageManager: Local .filterNot { filter.accept(it, it.name) } } - private fun CoroutineScope.getFromFileAsync( - file: File, - context: CoroutineContext, - ): Deferred = async(context) { - runInterruptible { - runCatchingCancellable { LocalManga(getFromFile(file), file) }.getOrNull() - } - } - - private fun zipUri(file: File, entryName: String) = "cbz://${file.path}#$entryName" - - private fun findFirstImageEntry(entries: Enumeration): ZipEntry? { - val list = entries.toList() - .filterNot { it.isDirectory } - .sortedWith(compareBy(AlphanumComparator()) { x -> x.name }) - val map = MimeTypeMap.getSingleton() - return list.firstOrNull { - map.getMimeTypeFromExtension(it.name.substringAfterLast('.')) - ?.startsWith("image/") == true - } - } - override val sortOrders = setOf(SortOrder.ALPHABETICAL, SortOrder.RATING) override suspend fun getPageUrl(page: MangaPage) = page.url @@ -301,49 +179,16 @@ class LocalMangaRepository @Inject constructor(private val storageManager: Local return coroutineScope { val dispatcher = Dispatchers.IO.limitedParallelism(MAX_PARALLELISM) files.map { file -> - getFromFileAsync(file, dispatcher) + async(dispatcher) { + runCatchingCancellable { LocalMangaInput.of(file).getManga() }.getOrNull() + } }.awaitAll() }.filterNotNullTo(ArrayList(files.size)) } private suspend fun getAllFiles() = storageManager.getReadableDirs().flatMap { dir -> - dir.listFiles(filenameFilter)?.toList().orEmpty() + dir.listFiles()?.toList().orEmpty() } - private fun Manga.copy2( - url: String = this.url, - coverUrl: String = this.coverUrl, - chapters: List? = this.chapters, - source: MangaSource = this.source, - ) = Manga( - id = id, - title = title, - altTitle = altTitle, - url = url, - publicUrl = publicUrl, - rating = rating, - isNsfw = isNsfw, - coverUrl = coverUrl, - tags = tags, - state = state, - author = author, - largeCoverUrl = largeCoverUrl, - description = description, - chapters = chapters, - source = source, - ) - - private fun MangaChapter.copy( - url: String = this.url, - source: MangaSource = this.source, - ) = MangaChapter( - id = id, - name = name, - number = number, - url = url, - scanlator = scanlator, - uploadDate = uploadDate, - branch = branch, - source = source, - ) + private fun Collection.unwrap(): List = map { it.manga } } diff --git a/app/src/main/java/org/koitharu/kotatsu/local/domain/importer/DirMangaImporter.kt b/app/src/main/java/org/koitharu/kotatsu/local/domain/importer/DirMangaImporter.kt index b117f1395..ff92955a2 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/domain/importer/DirMangaImporter.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/domain/importer/DirMangaImporter.kt @@ -6,9 +6,10 @@ import android.webkit.MimeTypeMap import androidx.documentfile.provider.DocumentFile import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.withContext +import org.koitharu.kotatsu.local.data.LocalManga import org.koitharu.kotatsu.local.data.LocalStorageManager -import org.koitharu.kotatsu.local.domain.CbzMangaOutput -import org.koitharu.kotatsu.local.domain.LocalMangaRepository +import org.koitharu.kotatsu.local.data.input.LocalMangaInput +import org.koitharu.kotatsu.local.data.output.LocalMangaOutput import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaSource @@ -24,17 +25,16 @@ import java.io.File class DirMangaImporter( private val context: Context, storageManager: LocalStorageManager, - private val localMangaRepository: LocalMangaRepository, ) : MangaImporter(storageManager) { private val contentResolver = context.contentResolver - override suspend fun import(uri: Uri): Manga { + override suspend fun import(uri: Uri): LocalManga { val root = requireNotNull(DocumentFile.fromTreeUri(context, uri)) { "Provided uri $uri is not a tree" } val manga = Manga(root) - val output = CbzMangaOutput.get(getOutputDir(), manga) + val output = LocalMangaOutput.getOrCreate(getOutputDir(), manga) try { val dest = output.use { addPages( @@ -46,9 +46,9 @@ class DirMangaImporter( it.sortChaptersByName() it.mergeWithExisting() it.finish() - it.file + it.rootFile } - return localMangaRepository.getFromFile(dest) + return LocalMangaInput.of(dest).getManga() } finally { withContext(NonCancellable) { output.cleanup() @@ -57,7 +57,7 @@ class DirMangaImporter( } } - private suspend fun addPages(output: CbzMangaOutput, root: DocumentFile, path: String, state: State) { + private suspend fun addPages(output: LocalMangaOutput, root: DocumentFile, path: String, state: State) { var number = 0 for (file in root.listFiles().sortedWith(compareBy(AlphanumComparator()) { it.name.orEmpty() })) { when { diff --git a/app/src/main/java/org/koitharu/kotatsu/local/domain/importer/MangaImporter.kt b/app/src/main/java/org/koitharu/kotatsu/local/domain/importer/MangaImporter.kt index dc281b920..bb036c549 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/domain/importer/MangaImporter.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/domain/importer/MangaImporter.kt @@ -4,18 +4,17 @@ import android.content.Context import android.net.Uri import androidx.documentfile.provider.DocumentFile import dagger.hilt.android.qualifiers.ApplicationContext +import org.koitharu.kotatsu.local.data.LocalManga +import org.koitharu.kotatsu.local.data.LocalStorageManager import java.io.File import java.io.IOException import javax.inject.Inject -import org.koitharu.kotatsu.local.data.LocalStorageManager -import org.koitharu.kotatsu.local.domain.LocalMangaRepository -import org.koitharu.kotatsu.parsers.model.Manga abstract class MangaImporter( protected val storageManager: LocalStorageManager, ) { - abstract suspend fun import(uri: Uri): Manga + abstract suspend fun import(uri: Uri): LocalManga suspend fun getOutputDir(): File { return storageManager.getDefaultWriteableDir() ?: throw IOException("External files dir unavailable") @@ -24,13 +23,12 @@ abstract class MangaImporter( class Factory @Inject constructor( @ApplicationContext private val context: Context, private val storageManager: LocalStorageManager, - private val localMangaRepository: LocalMangaRepository, ) { fun create(uri: Uri): MangaImporter { return when { - isDir(uri) -> DirMangaImporter(context, storageManager, localMangaRepository) - else -> ZipMangaImporter(storageManager, localMangaRepository) + isDir(uri) -> DirMangaImporter(context, storageManager) + else -> ZipMangaImporter(storageManager) } } diff --git a/app/src/main/java/org/koitharu/kotatsu/local/domain/importer/ZipMangaImporter.kt b/app/src/main/java/org/koitharu/kotatsu/local/domain/importer/ZipMangaImporter.kt index fdf24abd1..58fa3e77f 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/domain/importer/ZipMangaImporter.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/domain/importer/ZipMangaImporter.kt @@ -6,9 +6,9 @@ import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.withContext import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException import org.koitharu.kotatsu.local.data.CbzFilter +import org.koitharu.kotatsu.local.data.LocalManga import org.koitharu.kotatsu.local.data.LocalStorageManager -import org.koitharu.kotatsu.local.domain.LocalMangaRepository -import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.local.data.input.LocalMangaInput import org.koitharu.kotatsu.utils.ext.copyToSuspending import org.koitharu.kotatsu.utils.ext.resolveName import java.io.File @@ -16,10 +16,9 @@ import java.io.IOException class ZipMangaImporter( storageManager: LocalStorageManager, - private val localMangaRepository: LocalMangaRepository, ) : MangaImporter(storageManager) { - override suspend fun import(uri: Uri): Manga { + override suspend fun import(uri: Uri): LocalManga { val contentResolver = storageManager.contentResolver return withContext(Dispatchers.IO) { val name = contentResolver.resolveName(uri) ?: throw IOException("Cannot fetch name from uri: $uri") @@ -34,7 +33,7 @@ class ZipMangaImporter( source.copyToSuspending(output) } } ?: throw IOException("Cannot open input stream: $uri") - localMangaRepository.getFromFile(dest) + LocalMangaInput.of(dest).getManga() } } } diff --git a/app/src/main/java/org/koitharu/kotatsu/local/ui/ImportService.kt b/app/src/main/java/org/koitharu/kotatsu/local/ui/ImportService.kt index f2efd9d27..9be2af27e 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/ui/ImportService.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/ui/ImportService.kt @@ -79,7 +79,7 @@ class ImportService : CoroutineIntentService() { private suspend fun importImpl(uri: Uri): Manga { val importer = importerFactory.create(uri) - return importer.import(uri) + return importer.import(uri).manga } private fun sendBroadcast(manga: Manga) { diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/domain/PageLoader.kt b/app/src/main/java/org/koitharu/kotatsu/reader/domain/PageLoader.kt index 0d19b47e9..30bab72b9 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/domain/PageLoader.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/domain/PageLoader.kt @@ -193,7 +193,7 @@ class PageLoader @Inject constructor( .get() .header(CommonHeaders.REFERER, page.referer) .header(CommonHeaders.ACCEPT, "image/webp,image/png;q=0.9,image/jpeg,*/*;q=0.8") - .cacheControl(CommonHeaders.CACHE_CONTROL_DISABLED) + .cacheControl(CommonHeaders.CACHE_CONTROL_NO_STORE) .tag(MangaSource::class.java, page.source) .build() okHttp.newCall(request).await().use { response -> diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/RetainedLifecycleCoroutineScope.kt b/app/src/main/java/org/koitharu/kotatsu/utils/RetainedLifecycleCoroutineScope.kt index 73cbf290f..66a232922 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/RetainedLifecycleCoroutineScope.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/RetainedLifecycleCoroutineScope.kt @@ -19,6 +19,5 @@ class RetainedLifecycleCoroutineScope( override fun onCleared() { coroutineContext.cancel() - lifecycle.removeOnClearedListener(this) } } diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/CollectionExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/CollectionExt.kt index 10b5fdb5c..6f6513707 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/CollectionExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/CollectionExt.kt @@ -41,3 +41,7 @@ fun Map.findKeyByValue(value: V): K? { inline fun Collection.filterToSet(predicate: (T) -> Boolean): Set { return filterTo(ArraySet(size), predicate) } + +fun Sequence.toListSorted(comparator: Comparator): List { + return toMutableList().apply { sortWith(comparator) } +} diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/FileExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/FileExt.kt index c40f4e01c..9dec944b4 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/FileExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/FileExt.kt @@ -14,6 +14,7 @@ import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.withContext import org.koitharu.kotatsu.R import java.io.File +import java.io.FileFilter import java.util.zip.ZipEntry import java.util.zip.ZipFile @@ -77,3 +78,19 @@ private fun computeSizeInternal(file: File): Long { return file.length() } } + +fun File.listFilesRecursive(filter: FileFilter? = null): Sequence = sequence { + listFilesRecursiveImpl(this@listFilesRecursive, filter) +} + +private suspend fun SequenceScope.listFilesRecursiveImpl(root: File, filter: FileFilter?) { + val ss = root.list() ?: return + for (s in ss) { + val f = File(root, s) + if (f.isDirectory) { + listFilesRecursiveImpl(f, filter) + } else if (filter == null || filter.accept(f)) { + yield(f) + } + } +}