Download manga to cbz directly
This commit is contained in:
@@ -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 = []
|
||||||
|
|||||||
@@ -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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
61
app/src/main/java/org/koitharu/kotatsu/core/zip/ZipOutput.kt
Normal file
61
app/src/main/java/org/koitharu/kotatsu/core/zip/ZipOutput.kt
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
37
app/src/main/java/org/koitharu/kotatsu/core/zip/ZipUtils.kt
Normal file
37
app/src/main/java/org/koitharu/kotatsu/core/zip/ZipUtils.kt
Normal 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()
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|
||||||
|
|||||||
@@ -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())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user