Merge branch 'feature/direct-download' into devel
commit
c220bd5517
@ -0,0 +1,37 @@
|
|||||||
|
package org.koitharu.kotatsu.base.ui
|
||||||
|
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
abstract class CoroutineIntentService : BaseService() {
|
||||||
|
|
||||||
|
private val mutex = Mutex()
|
||||||
|
protected open val dispatcher: CoroutineDispatcher = Dispatchers.Default
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
super.onStartCommand(intent, flags, startId)
|
||||||
|
launchCoroutine(intent, startId)
|
||||||
|
return Service.START_REDELIVER_INTENT
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun launchCoroutine(intent: Intent?, startId: Int) = lifecycleScope.launch {
|
||||||
|
mutex.withLock {
|
||||||
|
try {
|
||||||
|
withContext(dispatcher) {
|
||||||
|
processIntent(intent)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
stopSelf(startId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected abstract suspend fun processIntent(intent: Intent?)
|
||||||
|
}
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
package org.koitharu.kotatsu.base.ui.util
|
||||||
|
|
||||||
|
import androidx.lifecycle.MutableLiveData
|
||||||
|
|
||||||
|
class CountedBooleanLiveData : MutableLiveData<Boolean>(false) {
|
||||||
|
|
||||||
|
private var counter = 0
|
||||||
|
|
||||||
|
override fun setValue(value: Boolean) {
|
||||||
|
if (value) {
|
||||||
|
counter++
|
||||||
|
} else {
|
||||||
|
counter--
|
||||||
|
}
|
||||||
|
val newValue = counter > 0
|
||||||
|
if (newValue != this.value) {
|
||||||
|
super.setValue(newValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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))
|
||||||
|
}
|
||||||
@ -0,0 +1,118 @@
|
|||||||
|
package org.koitharu.kotatsu.core.zip
|
||||||
|
|
||||||
|
import androidx.annotation.WorkerThread
|
||||||
|
import androidx.collection.ArraySet
|
||||||
|
import okio.Closeable
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileInputStream
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
@WorkerThread
|
||||||
|
fun put(name: String, file: File): Boolean {
|
||||||
|
return output.appendFile(file, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
@WorkerThread
|
||||||
|
fun put(name: String, content: String): Boolean {
|
||||||
|
return output.appendText(content, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
@WorkerThread
|
||||||
|
fun addDirectory(name: String): Boolean {
|
||||||
|
val entry = if (name.endsWith("/")) {
|
||||||
|
ZipEntry(name)
|
||||||
|
} else {
|
||||||
|
ZipEntry("$name/")
|
||||||
|
}
|
||||||
|
return if (entryNames.add(entry.name)) {
|
||||||
|
output.putNextEntry(entry)
|
||||||
|
output.closeEntry()
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun finish() {
|
||||||
|
output.finish()
|
||||||
|
output.flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
if (!isClosed) {
|
||||||
|
output.close()
|
||||||
|
isClosed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@WorkerThread
|
||||||
|
private fun ZipOutputStream.appendFile(fileToZip: File, name: String): Boolean {
|
||||||
|
if (fileToZip.isDirectory) {
|
||||||
|
val entry = if (name.endsWith("/")) {
|
||||||
|
ZipEntry(name)
|
||||||
|
} else {
|
||||||
|
ZipEntry("$name/")
|
||||||
|
}
|
||||||
|
if (!entryNames.add(entry.name)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
putNextEntry(entry)
|
||||||
|
closeEntry()
|
||||||
|
fileToZip.listFiles()?.forEach { childFile ->
|
||||||
|
appendFile(childFile, "$name/${childFile.name}")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
FileInputStream(fileToZip).use { fis ->
|
||||||
|
if (!entryNames.add(name)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
val zipEntry = ZipEntry(name)
|
||||||
|
putNextEntry(zipEntry)
|
||||||
|
fis.copyTo(this)
|
||||||
|
closeEntry()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
@WorkerThread
|
||||||
|
private fun ZipOutputStream.appendText(content: String, name: String): Boolean {
|
||||||
|
if (!entryNames.add(name)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
val zipEntry = ZipEntry(name)
|
||||||
|
putNextEntry(zipEntry)
|
||||||
|
content.byteInputStream().copyTo(this)
|
||||||
|
closeEntry()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
package org.koitharu.kotatsu.local.data
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FilenameFilter
|
||||||
|
|
||||||
|
class TempFileFilter : FilenameFilter {
|
||||||
|
|
||||||
|
override fun accept(dir: File, name: String): Boolean {
|
||||||
|
return name.endsWith(".tmp", ignoreCase = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,153 @@
|
|||||||
|
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 org.koitharu.kotatsu.utils.ext.readText
|
||||||
|
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, 0))
|
||||||
|
if (ext.isNotEmpty() && ext.length <= 4) {
|
||||||
|
append('.')
|
||||||
|
append(ext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
runInterruptible(Dispatchers.IO) {
|
||||||
|
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.branch.hashCode(), chapter.number, pageNumber))
|
||||||
|
if (ext.isNotEmpty() && ext.length <= 4) {
|
||||||
|
append('.')
|
||||||
|
append(ext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
runInterruptible(Dispatchers.IO) {
|
||||||
|
output.put(name, file)
|
||||||
|
}
|
||||||
|
index.addChapter(chapter)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun finalize() {
|
||||||
|
runInterruptible(Dispatchers.IO) {
|
||||||
|
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 = "%08d_%03d%03d"
|
||||||
|
|
||||||
|
const val ENTRY_NAME_INDEX = "index.json"
|
||||||
|
|
||||||
|
fun get(root: File, manga: Manga): CbzMangaOutput {
|
||||||
|
val name = manga.title.toFileNameSafe() + ".cbz"
|
||||||
|
val file = File(root, name)
|
||||||
|
return CbzMangaOutput(file, manga)
|
||||||
|
}
|
||||||
|
|
||||||
|
@WorkerThread
|
||||||
|
fun filterChapters(subject: CbzMangaOutput, idsToRemove: Set<Long>) {
|
||||||
|
ZipFile(subject.file).use { zip ->
|
||||||
|
val index = MangaIndex(zip.readText(zip.getEntry(ENTRY_NAME_INDEX)))
|
||||||
|
idsToRemove.forEach { id -> index.removeChapter(id) }
|
||||||
|
val patterns = requireNotNull(index.getMangaInfo()?.chapters).map {
|
||||||
|
index.getChapterNamesPattern(it)
|
||||||
|
}
|
||||||
|
val coverEntryName = index.getCoverEntry()
|
||||||
|
for (entry in zip.entries()) {
|
||||||
|
when {
|
||||||
|
entry.name == ENTRY_NAME_INDEX -> {
|
||||||
|
subject.output.put(ENTRY_NAME_INDEX, index.toString())
|
||||||
|
}
|
||||||
|
entry.isDirectory -> {
|
||||||
|
subject.output.addDirectory(entry.name)
|
||||||
|
}
|
||||||
|
entry.name == coverEntryName -> {
|
||||||
|
subject.output.copyEntryFrom(zip, entry)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
val name = entry.name.substringBefore('.')
|
||||||
|
if (patterns.any { it.matches(name) }) {
|
||||||
|
subject.output.copyEntryFrom(zip, entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
subject.output.finish()
|
||||||
|
subject.output.close()
|
||||||
|
subject.file.delete()
|
||||||
|
subject.output.file.renameTo(subject.file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,80 @@
|
|||||||
|
package org.koitharu.kotatsu.local.ui
|
||||||
|
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.ServiceCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import org.koin.android.ext.android.inject
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.base.ui.CoroutineIntentService
|
||||||
|
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
||||||
|
import org.koitharu.kotatsu.download.ui.service.DownloadService
|
||||||
|
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
|
||||||
|
class LocalChaptersRemoveService : CoroutineIntentService() {
|
||||||
|
|
||||||
|
private val localMangaRepository by inject<LocalMangaRepository>()
|
||||||
|
|
||||||
|
override suspend fun processIntent(intent: Intent?) {
|
||||||
|
val manga = intent?.getParcelableExtra<ParcelableManga>(EXTRA_MANGA)?.manga ?: return
|
||||||
|
val chaptersIds = intent.getLongArrayExtra(EXTRA_CHAPTERS_IDS)?.toSet() ?: return
|
||||||
|
startForeground()
|
||||||
|
val mangaWithChapters = localMangaRepository.getDetails(manga)
|
||||||
|
localMangaRepository.deleteChapters(mangaWithChapters, chaptersIds)
|
||||||
|
sendBroadcast(
|
||||||
|
Intent(DownloadService.ACTION_DOWNLOAD_COMPLETE)
|
||||||
|
.putExtra(EXTRA_MANGA, ParcelableManga(manga, withChapters = false))
|
||||||
|
)
|
||||||
|
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startForeground() {
|
||||||
|
val title = getString(R.string.local_manga_processing)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
val channel = NotificationChannel(CHANNEL_ID, title, NotificationManager.IMPORTANCE_LOW)
|
||||||
|
channel.setShowBadge(false)
|
||||||
|
channel.enableVibration(false)
|
||||||
|
channel.setSound(null, null)
|
||||||
|
channel.enableLights(false)
|
||||||
|
manager.createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
|
||||||
|
.setContentTitle(title)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_MIN)
|
||||||
|
.setDefaults(0)
|
||||||
|
.setColor(ContextCompat.getColor(this, R.color.blue_primary_dark))
|
||||||
|
.setSilent(true)
|
||||||
|
.setProgress(0, 0, true)
|
||||||
|
.setSmallIcon(android.R.drawable.stat_notify_sync)
|
||||||
|
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_DEFERRED)
|
||||||
|
.setOngoing(true)
|
||||||
|
.build()
|
||||||
|
startForeground(NOTIFICATION_ID, notification)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
private const val CHANNEL_ID = "local_processing"
|
||||||
|
private const val NOTIFICATION_ID = 21
|
||||||
|
|
||||||
|
private const val EXTRA_MANGA = "manga"
|
||||||
|
private const val EXTRA_CHAPTERS_IDS = "chapters_ids"
|
||||||
|
|
||||||
|
fun start(context: Context, manga: Manga, chaptersIds: Collection<Long>) {
|
||||||
|
if (chaptersIds.isEmpty()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val intent = Intent(context, LocalChaptersRemoveService::class.java)
|
||||||
|
intent.putExtra(EXTRA_MANGA, ParcelableManga(manga, withChapters = false))
|
||||||
|
intent.putExtra(EXTRA_CHAPTERS_IDS, chaptersIds.toLongArray())
|
||||||
|
ContextCompat.startForegroundService(context, intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -0,0 +1,50 @@
|
|||||||
|
package org.koitharu.kotatsu.utils
|
||||||
|
|
||||||
|
import androidx.annotation.MainThread
|
||||||
|
import java.util.concurrent.ConcurrentLinkedQueue
|
||||||
|
import kotlin.coroutines.CoroutineContext
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.Runnable
|
||||||
|
|
||||||
|
class PausingDispatcher(
|
||||||
|
private val dispatcher: CoroutineDispatcher,
|
||||||
|
) : CoroutineDispatcher() {
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var isPaused = false
|
||||||
|
private val queue = ConcurrentLinkedQueue<Task>()
|
||||||
|
|
||||||
|
override fun isDispatchNeeded(context: CoroutineContext): Boolean {
|
||||||
|
return isPaused || super.isDispatchNeeded(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun dispatch(context: CoroutineContext, block: Runnable) {
|
||||||
|
if (isPaused) {
|
||||||
|
queue.add(Task(context, block))
|
||||||
|
} else {
|
||||||
|
dispatcher.dispatch(context, block)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainThread
|
||||||
|
fun pause() {
|
||||||
|
isPaused = true
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainThread
|
||||||
|
fun resume() {
|
||||||
|
if (!isPaused) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
isPaused = false
|
||||||
|
while (true) {
|
||||||
|
val task = queue.poll() ?: break
|
||||||
|
dispatcher.dispatch(task.context, task.block)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class Task(
|
||||||
|
val context: CoroutineContext,
|
||||||
|
val block: Runnable,
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,47 @@
|
|||||||
|
package org.koitharu.kotatsu.utils.progress
|
||||||
|
|
||||||
|
import android.os.SystemClock
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
import kotlin.math.roundToLong
|
||||||
|
|
||||||
|
private const val MIN_ESTIMATE_TICKS = 4
|
||||||
|
private const val NO_TIME = -1L
|
||||||
|
|
||||||
|
class TimeLeftEstimator {
|
||||||
|
|
||||||
|
private var times = ArrayList<Int>()
|
||||||
|
private var lastTick: Tick? = null
|
||||||
|
|
||||||
|
fun tick(value: Int, total: Int) {
|
||||||
|
if (total < 0) {
|
||||||
|
emptyTick()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val tick = Tick(value, total, SystemClock.elapsedRealtime())
|
||||||
|
lastTick?.let {
|
||||||
|
val ticksCount = value - it.value
|
||||||
|
times.add(((tick.time - it.time) / ticksCount.toDouble()).roundToInt())
|
||||||
|
}
|
||||||
|
lastTick = tick
|
||||||
|
}
|
||||||
|
|
||||||
|
fun emptyTick() {
|
||||||
|
lastTick = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getEstimatedTimeLeft(): Long {
|
||||||
|
val progress = lastTick ?: return NO_TIME
|
||||||
|
if (times.size < MIN_ESTIMATE_TICKS) {
|
||||||
|
return NO_TIME
|
||||||
|
}
|
||||||
|
val timePerTick = times.average()
|
||||||
|
val ticksLeft = progress.total - progress.value
|
||||||
|
return (ticksLeft * timePerTick).roundToLong()
|
||||||
|
}
|
||||||
|
|
||||||
|
private class Tick(
|
||||||
|
val value: Int,
|
||||||
|
val total: Int,
|
||||||
|
val time: Long,
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:tint="?colorControlNormal"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#000"
|
||||||
|
android:pathData="M14,19H18V5H14M6,19H10V5H6V19Z" />
|
||||||
|
</vector>
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:tint="?colorControlNormal"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#000"
|
||||||
|
android:pathData="M8,5.14V19.14L19,12.14L8,5.14Z" />
|
||||||
|
</vector>
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<menu
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||||
|
|
||||||
|
<item
|
||||||
|
android:icon="@drawable/ic_pause"
|
||||||
|
android:id="@+id/action_pause"
|
||||||
|
android:title="Pause"
|
||||||
|
app:showAsAction="ifRoom|withText" />
|
||||||
|
|
||||||
|
<item
|
||||||
|
android:icon="@drawable/ic_resume"
|
||||||
|
android:id="@+id/action_resume"
|
||||||
|
android:title="Resume"
|
||||||
|
app:showAsAction="ifRoom|withText" />
|
||||||
|
|
||||||
|
</menu>
|
||||||
Loading…
Reference in New Issue