Download manga to cbz directly

This commit is contained in:
Koitharu
2022-04-18 16:42:37 +03:00
parent aaf9c6a0bf
commit d3e9ce874a
18 changed files with 340 additions and 394 deletions

View File

@@ -6,14 +6,14 @@ plugins {
} }
android { android {
compileSdkVersion 32 compileSdkVersion 31
buildToolsVersion '32.0.0' buildToolsVersion '31.0.0'
namespace 'org.koitharu.kotatsu' namespace 'org.koitharu.kotatsu'
defaultConfig { defaultConfig {
applicationId 'org.koitharu.kotatsu' applicationId 'org.koitharu.kotatsu'
minSdkVersion 21 minSdkVersion 21
targetSdkVersion 32 targetSdkVersion 31
versionCode 402 versionCode 402
versionName '3.1.1' versionName '3.1.1'
generatedDensities = [] generatedDensities = []

View File

@@ -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))
}
}
}

View File

@@ -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()
}
}

View File

@@ -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))
}

View File

@@ -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<String>()
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
}
}
}

View File

@@ -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()
}

View File

@@ -7,7 +7,6 @@ import android.webkit.MimeTypeMap
import coil.ImageLoader import coil.ImageLoader
import coil.request.ImageRequest import coil.request.ImageRequest
import coil.size.Scale import coil.size.Scale
import java.io.File
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.Semaphore
@@ -18,8 +17,8 @@ import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.network.CommonHeaders import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.parser.MangaRepository 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.data.PagesCache
import org.koitharu.kotatsu.local.domain.CbzMangaOutput
import org.koitharu.kotatsu.local.domain.LocalMangaRepository import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource 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.referer
import org.koitharu.kotatsu.utils.ext.waitForNetwork import org.koitharu.kotatsu.utils.ext.waitForNetwork
import org.koitharu.kotatsu.utils.progress.ProgressJob import org.koitharu.kotatsu.utils.progress.ProgressJob
import java.io.File
private const val MAX_DOWNLOAD_ATTEMPTS = 3 private const val MAX_DOWNLOAD_ATTEMPTS = 3
private const val MAX_PARALLEL_DOWNLOADS = 2 private const val MAX_PARALLEL_DOWNLOADS = 2
@@ -80,7 +80,7 @@ class DownloadManager(
var cover: Drawable? = null var cover: Drawable? = null
val destination = localMangaRepository.getOutputDir() val destination = localMangaRepository.getOutputDir()
checkNotNull(destination) { context.getString(R.string.cannot_find_available_storage) } checkNotNull(destination) { context.getString(R.string.cannot_find_available_storage) }
var output: MangaZip? = null var output: CbzMangaOutput? = null
try { try {
if (manga.source == MangaSource.LOCAL) { if (manga.source == MangaSource.LOCAL) {
manga = localMangaRepository.getRemoteManga(manga) ?: error("Cannot obtain remote manga instance") manga = localMangaRepository.getRemoteManga(manga) ?: error("Cannot obtain remote manga instance")
@@ -98,8 +98,7 @@ class DownloadManager(
}.getOrNull() }.getOrNull()
outState.value = DownloadState.Preparing(startId, manga, cover) outState.value = DownloadState.Preparing(startId, manga, cover)
val data = if (manga.chapters.isNullOrEmpty()) repo.getDetails(manga) else manga val data = if (manga.chapters.isNullOrEmpty()) repo.getDetails(manga) else manga
output = MangaZip.findInDir(destination, data) output = CbzMangaOutput.createNew(destination, data)
output.prepare(data)
val coverUrl = data.largeCoverUrl ?: data.coverUrl val coverUrl = data.largeCoverUrl ?: data.coverUrl
downloadFile(coverUrl, data.publicUrl, destination).let { file -> downloadFile(coverUrl, data.publicUrl, destination).let { file ->
output.addCover(file, MimeTypeMap.getFileExtensionFromUrl(coverUrl)) output.addCover(file, MimeTypeMap.getFileExtensionFromUrl(coverUrl))
@@ -145,9 +144,8 @@ class DownloadManager(
} }
} }
outState.value = DownloadState.PostProcessing(startId, data, cover) outState.value = DownloadState.PostProcessing(startId, data, cover)
if (!output.compress()) { output.mergeWithExisting()
throw RuntimeException("Cannot create target file") output.finalize()
}
val localManga = localMangaRepository.getFromFile(output.file) val localManga = localMangaRepository.getFromFile(output.file)
outState.value = DownloadState.Done(startId, data, cover, localManga) outState.value = DownloadState.Done(startId, data, cover, localManga)
} catch (e: CancellationException) { } catch (e: CancellationException) {

View File

@@ -9,11 +9,11 @@ import coil.fetch.FetchResult
import coil.fetch.Fetcher import coil.fetch.Fetcher
import coil.fetch.SourceResult import coil.fetch.SourceResult
import coil.size.Size import coil.size.Size
import java.util.zip.ZipFile
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.runInterruptible
import okio.buffer import okio.buffer
import okio.source import okio.source
import java.util.zip.ZipFile
class CbzFetcher : Fetcher<Uri> { class CbzFetcher : Fetcher<Uri> {

View File

@@ -28,14 +28,17 @@ class MangaIndex(source: String?) {
json.put("state", manga.state?.name) json.put("state", manga.state?.name)
json.put("source", manga.source.name) json.put("source", manga.source.name)
json.put("cover_large", manga.largeCoverUrl) json.put("cover_large", manga.largeCoverUrl)
json.put("tags", JSONArray().also { a -> json.put(
for (tag in manga.tags) { "tags",
val jo = JSONObject() JSONArray().also { a ->
jo.put("key", tag.key) for (tag in manga.tags) {
jo.put("title", tag.title) val jo = JSONObject()
a.put(jo) jo.put("key", tag.key)
jo.put("title", tag.title)
a.put(jo)
}
} }
}) )
if (!append || !json.has("chapters")) { if (!append || !json.has("chapters")) {
json.put("chapters", JSONObject()) json.put("chapters", JSONObject())
} }

View File

@@ -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)
}
}
}

View File

@@ -2,13 +2,13 @@ package org.koitharu.kotatsu.local.data
import android.content.Context import android.content.Context
import com.tomclaw.cache.DiskLruCache import com.tomclaw.cache.DiskLruCache
import java.io.File
import java.io.InputStream
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import org.koitharu.kotatsu.parsers.util.longHashCode import org.koitharu.kotatsu.parsers.util.longHashCode
import org.koitharu.kotatsu.utils.FileSize import org.koitharu.kotatsu.utils.FileSize
import org.koitharu.kotatsu.utils.ext.subdir import org.koitharu.kotatsu.utils.ext.subdir
import org.koitharu.kotatsu.utils.ext.takeIfReadable import org.koitharu.kotatsu.utils.ext.takeIfReadable
import java.io.File
import java.io.InputStream
class PagesCache(context: Context) { class PagesCache(context: Context) {
@@ -60,4 +60,4 @@ class PagesCache(context: Context) {
progress.value = (bytesCopied.toDouble() / contentLength.toDouble()).toFloat() progress.value = (bytesCopied.toDouble() / contentLength.toDouble()).toFloat()
} }
} }
} }

View File

@@ -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)
}
}
}
}

View File

@@ -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)
}
}
}

View File

@@ -14,7 +14,6 @@ import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.local.data.CbzFilter import org.koitharu.kotatsu.local.data.CbzFilter
import org.koitharu.kotatsu.local.data.LocalStorageManager import org.koitharu.kotatsu.local.data.LocalStorageManager
import org.koitharu.kotatsu.local.data.MangaIndex 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.model.*
import org.koitharu.kotatsu.parsers.util.longHashCode import org.koitharu.kotatsu.parsers.util.longHashCode
import org.koitharu.kotatsu.parsers.util.toCamelCase 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 uri = Uri.parse(chapter.url)
val file = uri.toFile() val file = uri.toFile()
val zip = ZipFile(file) 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() var entries = zip.entries().asSequence()
entries = if (index != null) { entries = if (index != null) {
val pattern = index.getChapterNamesPattern(chapter) val pattern = index.getChapterNamesPattern(chapter)
@@ -97,7 +96,7 @@ class LocalMangaRepository(private val storageManager: LocalStorageManager) : Ma
@SuppressLint("DefaultLocale") @SuppressLint("DefaultLocale")
fun getFromFile(file: File): Manga = ZipFile(file).use { zip -> fun getFromFile(file: File): Manga = ZipFile(file).use { zip ->
val fileUri = file.toUri().toString() 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 index = entry?.let(zip::readText)?.let(::MangaIndex)
val info = index?.getMangaInfo() val info = index?.getMangaInfo()
if (index != null && info != null) { if (index != null && info != null) {
@@ -158,7 +157,7 @@ class LocalMangaRepository(private val storageManager: LocalStorageManager) : Ma
}.getOrNull() ?: return null }.getOrNull() ?: return null
return runInterruptible(Dispatchers.IO) { return runInterruptible(Dispatchers.IO) {
ZipFile(file).use { zip -> 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) val index = entry?.let(zip::readText)?.let(::MangaIndex)
index?.getMangaInfo() index?.getMangaInfo()
} }
@@ -170,7 +169,7 @@ class LocalMangaRepository(private val storageManager: LocalStorageManager) : Ma
return runInterruptible(Dispatchers.IO) { return runInterruptible(Dispatchers.IO) {
for (file in files) { for (file in files) {
val index = ZipFile(file).use { zip -> 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) entry?.let(zip::readText)?.let(::MangaIndex)
} ?: continue } ?: continue
val info = index.getMangaInfo() ?: continue val info = index.getMangaInfo() ?: continue

View File

@@ -5,12 +5,8 @@ import android.app.backup.BackupDataInput
import android.app.backup.BackupDataOutput import android.app.backup.BackupDataOutput
import android.app.backup.FullBackupDataOutput import android.app.backup.FullBackupDataOutput
import android.os.ParcelFileDescriptor import android.os.ParcelFileDescriptor
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.koitharu.kotatsu.core.backup.BackupArchive import org.koitharu.kotatsu.core.backup.*
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.db.MangaDatabase import org.koitharu.kotatsu.core.db.MangaDatabase
import java.io.* import java.io.*
@@ -46,7 +42,7 @@ class AppBackupAgent : BackupAgent() {
mode: Long, mode: Long,
mtime: Long mtime: Long
) { ) {
if (destination?.name?.endsWith(".bak") == true) { if (destination?.name?.endsWith(".bk.zip") == true) {
restoreBackupFile(data.fileDescriptor, size) restoreBackupFile(data.fileDescriptor, size)
destination.delete() destination.delete()
} else { } else {
@@ -56,14 +52,14 @@ class AppBackupAgent : BackupAgent() {
private fun createBackupFile() = runBlocking { private fun createBackupFile() = runBlocking {
val repository = BackupRepository(MangaDatabase.create(applicationContext)) val repository = BackupRepository(MangaDatabase.create(applicationContext))
val backup = BackupArchive.createNew(this@AppBackupAgent) BackupZipOutput(this@AppBackupAgent).use { backup ->
backup.put(repository.createIndex()) backup.put(repository.createIndex())
backup.put(repository.dumpHistory()) backup.put(repository.dumpHistory())
backup.put(repository.dumpCategories()) backup.put(repository.dumpCategories())
backup.put(repository.dumpFavourites()) backup.put(repository.dumpFavourites())
backup.flush() backup.finish()
backup.cleanup() backup.file
backup.file }
} }
private fun restoreBackupFile(fd: FileDescriptor, size: Long) { private fun restoreBackupFile(fd: FileDescriptor, size: Long) {
@@ -74,18 +70,15 @@ class AppBackupAgent : BackupAgent() {
input.copyLimitedTo(output, size) input.copyLimitedTo(output, size)
} }
} }
val backup = BackupArchive(tempFile) val backup = BackupZipInput(tempFile)
try { try {
runBlocking { runBlocking {
backup.unpack()
repository.upsertHistory(backup.getEntry(BackupEntry.HISTORY)) repository.upsertHistory(backup.getEntry(BackupEntry.HISTORY))
repository.upsertCategories(backup.getEntry(BackupEntry.CATEGORIES)) repository.upsertCategories(backup.getEntry(BackupEntry.CATEGORIES))
repository.upsertFavourites(backup.getEntry(BackupEntry.FAVOURITES)) repository.upsertFavourites(backup.getEntry(BackupEntry.FAVOURITES))
} }
} finally { } finally {
runBlocking(NonCancellable) { backup.close()
backup.cleanup()
}
tempFile.delete() tempFile.delete()
} }
} }

View File

@@ -3,8 +3,8 @@ package org.koitharu.kotatsu.settings.backup
import android.content.Context import android.content.Context
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import org.koitharu.kotatsu.base.ui.BaseViewModel 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.BackupRepository
import org.koitharu.kotatsu.core.backup.BackupZipOutput
import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.progress.Progress import org.koitharu.kotatsu.utils.progress.Progress
import java.io.File import java.io.File
@@ -19,23 +19,25 @@ class BackupViewModel(
init { init {
launchLoadingJob { launchLoadingJob {
val backup = BackupArchive.createNew(context) val file = BackupZipOutput(context).use { backup ->
backup.put(repository.createIndex()) backup.put(repository.createIndex())
progress.value = Progress(0, 3) progress.value = Progress(0, 3)
backup.put(repository.dumpHistory()) backup.put(repository.dumpHistory())
progress.value = Progress(1, 3) progress.value = Progress(1, 3)
backup.put(repository.dumpCategories()) backup.put(repository.dumpCategories())
progress.value = Progress(2, 3) progress.value = Progress(2, 3)
backup.put(repository.dumpFavourites()) backup.put(repository.dumpFavourites())
progress.value = Progress(3, 3) progress.value = Progress(3, 3)
backup.flush() backup.finish()
progress.value = null progress.value = null
backup.cleanup() backup.close()
onBackupDone.call(backup.file) backup.file
}
onBackupDone.call(file)
} }
} }
} }

View File

@@ -3,19 +3,17 @@ package org.koitharu.kotatsu.settings.backup
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import java.io.File
import java.io.FileNotFoundException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.base.ui.BaseViewModel 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.BackupEntry
import org.koitharu.kotatsu.core.backup.BackupZipInput
import org.koitharu.kotatsu.core.backup.CompositeResult import org.koitharu.kotatsu.core.backup.CompositeResult
import org.koitharu.kotatsu.core.backup.RestoreRepository import org.koitharu.kotatsu.core.backup.RestoreRepository
import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.progress.Progress import org.koitharu.kotatsu.utils.progress.Progress
import java.io.File
import java.io.FileNotFoundException
class RestoreViewModel( class RestoreViewModel(
uri: Uri?, uri: Uri?,
@@ -40,10 +38,9 @@ class RestoreViewModel(
input.copyTo(output) input.copyTo(output)
} }
} }
BackupArchive(tempFile) BackupZipInput(tempFile)
} }
try { try {
backup.unpack()
val result = CompositeResult() val result = CompositeResult()
progress.value = Progress(0, 3) progress.value = Progress(0, 3)
@@ -58,10 +55,8 @@ class RestoreViewModel(
progress.value = Progress(3, 3) progress.value = Progress(3, 3)
onRestoreDone.call(result) onRestoreDone.call(result)
} finally { } finally {
withContext(NonCancellable) { backup.close()
backup.cleanup() backup.file.delete()
backup.file.delete()
}
} }
} }
} }

View File

@@ -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)
}
}
}
}
}