From d3e9ce874a663179da2fcd6a91af94a7a07128e8 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Mon, 18 Apr 2022 16:42:37 +0300 Subject: [PATCH] Download manga to cbz directly --- app/build.gradle | 6 +- .../kotatsu/core/backup/BackupArchive.kt | 51 -------- .../kotatsu/core/backup/BackupZipInput.kt | 25 ++++ .../kotatsu/core/backup/BackupZipOutput.kt | 45 +++++++ .../koitharu/kotatsu/core/zip/ZipOutput.kt | 61 ++++++++++ .../org/koitharu/kotatsu/core/zip/ZipUtils.kt | 37 ++++++ .../download/domain/DownloadManager.kt | 14 +-- .../koitharu/kotatsu/local/data/CbzFetcher.kt | 2 +- .../koitharu/kotatsu/local/data/MangaIndex.kt | 17 +-- .../koitharu/kotatsu/local/data/MangaZip.kt | 70 ----------- .../koitharu/kotatsu/local/data/PagesCache.kt | 6 +- .../kotatsu/local/data/WritableCbzFile.kt | 99 ---------------- .../kotatsu/local/domain/CbzMangaOutput.kt | 111 ++++++++++++++++++ .../local/domain/LocalMangaRepository.kt | 9 +- .../kotatsu/settings/backup/AppBackupAgent.kt | 31 ++--- .../settings/backup/BackupViewModel.kt | 30 ++--- .../settings/backup/RestoreViewModel.kt | 17 +-- .../koitharu/kotatsu/utils/MutableZipFile.kt | 103 ---------------- 18 files changed, 340 insertions(+), 394 deletions(-) delete mode 100644 app/src/main/java/org/koitharu/kotatsu/core/backup/BackupArchive.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/core/backup/BackupZipInput.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/core/backup/BackupZipOutput.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/core/zip/ZipOutput.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/core/zip/ZipUtils.kt delete mode 100644 app/src/main/java/org/koitharu/kotatsu/local/data/MangaZip.kt delete mode 100644 app/src/main/java/org/koitharu/kotatsu/local/data/WritableCbzFile.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/local/domain/CbzMangaOutput.kt delete mode 100644 app/src/main/java/org/koitharu/kotatsu/utils/MutableZipFile.kt diff --git a/app/build.gradle b/app/build.gradle index 4cd4b8cf9..69e2b2373 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -6,14 +6,14 @@ plugins { } android { - compileSdkVersion 32 - buildToolsVersion '32.0.0' + compileSdkVersion 31 + buildToolsVersion '31.0.0' namespace 'org.koitharu.kotatsu' defaultConfig { applicationId 'org.koitharu.kotatsu' minSdkVersion 21 - targetSdkVersion 32 + targetSdkVersion 31 versionCode 402 versionName '3.1.1' generatedDensities = [] diff --git a/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupArchive.kt b/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupArchive.kt deleted file mode 100644 index 6a90243fa..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupArchive.kt +++ /dev/null @@ -1,51 +0,0 @@ -package org.koitharu.kotatsu.core.backup - -import android.content.Context -import java.io.File -import java.util.* -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.runInterruptible -import kotlinx.coroutines.withContext -import org.json.JSONArray -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.utils.MutableZipFile -import org.koitharu.kotatsu.utils.ext.format - -class BackupArchive(file: File) : MutableZipFile(file) { - - init { - if (!dir.exists()) { - dir.mkdirs() - } - } - - suspend fun put(entry: BackupEntry) { - put(entry.name, entry.data.toString(2)) - } - - suspend fun getEntry(name: String): BackupEntry { - val json = withContext(Dispatchers.Default) { - JSONArray(getContent(name)) - } - return BackupEntry(name, json) - } - - companion object { - - private const val DIR_BACKUPS = "backups" - - suspend fun createNew(context: Context): BackupArchive = runInterruptible(Dispatchers.IO) { - val dir = context.run { - getExternalFilesDir(DIR_BACKUPS) ?: File(filesDir, DIR_BACKUPS) - } - dir.mkdirs() - val filename = buildString { - append(context.getString(R.string.app_name).lowercase(Locale.ROOT)) - append('_') - append(Date().format("ddMMyyyy")) - append(".bak") - } - BackupArchive(File(dir, filename)) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupZipInput.kt b/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupZipInput.kt new file mode 100644 index 000000000..25e1d3688 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupZipInput.kt @@ -0,0 +1,25 @@ +package org.koitharu.kotatsu.core.backup + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runInterruptible +import okio.Closeable +import org.json.JSONArray +import java.io.File +import java.util.zip.ZipFile + +class BackupZipInput(val file: File) : Closeable { + + private val zipFile = ZipFile(file) + + suspend fun getEntry(name: String): BackupEntry = runInterruptible(Dispatchers.IO) { + val entry = zipFile.getEntry(name) + val json = zipFile.getInputStream(entry).use { + JSONArray(it.bufferedReader().readText()) + } + BackupEntry(name, json) + } + + override fun close() { + zipFile.close() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupZipOutput.kt b/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupZipOutput.kt new file mode 100644 index 000000000..f01dc73d9 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupZipOutput.kt @@ -0,0 +1,45 @@ +package org.koitharu.kotatsu.core.backup + +import android.content.Context +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runInterruptible +import okio.Closeable +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.zip.ZipOutput +import org.koitharu.kotatsu.utils.ext.format +import java.io.File +import java.util.* +import java.util.zip.Deflater + +class BackupZipOutput(val file: File) : Closeable { + + private val output = ZipOutput(file, Deflater.BEST_COMPRESSION) + + suspend fun put(entry: BackupEntry) { + output.put(entry.name, entry.data.toString(2)) + } + + suspend fun finish() { + output.finish() + } + + override fun close() { + output.close() + } +} + +private const val DIR_BACKUPS = "backups" + +suspend fun BackupZipOutput(context: Context): BackupZipOutput = runInterruptible(Dispatchers.IO) { + val dir = context.run { + getExternalFilesDir(DIR_BACKUPS) ?: File(filesDir, DIR_BACKUPS) + } + dir.mkdirs() + val filename = buildString { + append(context.getString(R.string.app_name).replace(' ', '_').lowercase(Locale.ROOT)) + append('_') + append(Date().format("ddMMyyyy")) + append(".bk.zip") + } + BackupZipOutput(File(dir, filename)) +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/zip/ZipOutput.kt b/app/src/main/java/org/koitharu/kotatsu/core/zip/ZipOutput.kt new file mode 100644 index 000000000..bcdc5b0ee --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/core/zip/ZipOutput.kt @@ -0,0 +1,61 @@ +package org.koitharu.kotatsu.core.zip + +import androidx.annotation.WorkerThread +import androidx.collection.ArraySet +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runInterruptible +import okio.Closeable +import java.io.File +import java.util.zip.Deflater +import java.util.zip.ZipEntry +import java.util.zip.ZipFile +import java.util.zip.ZipOutputStream + +class ZipOutput( + val file: File, + compressionLevel: Int = Deflater.DEFAULT_COMPRESSION, +) : Closeable { + + private val entryNames = ArraySet() + private var isClosed = false + private val output = ZipOutputStream(file.outputStream()).apply { + setLevel(compressionLevel) + } + + suspend fun put(name: String, file: File): Unit = runInterruptible(Dispatchers.IO) { + entryNames.add(name) + output.appendFile(file, name) + } + + suspend fun put(name: String, content: String): Unit = runInterruptible(Dispatchers.IO) { + entryNames.add(name) + output.appendText(content, name) + } + + @WorkerThread + fun copyEntryFrom(other: ZipFile, entry: ZipEntry): Boolean { + return if (entryNames.add(entry.name)) { + val zipEntry = ZipEntry(entry.name) + output.putNextEntry(zipEntry) + other.getInputStream(entry).use { input -> + input.copyTo(output) + } + output.closeEntry() + true + } else { + false + } + } + + suspend fun finish() = runInterruptible(Dispatchers.IO) { + output.finish() + output.flush() + } + + override fun close() { + if (!isClosed) { + output.close() + isClosed = true + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/zip/ZipUtils.kt b/app/src/main/java/org/koitharu/kotatsu/core/zip/ZipUtils.kt new file mode 100644 index 000000000..c42f47142 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/core/zip/ZipUtils.kt @@ -0,0 +1,37 @@ +package org.koitharu.kotatsu.core.zip + +import androidx.annotation.WorkerThread +import java.io.File +import java.io.FileInputStream +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +@WorkerThread +fun ZipOutputStream.appendFile(fileToZip: File, name: String) { + if (fileToZip.isDirectory) { + if (name.endsWith("/")) { + putNextEntry(ZipEntry(name)) + } else { + putNextEntry(ZipEntry("$name/")) + } + closeEntry() + fileToZip.listFiles()?.forEach { childFile -> + appendFile(childFile, "$name/${childFile.name}") + } + } else { + FileInputStream(fileToZip).use { fis -> + val zipEntry = ZipEntry(name) + putNextEntry(zipEntry) + fis.copyTo(this) + closeEntry() + } + } +} + +@WorkerThread +fun ZipOutputStream.appendText(content: String, name: String) { + val zipEntry = ZipEntry(name) + putNextEntry(zipEntry) + content.byteInputStream().copyTo(this) + closeEntry() +} \ No newline at end of file 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 3373273ea..aba04e350 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 @@ -7,7 +7,6 @@ import android.webkit.MimeTypeMap import coil.ImageLoader import coil.request.ImageRequest import coil.size.Scale -import java.io.File import kotlinx.coroutines.* import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.sync.Semaphore @@ -18,8 +17,8 @@ import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.network.CommonHeaders import org.koitharu.kotatsu.core.parser.MangaRepository -import org.koitharu.kotatsu.local.data.MangaZip import org.koitharu.kotatsu.local.data.PagesCache +import org.koitharu.kotatsu.local.domain.CbzMangaOutput import org.koitharu.kotatsu.local.domain.LocalMangaRepository import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaSource @@ -28,6 +27,7 @@ import org.koitharu.kotatsu.utils.ext.deleteAwait import org.koitharu.kotatsu.utils.ext.referer import org.koitharu.kotatsu.utils.ext.waitForNetwork import org.koitharu.kotatsu.utils.progress.ProgressJob +import java.io.File private const val MAX_DOWNLOAD_ATTEMPTS = 3 private const val MAX_PARALLEL_DOWNLOADS = 2 @@ -80,7 +80,7 @@ class DownloadManager( var cover: Drawable? = null val destination = localMangaRepository.getOutputDir() checkNotNull(destination) { context.getString(R.string.cannot_find_available_storage) } - var output: MangaZip? = null + var output: CbzMangaOutput? = null try { if (manga.source == MangaSource.LOCAL) { manga = localMangaRepository.getRemoteManga(manga) ?: error("Cannot obtain remote manga instance") @@ -98,8 +98,7 @@ class DownloadManager( }.getOrNull() outState.value = DownloadState.Preparing(startId, manga, cover) val data = if (manga.chapters.isNullOrEmpty()) repo.getDetails(manga) else manga - output = MangaZip.findInDir(destination, data) - output.prepare(data) + output = CbzMangaOutput.createNew(destination, data) val coverUrl = data.largeCoverUrl ?: data.coverUrl downloadFile(coverUrl, data.publicUrl, destination).let { file -> output.addCover(file, MimeTypeMap.getFileExtensionFromUrl(coverUrl)) @@ -145,9 +144,8 @@ class DownloadManager( } } outState.value = DownloadState.PostProcessing(startId, data, cover) - if (!output.compress()) { - throw RuntimeException("Cannot create target file") - } + output.mergeWithExisting() + output.finalize() val localManga = localMangaRepository.getFromFile(output.file) outState.value = DownloadState.Done(startId, data, cover, localManga) } catch (e: CancellationException) { 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 4e2746cec..ff04a2eff 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 @@ -9,11 +9,11 @@ import coil.fetch.FetchResult import coil.fetch.Fetcher import coil.fetch.SourceResult import coil.size.Size +import java.util.zip.ZipFile import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runInterruptible import okio.buffer import okio.source -import java.util.zip.ZipFile class CbzFetcher : Fetcher { 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 f7d1a8aaf..07bbbce98 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 @@ -28,14 +28,17 @@ class MangaIndex(source: String?) { json.put("state", manga.state?.name) json.put("source", manga.source.name) json.put("cover_large", manga.largeCoverUrl) - json.put("tags", JSONArray().also { a -> - for (tag in manga.tags) { - val jo = JSONObject() - jo.put("key", tag.key) - jo.put("title", tag.title) - a.put(jo) + json.put( + "tags", + JSONArray().also { a -> + for (tag in manga.tags) { + val jo = JSONObject() + jo.put("key", tag.key) + jo.put("title", tag.title) + a.put(jo) + } } - }) + ) if (!append || !json.has("chapters")) { json.put("chapters", JSONObject()) } diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/MangaZip.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/MangaZip.kt deleted file mode 100644 index 0aacb4ee7..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/local/data/MangaZip.kt +++ /dev/null @@ -1,70 +0,0 @@ -package org.koitharu.kotatsu.local.data - -import androidx.annotation.CheckResult -import androidx.annotation.WorkerThread -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.takeIfReadable -import java.io.File - -@WorkerThread -class MangaZip(val file: File) { - - private val writableCbz = WritableCbzFile(file) - - private var index = MangaIndex(null) - - suspend fun prepare(manga: Manga) { - writableCbz.prepare(overwrite = true) - index = MangaIndex(writableCbz[INDEX_ENTRY].takeIfReadable()?.readText()) - index.setMangaInfo(manga, append = true) - } - - suspend fun cleanup() { - writableCbz.cleanup() - } - - @CheckResult - suspend fun compress(): Boolean { - writableCbz[INDEX_ENTRY].writeText(index.toString()) - return writableCbz.flush() - } - - suspend fun addCover(file: File, ext: String) { - val name = buildString { - append(FILENAME_PATTERN.format(0, 0)) - if (ext.isNotEmpty() && ext.length <= 4) { - append('.') - append(ext) - } - } - writableCbz.put(name, file) - index.setCoverEntry(name) - } - - suspend fun addPage(chapter: MangaChapter, file: File, pageNumber: Int, ext: String) { - val name = buildString { - append(FILENAME_PATTERN.format(chapter.number, pageNumber)) - if (ext.isNotEmpty() && ext.length <= 4) { - append('.') - append(ext) - } - } - writableCbz.put(name, file) - index.addChapter(chapter) - } - - companion object { - - private const val FILENAME_PATTERN = "%03d%03d" - - const val INDEX_ENTRY = "index.json" - - fun findInDir(root: File, manga: Manga): MangaZip { - val name = manga.title.toFileNameSafe() + ".cbz" - val file = File(root, name) - return MangaZip(file) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/PagesCache.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/PagesCache.kt index bf0da97f9..73dd83bb4 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/data/PagesCache.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/data/PagesCache.kt @@ -2,13 +2,13 @@ package org.koitharu.kotatsu.local.data import android.content.Context import com.tomclaw.cache.DiskLruCache +import java.io.File +import java.io.InputStream import kotlinx.coroutines.flow.MutableStateFlow import org.koitharu.kotatsu.parsers.util.longHashCode import org.koitharu.kotatsu.utils.FileSize import org.koitharu.kotatsu.utils.ext.subdir import org.koitharu.kotatsu.utils.ext.takeIfReadable -import java.io.File -import java.io.InputStream class PagesCache(context: Context) { @@ -60,4 +60,4 @@ class PagesCache(context: Context) { progress.value = (bytesCopied.toDouble() / contentLength.toDouble()).toFloat() } } -} +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/WritableCbzFile.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/WritableCbzFile.kt deleted file mode 100644 index fe61169b2..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/local/data/WritableCbzFile.kt +++ /dev/null @@ -1,99 +0,0 @@ -package org.koitharu.kotatsu.local.data - -import androidx.annotation.CheckResult -import kotlinx.coroutines.* -import org.koitharu.kotatsu.utils.ext.deleteAwait -import java.io.File -import java.io.FileInputStream -import java.io.FileOutputStream -import java.util.zip.ZipEntry -import java.util.zip.ZipInputStream -import java.util.zip.ZipOutputStream - -class WritableCbzFile(private val file: File) { - - private val dir = File(file.parentFile, file.nameWithoutExtension) - - suspend fun prepare(overwrite: Boolean) = withContext(Dispatchers.IO) { - if (!dir.list().isNullOrEmpty()) { - if (overwrite) { - dir.deleteRecursively() - } else { - throw IllegalStateException("Dir ${dir.name} is not empty") - } - } - if (!dir.exists()) { - dir.mkdir() - } - if (!file.exists()) { - return@withContext - } - ZipInputStream(FileInputStream(file)).use { zip -> - var entry = zip.nextEntry - while (entry != null && currentCoroutineContext().isActive) { - val target = File(dir.path + File.separator + entry.name) - runInterruptible { - target.parentFile?.mkdirs() - target.outputStream().use { out -> - zip.copyTo(out) - } - } - zip.closeEntry() - entry = zip.nextEntry - } - } - } - - suspend fun cleanup() = withContext(Dispatchers.IO) { - dir.deleteRecursively() - } - - @CheckResult - suspend fun flush() = withContext(Dispatchers.IO) { - val tempFile = File(file.path + ".tmp") - if (tempFile.exists()) { - tempFile.deleteAwait() - } - try { - runInterruptible { - ZipOutputStream(FileOutputStream(tempFile)).use { zip -> - dir.listFiles()?.forEach { - zipFile(it, it.name, zip) - } - zip.flush() - } - } - tempFile.renameTo(file) - } finally { - if (tempFile.exists()) { - tempFile.deleteAwait() - } - } - } - - operator fun get(name: String) = File(dir, name) - - suspend fun put(name: String, file: File) = runInterruptible(Dispatchers.IO) { - file.copyTo(this[name], overwrite = true) - } - - private fun zipFile(fileToZip: File, fileName: String, zipOut: ZipOutputStream) { - if (fileToZip.isDirectory) { - if (fileName.endsWith("/")) { - zipOut.putNextEntry(ZipEntry(fileName)) - } else { - zipOut.putNextEntry(ZipEntry("$fileName/")) - } - zipOut.closeEntry() - fileToZip.listFiles()?.forEach { childFile -> - zipFile(childFile, "$fileName/${childFile.name}", zipOut) - } - } else { - FileInputStream(fileToZip).use { fis -> - val zipEntry = ZipEntry(fileName) - zipOut.putNextEntry(zipEntry) - fis.copyTo(zipOut) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/local/domain/CbzMangaOutput.kt b/app/src/main/java/org/koitharu/kotatsu/local/domain/CbzMangaOutput.kt new file mode 100644 index 000000000..87b772bb7 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/local/domain/CbzMangaOutput.kt @@ -0,0 +1,111 @@ +package org.koitharu.kotatsu.local.domain + +import androidx.annotation.WorkerThread +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 java.io.File +import java.util.zip.ZipFile + +class CbzMangaOutput( + val file: File, + manga: Manga, +) : Closeable { + + private val output = ZipOutput(File(file.path + ".tmp")) + private val index = MangaIndex(null) + + init { + index.setMangaInfo(manga, false) + } + + suspend fun mergeWithExisting() { + if (file.exists()) { + runInterruptible(Dispatchers.IO) { + mergeWith(file) + } + } + } + + suspend fun addCover(file: File, ext: String) { + val name = buildString { + append(FILENAME_PATTERN.format(0, 0)) + if (ext.isNotEmpty() && ext.length <= 4) { + append('.') + append(ext) + } + } + output.put(name, file) + index.setCoverEntry(name) + } + + suspend fun addPage(chapter: MangaChapter, file: File, pageNumber: Int, ext: String) { + val name = buildString { + append(FILENAME_PATTERN.format(chapter.number, pageNumber)) + if (ext.isNotEmpty() && ext.length <= 4) { + append('.') + append(ext) + } + } + output.put(name, file) + index.addChapter(chapter) + } + + suspend fun finalize() { + output.put(ENTRY_NAME_INDEX, index.toString()) + output.finish() + output.close() + file.deleteAwait() + output.file.renameTo(file) + } + + suspend fun cleanup() { + output.file.deleteAwait() + } + + override fun close() { + output.close() + } + + @WorkerThread + private fun mergeWith(other: File) { + var otherIndex: MangaIndex? = null + ZipFile(other).use { zip -> + for (entry in zip.entries()) { + if (entry.name == ENTRY_NAME_INDEX) { + otherIndex = MangaIndex( + zip.getInputStream(entry).use { + it.reader().readText() + } + ) + } else { + output.copyEntryFrom(zip, entry) + } + } + } + otherIndex?.getMangaInfo()?.chapters?.let { chapters -> + for (chapter in chapters) { + index.addChapter(chapter) + } + } + } + + companion object { + + private const val FILENAME_PATTERN = "%03d%03d" + + const val ENTRY_NAME_INDEX = "index.json" + + fun createNew(root: File, manga: Manga): CbzMangaOutput { + val name = manga.title.toFileNameSafe() + ".cbz" + val file = File(root, name) + return CbzMangaOutput(file, manga) + } + } +} \ No newline at end of file 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 c6137a485..99d8f7ad9 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 @@ -14,7 +14,6 @@ import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.local.data.CbzFilter import org.koitharu.kotatsu.local.data.LocalStorageManager import org.koitharu.kotatsu.local.data.MangaIndex -import org.koitharu.kotatsu.local.data.MangaZip import org.koitharu.kotatsu.parsers.model.* import org.koitharu.kotatsu.parsers.util.longHashCode import org.koitharu.kotatsu.parsers.util.toCamelCase @@ -59,7 +58,7 @@ class LocalMangaRepository(private val storageManager: LocalStorageManager) : Ma val uri = Uri.parse(chapter.url) val file = uri.toFile() val zip = ZipFile(file) - val index = zip.getEntry(MangaZip.INDEX_ENTRY)?.let(zip::readText)?.let(::MangaIndex) + 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) @@ -97,7 +96,7 @@ class LocalMangaRepository(private val storageManager: LocalStorageManager) : Ma @SuppressLint("DefaultLocale") fun getFromFile(file: File): Manga = ZipFile(file).use { zip -> val fileUri = file.toUri().toString() - val entry = zip.getEntry(MangaZip.INDEX_ENTRY) + 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) { @@ -158,7 +157,7 @@ class LocalMangaRepository(private val storageManager: LocalStorageManager) : Ma }.getOrNull() ?: return null return runInterruptible(Dispatchers.IO) { ZipFile(file).use { zip -> - val entry = zip.getEntry(MangaZip.INDEX_ENTRY) + val entry = zip.getEntry(CbzMangaOutput.ENTRY_NAME_INDEX) val index = entry?.let(zip::readText)?.let(::MangaIndex) index?.getMangaInfo() } @@ -170,7 +169,7 @@ class LocalMangaRepository(private val storageManager: LocalStorageManager) : Ma return runInterruptible(Dispatchers.IO) { for (file in files) { val index = ZipFile(file).use { zip -> - val entry = zip.getEntry(MangaZip.INDEX_ENTRY) + val entry = zip.getEntry(CbzMangaOutput.ENTRY_NAME_INDEX) entry?.let(zip::readText)?.let(::MangaIndex) } ?: continue val info = index.getMangaInfo() ?: continue diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/backup/AppBackupAgent.kt b/app/src/main/java/org/koitharu/kotatsu/settings/backup/AppBackupAgent.kt index 9b59a983c..278995dab 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/backup/AppBackupAgent.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/backup/AppBackupAgent.kt @@ -5,12 +5,8 @@ import android.app.backup.BackupDataInput import android.app.backup.BackupDataOutput import android.app.backup.FullBackupDataOutput import android.os.ParcelFileDescriptor -import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.runBlocking -import org.koitharu.kotatsu.core.backup.BackupArchive -import org.koitharu.kotatsu.core.backup.BackupEntry -import org.koitharu.kotatsu.core.backup.BackupRepository -import org.koitharu.kotatsu.core.backup.RestoreRepository +import org.koitharu.kotatsu.core.backup.* import org.koitharu.kotatsu.core.db.MangaDatabase import java.io.* @@ -46,7 +42,7 @@ class AppBackupAgent : BackupAgent() { mode: Long, mtime: Long ) { - if (destination?.name?.endsWith(".bak") == true) { + if (destination?.name?.endsWith(".bk.zip") == true) { restoreBackupFile(data.fileDescriptor, size) destination.delete() } else { @@ -56,14 +52,14 @@ class AppBackupAgent : BackupAgent() { private fun createBackupFile() = runBlocking { val repository = BackupRepository(MangaDatabase.create(applicationContext)) - val backup = BackupArchive.createNew(this@AppBackupAgent) - backup.put(repository.createIndex()) - backup.put(repository.dumpHistory()) - backup.put(repository.dumpCategories()) - backup.put(repository.dumpFavourites()) - backup.flush() - backup.cleanup() - backup.file + BackupZipOutput(this@AppBackupAgent).use { backup -> + backup.put(repository.createIndex()) + backup.put(repository.dumpHistory()) + backup.put(repository.dumpCategories()) + backup.put(repository.dumpFavourites()) + backup.finish() + backup.file + } } private fun restoreBackupFile(fd: FileDescriptor, size: Long) { @@ -74,18 +70,15 @@ class AppBackupAgent : BackupAgent() { input.copyLimitedTo(output, size) } } - val backup = BackupArchive(tempFile) + val backup = BackupZipInput(tempFile) try { runBlocking { - backup.unpack() repository.upsertHistory(backup.getEntry(BackupEntry.HISTORY)) repository.upsertCategories(backup.getEntry(BackupEntry.CATEGORIES)) repository.upsertFavourites(backup.getEntry(BackupEntry.FAVOURITES)) } } finally { - runBlocking(NonCancellable) { - backup.cleanup() - } + backup.close() tempFile.delete() } } diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/backup/BackupViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/settings/backup/BackupViewModel.kt index 8c3ac36a9..2532dc8d2 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/backup/BackupViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/backup/BackupViewModel.kt @@ -3,8 +3,8 @@ package org.koitharu.kotatsu.settings.backup import android.content.Context import androidx.lifecycle.MutableLiveData import org.koitharu.kotatsu.base.ui.BaseViewModel -import org.koitharu.kotatsu.core.backup.BackupArchive import org.koitharu.kotatsu.core.backup.BackupRepository +import org.koitharu.kotatsu.core.backup.BackupZipOutput import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.progress.Progress import java.io.File @@ -19,23 +19,25 @@ class BackupViewModel( init { launchLoadingJob { - val backup = BackupArchive.createNew(context) - backup.put(repository.createIndex()) + val file = BackupZipOutput(context).use { backup -> + backup.put(repository.createIndex()) - progress.value = Progress(0, 3) - backup.put(repository.dumpHistory()) + progress.value = Progress(0, 3) + backup.put(repository.dumpHistory()) - progress.value = Progress(1, 3) - backup.put(repository.dumpCategories()) + progress.value = Progress(1, 3) + backup.put(repository.dumpCategories()) - progress.value = Progress(2, 3) - backup.put(repository.dumpFavourites()) + progress.value = Progress(2, 3) + backup.put(repository.dumpFavourites()) - progress.value = Progress(3, 3) - backup.flush() - progress.value = null - backup.cleanup() - onBackupDone.call(backup.file) + progress.value = Progress(3, 3) + backup.finish() + progress.value = null + backup.close() + backup.file + } + onBackupDone.call(file) } } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/backup/RestoreViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/settings/backup/RestoreViewModel.kt index e7d185eb2..79f2fc7c4 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/backup/RestoreViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/backup/RestoreViewModel.kt @@ -3,19 +3,17 @@ package org.koitharu.kotatsu.settings.backup import android.content.Context import android.net.Uri import androidx.lifecycle.MutableLiveData -import java.io.File -import java.io.FileNotFoundException import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.runInterruptible -import kotlinx.coroutines.withContext import org.koitharu.kotatsu.base.ui.BaseViewModel -import org.koitharu.kotatsu.core.backup.BackupArchive import org.koitharu.kotatsu.core.backup.BackupEntry +import org.koitharu.kotatsu.core.backup.BackupZipInput import org.koitharu.kotatsu.core.backup.CompositeResult import org.koitharu.kotatsu.core.backup.RestoreRepository import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.progress.Progress +import java.io.File +import java.io.FileNotFoundException class RestoreViewModel( uri: Uri?, @@ -40,10 +38,9 @@ class RestoreViewModel( input.copyTo(output) } } - BackupArchive(tempFile) + BackupZipInput(tempFile) } try { - backup.unpack() val result = CompositeResult() progress.value = Progress(0, 3) @@ -58,10 +55,8 @@ class RestoreViewModel( progress.value = Progress(3, 3) onRestoreDone.call(result) } finally { - withContext(NonCancellable) { - backup.cleanup() - backup.file.delete() - } + backup.close() + backup.file.delete() } } } diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/MutableZipFile.kt b/app/src/main/java/org/koitharu/kotatsu/utils/MutableZipFile.kt deleted file mode 100644 index 01eaf7118..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/utils/MutableZipFile.kt +++ /dev/null @@ -1,103 +0,0 @@ -package org.koitharu.kotatsu.utils - -import androidx.annotation.WorkerThread -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.runInterruptible -import kotlinx.coroutines.withContext -import java.io.File -import java.io.FileInputStream -import java.io.FileOutputStream -import java.util.zip.ZipEntry -import java.util.zip.ZipInputStream -import java.util.zip.ZipOutputStream - -open class MutableZipFile(val file: File) { - - protected val dir = File(file.parentFile, file.nameWithoutExtension) - - suspend fun unpack(): Unit = runInterruptible(Dispatchers.IO) { - check(dir.list().isNullOrEmpty()) { - "Dir ${dir.name} is not empty" - } - if (!dir.exists()) { - dir.mkdir() - } - if (!file.exists()) { - return@runInterruptible - } - ZipInputStream(FileInputStream(file)).use { zip -> - var entry = zip.nextEntry - while (entry != null) { - val target = File(dir.path + File.separator + entry.name) - target.parentFile?.mkdirs() - target.outputStream().use { out -> - zip.copyTo(out) - } - zip.closeEntry() - entry = zip.nextEntry - } - } - } - - suspend fun cleanup() = withContext(Dispatchers.IO) { - dir.deleteRecursively() - } - - suspend fun flush(): Boolean = runInterruptible(Dispatchers.IO) { - val tempFile = File(file.path + ".tmp") - if (tempFile.exists()) { - tempFile.delete() - } - try { - ZipOutputStream(FileOutputStream(tempFile)).use { zip -> - dir.listFiles()?.forEach { - zipFile(it, it.name, zip) - } - zip.flush() - } - tempFile.renameTo(file) - } finally { - if (tempFile.exists()) { - tempFile.delete() - } - } - } - - operator fun get(name: String) = File(dir, name) - - suspend fun put(name: String, file: File): Unit = withContext(Dispatchers.IO) { - file.copyTo(this@MutableZipFile[name], overwrite = true) - } - - suspend fun put(name: String, data: String): Unit = withContext(Dispatchers.IO) { - this@MutableZipFile[name].writeText(data) - } - - suspend fun getContent(name: String): String = withContext(Dispatchers.IO) { - get(name).readText() - } - - companion object { - - @WorkerThread - private fun zipFile(fileToZip: File, fileName: String, zipOut: ZipOutputStream) { - if (fileToZip.isDirectory) { - if (fileName.endsWith("/")) { - zipOut.putNextEntry(ZipEntry(fileName)) - } else { - zipOut.putNextEntry(ZipEntry("$fileName/")) - } - zipOut.closeEntry() - fileToZip.listFiles()?.forEach { childFile -> - zipFile(childFile, "$fileName/${childFile.name}", zipOut) - } - } else { - FileInputStream(fileToZip).use { fis -> - val zipEntry = ZipEntry(fileName) - zipOut.putNextEntry(zipEntry) - fis.copyTo(zipOut) - } - } - } - } -} \ No newline at end of file