From d3e9ce874a663179da2fcd6a91af94a7a07128e8 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Mon, 18 Apr 2022 16:42:37 +0300 Subject: [PATCH 01/20] Download manga to cbz directly --- app/build.gradle | 6 +- .../kotatsu/core/backup/BackupArchive.kt | 51 -------- .../kotatsu/core/backup/BackupZipInput.kt | 25 ++++ .../kotatsu/core/backup/BackupZipOutput.kt | 45 +++++++ .../koitharu/kotatsu/core/zip/ZipOutput.kt | 61 ++++++++++ .../org/koitharu/kotatsu/core/zip/ZipUtils.kt | 37 ++++++ .../download/domain/DownloadManager.kt | 14 +-- .../koitharu/kotatsu/local/data/CbzFetcher.kt | 2 +- .../koitharu/kotatsu/local/data/MangaIndex.kt | 17 +-- .../koitharu/kotatsu/local/data/MangaZip.kt | 70 ----------- .../koitharu/kotatsu/local/data/PagesCache.kt | 6 +- .../kotatsu/local/data/WritableCbzFile.kt | 99 ---------------- .../kotatsu/local/domain/CbzMangaOutput.kt | 111 ++++++++++++++++++ .../local/domain/LocalMangaRepository.kt | 9 +- .../kotatsu/settings/backup/AppBackupAgent.kt | 31 ++--- .../settings/backup/BackupViewModel.kt | 30 ++--- .../settings/backup/RestoreViewModel.kt | 17 +-- .../koitharu/kotatsu/utils/MutableZipFile.kt | 103 ---------------- 18 files changed, 340 insertions(+), 394 deletions(-) delete mode 100644 app/src/main/java/org/koitharu/kotatsu/core/backup/BackupArchive.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/core/backup/BackupZipInput.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/core/backup/BackupZipOutput.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/core/zip/ZipOutput.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/core/zip/ZipUtils.kt delete mode 100644 app/src/main/java/org/koitharu/kotatsu/local/data/MangaZip.kt delete mode 100644 app/src/main/java/org/koitharu/kotatsu/local/data/WritableCbzFile.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/local/domain/CbzMangaOutput.kt delete mode 100644 app/src/main/java/org/koitharu/kotatsu/utils/MutableZipFile.kt diff --git a/app/build.gradle b/app/build.gradle index 4cd4b8cf9..69e2b2373 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -6,14 +6,14 @@ plugins { } android { - compileSdkVersion 32 - buildToolsVersion '32.0.0' + compileSdkVersion 31 + buildToolsVersion '31.0.0' namespace 'org.koitharu.kotatsu' defaultConfig { applicationId 'org.koitharu.kotatsu' minSdkVersion 21 - targetSdkVersion 32 + targetSdkVersion 31 versionCode 402 versionName '3.1.1' generatedDensities = [] diff --git a/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupArchive.kt b/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupArchive.kt deleted file mode 100644 index 6a90243fa..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupArchive.kt +++ /dev/null @@ -1,51 +0,0 @@ -package org.koitharu.kotatsu.core.backup - -import android.content.Context -import java.io.File -import java.util.* -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.runInterruptible -import kotlinx.coroutines.withContext -import org.json.JSONArray -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.utils.MutableZipFile -import org.koitharu.kotatsu.utils.ext.format - -class BackupArchive(file: File) : MutableZipFile(file) { - - init { - if (!dir.exists()) { - dir.mkdirs() - } - } - - suspend fun put(entry: BackupEntry) { - put(entry.name, entry.data.toString(2)) - } - - suspend fun getEntry(name: String): BackupEntry { - val json = withContext(Dispatchers.Default) { - JSONArray(getContent(name)) - } - return BackupEntry(name, json) - } - - companion object { - - private const val DIR_BACKUPS = "backups" - - suspend fun createNew(context: Context): BackupArchive = runInterruptible(Dispatchers.IO) { - val dir = context.run { - getExternalFilesDir(DIR_BACKUPS) ?: File(filesDir, DIR_BACKUPS) - } - dir.mkdirs() - val filename = buildString { - append(context.getString(R.string.app_name).lowercase(Locale.ROOT)) - append('_') - append(Date().format("ddMMyyyy")) - append(".bak") - } - BackupArchive(File(dir, filename)) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupZipInput.kt b/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupZipInput.kt new file mode 100644 index 000000000..25e1d3688 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupZipInput.kt @@ -0,0 +1,25 @@ +package org.koitharu.kotatsu.core.backup + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runInterruptible +import okio.Closeable +import org.json.JSONArray +import java.io.File +import java.util.zip.ZipFile + +class BackupZipInput(val file: File) : Closeable { + + private val zipFile = ZipFile(file) + + suspend fun getEntry(name: String): BackupEntry = runInterruptible(Dispatchers.IO) { + val entry = zipFile.getEntry(name) + val json = zipFile.getInputStream(entry).use { + JSONArray(it.bufferedReader().readText()) + } + BackupEntry(name, json) + } + + override fun close() { + zipFile.close() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupZipOutput.kt b/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupZipOutput.kt new file mode 100644 index 000000000..f01dc73d9 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupZipOutput.kt @@ -0,0 +1,45 @@ +package org.koitharu.kotatsu.core.backup + +import android.content.Context +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runInterruptible +import okio.Closeable +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.zip.ZipOutput +import org.koitharu.kotatsu.utils.ext.format +import java.io.File +import java.util.* +import java.util.zip.Deflater + +class BackupZipOutput(val file: File) : Closeable { + + private val output = ZipOutput(file, Deflater.BEST_COMPRESSION) + + suspend fun put(entry: BackupEntry) { + output.put(entry.name, entry.data.toString(2)) + } + + suspend fun finish() { + output.finish() + } + + override fun close() { + output.close() + } +} + +private const val DIR_BACKUPS = "backups" + +suspend fun BackupZipOutput(context: Context): BackupZipOutput = runInterruptible(Dispatchers.IO) { + val dir = context.run { + getExternalFilesDir(DIR_BACKUPS) ?: File(filesDir, DIR_BACKUPS) + } + dir.mkdirs() + val filename = buildString { + append(context.getString(R.string.app_name).replace(' ', '_').lowercase(Locale.ROOT)) + append('_') + append(Date().format("ddMMyyyy")) + append(".bk.zip") + } + BackupZipOutput(File(dir, filename)) +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/zip/ZipOutput.kt b/app/src/main/java/org/koitharu/kotatsu/core/zip/ZipOutput.kt new file mode 100644 index 000000000..bcdc5b0ee --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/core/zip/ZipOutput.kt @@ -0,0 +1,61 @@ +package org.koitharu.kotatsu.core.zip + +import androidx.annotation.WorkerThread +import androidx.collection.ArraySet +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runInterruptible +import okio.Closeable +import java.io.File +import java.util.zip.Deflater +import java.util.zip.ZipEntry +import java.util.zip.ZipFile +import java.util.zip.ZipOutputStream + +class ZipOutput( + val file: File, + compressionLevel: Int = Deflater.DEFAULT_COMPRESSION, +) : Closeable { + + private val entryNames = ArraySet() + private var isClosed = false + private val output = ZipOutputStream(file.outputStream()).apply { + setLevel(compressionLevel) + } + + suspend fun put(name: String, file: File): Unit = runInterruptible(Dispatchers.IO) { + entryNames.add(name) + output.appendFile(file, name) + } + + suspend fun put(name: String, content: String): Unit = runInterruptible(Dispatchers.IO) { + entryNames.add(name) + output.appendText(content, name) + } + + @WorkerThread + fun copyEntryFrom(other: ZipFile, entry: ZipEntry): Boolean { + return if (entryNames.add(entry.name)) { + val zipEntry = ZipEntry(entry.name) + output.putNextEntry(zipEntry) + other.getInputStream(entry).use { input -> + input.copyTo(output) + } + output.closeEntry() + true + } else { + false + } + } + + suspend fun finish() = runInterruptible(Dispatchers.IO) { + output.finish() + output.flush() + } + + override fun close() { + if (!isClosed) { + output.close() + isClosed = true + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/zip/ZipUtils.kt b/app/src/main/java/org/koitharu/kotatsu/core/zip/ZipUtils.kt new file mode 100644 index 000000000..c42f47142 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/core/zip/ZipUtils.kt @@ -0,0 +1,37 @@ +package org.koitharu.kotatsu.core.zip + +import androidx.annotation.WorkerThread +import java.io.File +import java.io.FileInputStream +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +@WorkerThread +fun ZipOutputStream.appendFile(fileToZip: File, name: String) { + if (fileToZip.isDirectory) { + if (name.endsWith("/")) { + putNextEntry(ZipEntry(name)) + } else { + putNextEntry(ZipEntry("$name/")) + } + closeEntry() + fileToZip.listFiles()?.forEach { childFile -> + appendFile(childFile, "$name/${childFile.name}") + } + } else { + FileInputStream(fileToZip).use { fis -> + val zipEntry = ZipEntry(name) + putNextEntry(zipEntry) + fis.copyTo(this) + closeEntry() + } + } +} + +@WorkerThread +fun ZipOutputStream.appendText(content: String, name: String) { + val zipEntry = ZipEntry(name) + putNextEntry(zipEntry) + content.byteInputStream().copyTo(this) + closeEntry() +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt b/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt index 3373273ea..aba04e350 100644 --- a/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt +++ b/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt @@ -7,7 +7,6 @@ import android.webkit.MimeTypeMap import coil.ImageLoader import coil.request.ImageRequest import coil.size.Scale -import java.io.File import kotlinx.coroutines.* import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.sync.Semaphore @@ -18,8 +17,8 @@ import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.network.CommonHeaders import org.koitharu.kotatsu.core.parser.MangaRepository -import org.koitharu.kotatsu.local.data.MangaZip import org.koitharu.kotatsu.local.data.PagesCache +import org.koitharu.kotatsu.local.domain.CbzMangaOutput import org.koitharu.kotatsu.local.domain.LocalMangaRepository import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaSource @@ -28,6 +27,7 @@ import org.koitharu.kotatsu.utils.ext.deleteAwait import org.koitharu.kotatsu.utils.ext.referer import org.koitharu.kotatsu.utils.ext.waitForNetwork import org.koitharu.kotatsu.utils.progress.ProgressJob +import java.io.File private const val MAX_DOWNLOAD_ATTEMPTS = 3 private const val MAX_PARALLEL_DOWNLOADS = 2 @@ -80,7 +80,7 @@ class DownloadManager( var cover: Drawable? = null val destination = localMangaRepository.getOutputDir() checkNotNull(destination) { context.getString(R.string.cannot_find_available_storage) } - var output: MangaZip? = null + var output: CbzMangaOutput? = null try { if (manga.source == MangaSource.LOCAL) { manga = localMangaRepository.getRemoteManga(manga) ?: error("Cannot obtain remote manga instance") @@ -98,8 +98,7 @@ class DownloadManager( }.getOrNull() outState.value = DownloadState.Preparing(startId, manga, cover) val data = if (manga.chapters.isNullOrEmpty()) repo.getDetails(manga) else manga - output = MangaZip.findInDir(destination, data) - output.prepare(data) + output = CbzMangaOutput.createNew(destination, data) val coverUrl = data.largeCoverUrl ?: data.coverUrl downloadFile(coverUrl, data.publicUrl, destination).let { file -> output.addCover(file, MimeTypeMap.getFileExtensionFromUrl(coverUrl)) @@ -145,9 +144,8 @@ class DownloadManager( } } outState.value = DownloadState.PostProcessing(startId, data, cover) - if (!output.compress()) { - throw RuntimeException("Cannot create target file") - } + output.mergeWithExisting() + output.finalize() val localManga = localMangaRepository.getFromFile(output.file) outState.value = DownloadState.Done(startId, data, cover, localManga) } catch (e: CancellationException) { diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/CbzFetcher.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/CbzFetcher.kt index 4e2746cec..ff04a2eff 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/data/CbzFetcher.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/data/CbzFetcher.kt @@ -9,11 +9,11 @@ import coil.fetch.FetchResult import coil.fetch.Fetcher import coil.fetch.SourceResult import coil.size.Size +import java.util.zip.ZipFile import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runInterruptible import okio.buffer import okio.source -import java.util.zip.ZipFile class CbzFetcher : Fetcher { diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/MangaIndex.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/MangaIndex.kt index f7d1a8aaf..07bbbce98 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/data/MangaIndex.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/data/MangaIndex.kt @@ -28,14 +28,17 @@ class MangaIndex(source: String?) { json.put("state", manga.state?.name) json.put("source", manga.source.name) json.put("cover_large", manga.largeCoverUrl) - json.put("tags", JSONArray().also { a -> - for (tag in manga.tags) { - val jo = JSONObject() - jo.put("key", tag.key) - jo.put("title", tag.title) - a.put(jo) + json.put( + "tags", + JSONArray().also { a -> + for (tag in manga.tags) { + val jo = JSONObject() + jo.put("key", tag.key) + jo.put("title", tag.title) + a.put(jo) + } } - }) + ) if (!append || !json.has("chapters")) { json.put("chapters", JSONObject()) } diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/MangaZip.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/MangaZip.kt deleted file mode 100644 index 0aacb4ee7..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/local/data/MangaZip.kt +++ /dev/null @@ -1,70 +0,0 @@ -package org.koitharu.kotatsu.local.data - -import androidx.annotation.CheckResult -import androidx.annotation.WorkerThread -import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.parsers.model.MangaChapter -import org.koitharu.kotatsu.parsers.util.toFileNameSafe -import org.koitharu.kotatsu.utils.ext.takeIfReadable -import java.io.File - -@WorkerThread -class MangaZip(val file: File) { - - private val writableCbz = WritableCbzFile(file) - - private var index = MangaIndex(null) - - suspend fun prepare(manga: Manga) { - writableCbz.prepare(overwrite = true) - index = MangaIndex(writableCbz[INDEX_ENTRY].takeIfReadable()?.readText()) - index.setMangaInfo(manga, append = true) - } - - suspend fun cleanup() { - writableCbz.cleanup() - } - - @CheckResult - suspend fun compress(): Boolean { - writableCbz[INDEX_ENTRY].writeText(index.toString()) - return writableCbz.flush() - } - - suspend fun addCover(file: File, ext: String) { - val name = buildString { - append(FILENAME_PATTERN.format(0, 0)) - if (ext.isNotEmpty() && ext.length <= 4) { - append('.') - append(ext) - } - } - writableCbz.put(name, file) - index.setCoverEntry(name) - } - - suspend fun addPage(chapter: MangaChapter, file: File, pageNumber: Int, ext: String) { - val name = buildString { - append(FILENAME_PATTERN.format(chapter.number, pageNumber)) - if (ext.isNotEmpty() && ext.length <= 4) { - append('.') - append(ext) - } - } - writableCbz.put(name, file) - index.addChapter(chapter) - } - - companion object { - - private const val FILENAME_PATTERN = "%03d%03d" - - const val INDEX_ENTRY = "index.json" - - fun findInDir(root: File, manga: Manga): MangaZip { - val name = manga.title.toFileNameSafe() + ".cbz" - val file = File(root, name) - return MangaZip(file) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/PagesCache.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/PagesCache.kt index bf0da97f9..73dd83bb4 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/data/PagesCache.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/data/PagesCache.kt @@ -2,13 +2,13 @@ package org.koitharu.kotatsu.local.data import android.content.Context import com.tomclaw.cache.DiskLruCache +import java.io.File +import java.io.InputStream import kotlinx.coroutines.flow.MutableStateFlow import org.koitharu.kotatsu.parsers.util.longHashCode import org.koitharu.kotatsu.utils.FileSize import org.koitharu.kotatsu.utils.ext.subdir import org.koitharu.kotatsu.utils.ext.takeIfReadable -import java.io.File -import java.io.InputStream class PagesCache(context: Context) { @@ -60,4 +60,4 @@ class PagesCache(context: Context) { progress.value = (bytesCopied.toDouble() / contentLength.toDouble()).toFloat() } } -} +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/WritableCbzFile.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/WritableCbzFile.kt deleted file mode 100644 index fe61169b2..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/local/data/WritableCbzFile.kt +++ /dev/null @@ -1,99 +0,0 @@ -package org.koitharu.kotatsu.local.data - -import androidx.annotation.CheckResult -import kotlinx.coroutines.* -import org.koitharu.kotatsu.utils.ext.deleteAwait -import java.io.File -import java.io.FileInputStream -import java.io.FileOutputStream -import java.util.zip.ZipEntry -import java.util.zip.ZipInputStream -import java.util.zip.ZipOutputStream - -class WritableCbzFile(private val file: File) { - - private val dir = File(file.parentFile, file.nameWithoutExtension) - - suspend fun prepare(overwrite: Boolean) = withContext(Dispatchers.IO) { - if (!dir.list().isNullOrEmpty()) { - if (overwrite) { - dir.deleteRecursively() - } else { - throw IllegalStateException("Dir ${dir.name} is not empty") - } - } - if (!dir.exists()) { - dir.mkdir() - } - if (!file.exists()) { - return@withContext - } - ZipInputStream(FileInputStream(file)).use { zip -> - var entry = zip.nextEntry - while (entry != null && currentCoroutineContext().isActive) { - val target = File(dir.path + File.separator + entry.name) - runInterruptible { - target.parentFile?.mkdirs() - target.outputStream().use { out -> - zip.copyTo(out) - } - } - zip.closeEntry() - entry = zip.nextEntry - } - } - } - - suspend fun cleanup() = withContext(Dispatchers.IO) { - dir.deleteRecursively() - } - - @CheckResult - suspend fun flush() = withContext(Dispatchers.IO) { - val tempFile = File(file.path + ".tmp") - if (tempFile.exists()) { - tempFile.deleteAwait() - } - try { - runInterruptible { - ZipOutputStream(FileOutputStream(tempFile)).use { zip -> - dir.listFiles()?.forEach { - zipFile(it, it.name, zip) - } - zip.flush() - } - } - tempFile.renameTo(file) - } finally { - if (tempFile.exists()) { - tempFile.deleteAwait() - } - } - } - - operator fun get(name: String) = File(dir, name) - - suspend fun put(name: String, file: File) = runInterruptible(Dispatchers.IO) { - file.copyTo(this[name], overwrite = true) - } - - private fun zipFile(fileToZip: File, fileName: String, zipOut: ZipOutputStream) { - if (fileToZip.isDirectory) { - if (fileName.endsWith("/")) { - zipOut.putNextEntry(ZipEntry(fileName)) - } else { - zipOut.putNextEntry(ZipEntry("$fileName/")) - } - zipOut.closeEntry() - fileToZip.listFiles()?.forEach { childFile -> - zipFile(childFile, "$fileName/${childFile.name}", zipOut) - } - } else { - FileInputStream(fileToZip).use { fis -> - val zipEntry = ZipEntry(fileName) - zipOut.putNextEntry(zipEntry) - fis.copyTo(zipOut) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/local/domain/CbzMangaOutput.kt b/app/src/main/java/org/koitharu/kotatsu/local/domain/CbzMangaOutput.kt new file mode 100644 index 000000000..87b772bb7 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/local/domain/CbzMangaOutput.kt @@ -0,0 +1,111 @@ +package org.koitharu.kotatsu.local.domain + +import androidx.annotation.WorkerThread +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runInterruptible +import okio.Closeable +import org.koitharu.kotatsu.core.zip.ZipOutput +import org.koitharu.kotatsu.local.data.MangaIndex +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaChapter +import org.koitharu.kotatsu.parsers.util.toFileNameSafe +import org.koitharu.kotatsu.utils.ext.deleteAwait +import java.io.File +import java.util.zip.ZipFile + +class CbzMangaOutput( + val file: File, + manga: Manga, +) : Closeable { + + private val output = ZipOutput(File(file.path + ".tmp")) + private val index = MangaIndex(null) + + init { + index.setMangaInfo(manga, false) + } + + suspend fun mergeWithExisting() { + if (file.exists()) { + runInterruptible(Dispatchers.IO) { + mergeWith(file) + } + } + } + + suspend fun addCover(file: File, ext: String) { + val name = buildString { + append(FILENAME_PATTERN.format(0, 0)) + if (ext.isNotEmpty() && ext.length <= 4) { + append('.') + append(ext) + } + } + output.put(name, file) + index.setCoverEntry(name) + } + + suspend fun addPage(chapter: MangaChapter, file: File, pageNumber: Int, ext: String) { + val name = buildString { + append(FILENAME_PATTERN.format(chapter.number, pageNumber)) + if (ext.isNotEmpty() && ext.length <= 4) { + append('.') + append(ext) + } + } + output.put(name, file) + index.addChapter(chapter) + } + + suspend fun finalize() { + output.put(ENTRY_NAME_INDEX, index.toString()) + output.finish() + output.close() + file.deleteAwait() + output.file.renameTo(file) + } + + suspend fun cleanup() { + output.file.deleteAwait() + } + + override fun close() { + output.close() + } + + @WorkerThread + private fun mergeWith(other: File) { + var otherIndex: MangaIndex? = null + ZipFile(other).use { zip -> + for (entry in zip.entries()) { + if (entry.name == ENTRY_NAME_INDEX) { + otherIndex = MangaIndex( + zip.getInputStream(entry).use { + it.reader().readText() + } + ) + } else { + output.copyEntryFrom(zip, entry) + } + } + } + otherIndex?.getMangaInfo()?.chapters?.let { chapters -> + for (chapter in chapters) { + index.addChapter(chapter) + } + } + } + + companion object { + + private const val FILENAME_PATTERN = "%03d%03d" + + const val ENTRY_NAME_INDEX = "index.json" + + fun createNew(root: File, manga: Manga): CbzMangaOutput { + val name = manga.title.toFileNameSafe() + ".cbz" + val file = File(root, name) + return CbzMangaOutput(file, manga) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt b/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt index c6137a485..99d8f7ad9 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt @@ -14,7 +14,6 @@ import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.local.data.CbzFilter import org.koitharu.kotatsu.local.data.LocalStorageManager import org.koitharu.kotatsu.local.data.MangaIndex -import org.koitharu.kotatsu.local.data.MangaZip import org.koitharu.kotatsu.parsers.model.* import org.koitharu.kotatsu.parsers.util.longHashCode import org.koitharu.kotatsu.parsers.util.toCamelCase @@ -59,7 +58,7 @@ class LocalMangaRepository(private val storageManager: LocalStorageManager) : Ma val uri = Uri.parse(chapter.url) val file = uri.toFile() val zip = ZipFile(file) - val index = zip.getEntry(MangaZip.INDEX_ENTRY)?.let(zip::readText)?.let(::MangaIndex) + val index = zip.getEntry(CbzMangaOutput.ENTRY_NAME_INDEX)?.let(zip::readText)?.let(::MangaIndex) var entries = zip.entries().asSequence() entries = if (index != null) { val pattern = index.getChapterNamesPattern(chapter) @@ -97,7 +96,7 @@ class LocalMangaRepository(private val storageManager: LocalStorageManager) : Ma @SuppressLint("DefaultLocale") fun getFromFile(file: File): Manga = ZipFile(file).use { zip -> val fileUri = file.toUri().toString() - val entry = zip.getEntry(MangaZip.INDEX_ENTRY) + val entry = zip.getEntry(CbzMangaOutput.ENTRY_NAME_INDEX) val index = entry?.let(zip::readText)?.let(::MangaIndex) val info = index?.getMangaInfo() if (index != null && info != null) { @@ -158,7 +157,7 @@ class LocalMangaRepository(private val storageManager: LocalStorageManager) : Ma }.getOrNull() ?: return null return runInterruptible(Dispatchers.IO) { ZipFile(file).use { zip -> - val entry = zip.getEntry(MangaZip.INDEX_ENTRY) + val entry = zip.getEntry(CbzMangaOutput.ENTRY_NAME_INDEX) val index = entry?.let(zip::readText)?.let(::MangaIndex) index?.getMangaInfo() } @@ -170,7 +169,7 @@ class LocalMangaRepository(private val storageManager: LocalStorageManager) : Ma return runInterruptible(Dispatchers.IO) { for (file in files) { val index = ZipFile(file).use { zip -> - val entry = zip.getEntry(MangaZip.INDEX_ENTRY) + val entry = zip.getEntry(CbzMangaOutput.ENTRY_NAME_INDEX) entry?.let(zip::readText)?.let(::MangaIndex) } ?: continue val info = index.getMangaInfo() ?: continue diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/backup/AppBackupAgent.kt b/app/src/main/java/org/koitharu/kotatsu/settings/backup/AppBackupAgent.kt index 9b59a983c..278995dab 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/backup/AppBackupAgent.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/backup/AppBackupAgent.kt @@ -5,12 +5,8 @@ import android.app.backup.BackupDataInput import android.app.backup.BackupDataOutput import android.app.backup.FullBackupDataOutput import android.os.ParcelFileDescriptor -import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.runBlocking -import org.koitharu.kotatsu.core.backup.BackupArchive -import org.koitharu.kotatsu.core.backup.BackupEntry -import org.koitharu.kotatsu.core.backup.BackupRepository -import org.koitharu.kotatsu.core.backup.RestoreRepository +import org.koitharu.kotatsu.core.backup.* import org.koitharu.kotatsu.core.db.MangaDatabase import java.io.* @@ -46,7 +42,7 @@ class AppBackupAgent : BackupAgent() { mode: Long, mtime: Long ) { - if (destination?.name?.endsWith(".bak") == true) { + if (destination?.name?.endsWith(".bk.zip") == true) { restoreBackupFile(data.fileDescriptor, size) destination.delete() } else { @@ -56,14 +52,14 @@ class AppBackupAgent : BackupAgent() { private fun createBackupFile() = runBlocking { val repository = BackupRepository(MangaDatabase.create(applicationContext)) - val backup = BackupArchive.createNew(this@AppBackupAgent) - backup.put(repository.createIndex()) - backup.put(repository.dumpHistory()) - backup.put(repository.dumpCategories()) - backup.put(repository.dumpFavourites()) - backup.flush() - backup.cleanup() - backup.file + BackupZipOutput(this@AppBackupAgent).use { backup -> + backup.put(repository.createIndex()) + backup.put(repository.dumpHistory()) + backup.put(repository.dumpCategories()) + backup.put(repository.dumpFavourites()) + backup.finish() + backup.file + } } private fun restoreBackupFile(fd: FileDescriptor, size: Long) { @@ -74,18 +70,15 @@ class AppBackupAgent : BackupAgent() { input.copyLimitedTo(output, size) } } - val backup = BackupArchive(tempFile) + val backup = BackupZipInput(tempFile) try { runBlocking { - backup.unpack() repository.upsertHistory(backup.getEntry(BackupEntry.HISTORY)) repository.upsertCategories(backup.getEntry(BackupEntry.CATEGORIES)) repository.upsertFavourites(backup.getEntry(BackupEntry.FAVOURITES)) } } finally { - runBlocking(NonCancellable) { - backup.cleanup() - } + backup.close() tempFile.delete() } } diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/backup/BackupViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/settings/backup/BackupViewModel.kt index 8c3ac36a9..2532dc8d2 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/backup/BackupViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/backup/BackupViewModel.kt @@ -3,8 +3,8 @@ package org.koitharu.kotatsu.settings.backup import android.content.Context import androidx.lifecycle.MutableLiveData import org.koitharu.kotatsu.base.ui.BaseViewModel -import org.koitharu.kotatsu.core.backup.BackupArchive import org.koitharu.kotatsu.core.backup.BackupRepository +import org.koitharu.kotatsu.core.backup.BackupZipOutput import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.progress.Progress import java.io.File @@ -19,23 +19,25 @@ class BackupViewModel( init { launchLoadingJob { - val backup = BackupArchive.createNew(context) - backup.put(repository.createIndex()) + val file = BackupZipOutput(context).use { backup -> + backup.put(repository.createIndex()) - progress.value = Progress(0, 3) - backup.put(repository.dumpHistory()) + progress.value = Progress(0, 3) + backup.put(repository.dumpHistory()) - progress.value = Progress(1, 3) - backup.put(repository.dumpCategories()) + progress.value = Progress(1, 3) + backup.put(repository.dumpCategories()) - progress.value = Progress(2, 3) - backup.put(repository.dumpFavourites()) + progress.value = Progress(2, 3) + backup.put(repository.dumpFavourites()) - progress.value = Progress(3, 3) - backup.flush() - progress.value = null - backup.cleanup() - onBackupDone.call(backup.file) + progress.value = Progress(3, 3) + backup.finish() + progress.value = null + backup.close() + backup.file + } + onBackupDone.call(file) } } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/backup/RestoreViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/settings/backup/RestoreViewModel.kt index e7d185eb2..79f2fc7c4 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/backup/RestoreViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/backup/RestoreViewModel.kt @@ -3,19 +3,17 @@ package org.koitharu.kotatsu.settings.backup import android.content.Context import android.net.Uri import androidx.lifecycle.MutableLiveData -import java.io.File -import java.io.FileNotFoundException import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.runInterruptible -import kotlinx.coroutines.withContext import org.koitharu.kotatsu.base.ui.BaseViewModel -import org.koitharu.kotatsu.core.backup.BackupArchive import org.koitharu.kotatsu.core.backup.BackupEntry +import org.koitharu.kotatsu.core.backup.BackupZipInput import org.koitharu.kotatsu.core.backup.CompositeResult import org.koitharu.kotatsu.core.backup.RestoreRepository import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.progress.Progress +import java.io.File +import java.io.FileNotFoundException class RestoreViewModel( uri: Uri?, @@ -40,10 +38,9 @@ class RestoreViewModel( input.copyTo(output) } } - BackupArchive(tempFile) + BackupZipInput(tempFile) } try { - backup.unpack() val result = CompositeResult() progress.value = Progress(0, 3) @@ -58,10 +55,8 @@ class RestoreViewModel( progress.value = Progress(3, 3) onRestoreDone.call(result) } finally { - withContext(NonCancellable) { - backup.cleanup() - backup.file.delete() - } + backup.close() + backup.file.delete() } } } diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/MutableZipFile.kt b/app/src/main/java/org/koitharu/kotatsu/utils/MutableZipFile.kt deleted file mode 100644 index 01eaf7118..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/utils/MutableZipFile.kt +++ /dev/null @@ -1,103 +0,0 @@ -package org.koitharu.kotatsu.utils - -import androidx.annotation.WorkerThread -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.runInterruptible -import kotlinx.coroutines.withContext -import java.io.File -import java.io.FileInputStream -import java.io.FileOutputStream -import java.util.zip.ZipEntry -import java.util.zip.ZipInputStream -import java.util.zip.ZipOutputStream - -open class MutableZipFile(val file: File) { - - protected val dir = File(file.parentFile, file.nameWithoutExtension) - - suspend fun unpack(): Unit = runInterruptible(Dispatchers.IO) { - check(dir.list().isNullOrEmpty()) { - "Dir ${dir.name} is not empty" - } - if (!dir.exists()) { - dir.mkdir() - } - if (!file.exists()) { - return@runInterruptible - } - ZipInputStream(FileInputStream(file)).use { zip -> - var entry = zip.nextEntry - while (entry != null) { - val target = File(dir.path + File.separator + entry.name) - target.parentFile?.mkdirs() - target.outputStream().use { out -> - zip.copyTo(out) - } - zip.closeEntry() - entry = zip.nextEntry - } - } - } - - suspend fun cleanup() = withContext(Dispatchers.IO) { - dir.deleteRecursively() - } - - suspend fun flush(): Boolean = runInterruptible(Dispatchers.IO) { - val tempFile = File(file.path + ".tmp") - if (tempFile.exists()) { - tempFile.delete() - } - try { - ZipOutputStream(FileOutputStream(tempFile)).use { zip -> - dir.listFiles()?.forEach { - zipFile(it, it.name, zip) - } - zip.flush() - } - tempFile.renameTo(file) - } finally { - if (tempFile.exists()) { - tempFile.delete() - } - } - } - - operator fun get(name: String) = File(dir, name) - - suspend fun put(name: String, file: File): Unit = withContext(Dispatchers.IO) { - file.copyTo(this@MutableZipFile[name], overwrite = true) - } - - suspend fun put(name: String, data: String): Unit = withContext(Dispatchers.IO) { - this@MutableZipFile[name].writeText(data) - } - - suspend fun getContent(name: String): String = withContext(Dispatchers.IO) { - get(name).readText() - } - - companion object { - - @WorkerThread - private fun zipFile(fileToZip: File, fileName: String, zipOut: ZipOutputStream) { - if (fileToZip.isDirectory) { - if (fileName.endsWith("/")) { - zipOut.putNextEntry(ZipEntry(fileName)) - } else { - zipOut.putNextEntry(ZipEntry("$fileName/")) - } - zipOut.closeEntry() - fileToZip.listFiles()?.forEach { childFile -> - zipFile(childFile, "$fileName/${childFile.name}", zipOut) - } - } else { - FileInputStream(fileToZip).use { fis -> - val zipEntry = ZipEntry(fileName) - zipOut.putNextEntry(zipEntry) - fis.copyTo(zipOut) - } - } - } - } -} \ No newline at end of file From 16c8641a078b5e248af70c24da4e6f27ce754d30 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Mon, 18 Apr 2022 16:46:35 +0300 Subject: [PATCH 02/20] Fix concurrent downloading #146 --- .../kotatsu/download/domain/DownloadManager.kt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt b/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt index aba04e350..4cc868344 100644 --- a/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt +++ b/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt @@ -32,7 +32,6 @@ import java.io.File private const val MAX_DOWNLOAD_ATTEMPTS = 3 private const val MAX_PARALLEL_DOWNLOADS = 2 private const val DOWNLOAD_ERROR_DELAY = 500L -private const val TEMP_PAGE_FILE = "page.tmp" class DownloadManager( private val coroutineScope: CoroutineScope, @@ -80,6 +79,7 @@ class DownloadManager( var cover: Drawable? = null val destination = localMangaRepository.getOutputDir() checkNotNull(destination) { context.getString(R.string.cannot_find_available_storage) } + val tempFileName = "${manga.id}_$startId.tmp" var output: CbzMangaOutput? = null try { if (manga.source == MangaSource.LOCAL) { @@ -100,7 +100,7 @@ class DownloadManager( val data = if (manga.chapters.isNullOrEmpty()) repo.getDetails(manga) else manga output = CbzMangaOutput.createNew(destination, data) val coverUrl = data.largeCoverUrl ?: data.coverUrl - downloadFile(coverUrl, data.publicUrl, destination).let { file -> + downloadFile(coverUrl, data.publicUrl, destination, tempFileName).let { file -> output.addCover(file, MimeTypeMap.getFileExtensionFromUrl(coverUrl)) } val chapters = checkNotNull( @@ -120,7 +120,7 @@ class DownloadManager( failsafe@ do { try { val url = repo.getPageUrl(page) - val file = cache[url] ?: downloadFile(url, page.referer, destination) + val file = cache[url] ?: downloadFile(url, page.referer, destination, tempFileName) output.addPage( chapter = chapter, file = file, @@ -159,14 +159,14 @@ class DownloadManager( } finally { withContext(NonCancellable) { output?.cleanup() - File(destination, TEMP_PAGE_FILE).deleteAwait() + File(destination, tempFileName).deleteAwait() } coroutineContext[WakeLockNode]?.release() semaphore.release() } } - private suspend fun downloadFile(url: String, referer: String, destination: File): File { + private suspend fun downloadFile(url: String, referer: String, destination: File, tempFileName: String): File { val request = Request.Builder() .url(url) .header(CommonHeaders.REFERER, referer) @@ -175,7 +175,7 @@ class DownloadManager( .build() val call = okHttp.newCall(request) var attempts = MAX_DOWNLOAD_ATTEMPTS - val file = File(destination, TEMP_PAGE_FILE) + val file = File(destination, tempFileName) while (true) { try { val response = call.clone().await() From be661063367c744bdd202d87d13f0df74ae2da01 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Mon, 18 Apr 2022 20:00:43 +0300 Subject: [PATCH 03/20] Removing selected chapters from local storage --- .../koitharu/kotatsu/base/ui/BaseViewModel.kt | 4 +- .../base/ui/util/CountedBooleanLiveData.kt | 20 +++++++ .../koitharu/kotatsu/core/zip/ZipOutput.kt | 18 +++++-- .../kotatsu/details/ui/ChaptersFragment.kt | 13 ++++- .../kotatsu/details/ui/DetailsActivity.kt | 13 +++-- .../kotatsu/details/ui/DetailsViewModel.kt | 10 ++++ .../details/ui/model/ChapterListItem.kt | 3 +- .../download/domain/DownloadManager.kt | 2 +- .../koitharu/kotatsu/local/data/MangaIndex.kt | 4 ++ .../kotatsu/local/domain/CbzMangaOutput.kt | 54 ++++++++++++++++--- .../local/domain/LocalMangaRepository.kt | 12 +++-- app/src/main/res/menu/mode_chapters.xml | 6 +++ 12 files changed, 136 insertions(+), 23 deletions(-) create mode 100644 app/src/main/java/org/koitharu/kotatsu/base/ui/util/CountedBooleanLiveData.kt diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseViewModel.kt index 50d8fe803..39233e28f 100644 --- a/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseViewModel.kt @@ -1,18 +1,18 @@ package org.koitharu.kotatsu.base.ui -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlin.coroutines.CoroutineContext import kotlin.coroutines.EmptyCoroutineContext import kotlinx.coroutines.* import org.koitharu.kotatsu.BuildConfig +import org.koitharu.kotatsu.base.ui.util.CountedBooleanLiveData import org.koitharu.kotatsu.utils.SingleLiveEvent abstract class BaseViewModel : ViewModel() { val onError = SingleLiveEvent() - val isLoading = MutableLiveData(false) + val isLoading = CountedBooleanLiveData() protected fun launchJob( context: CoroutineContext = EmptyCoroutineContext, diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/util/CountedBooleanLiveData.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/util/CountedBooleanLiveData.kt new file mode 100644 index 000000000..fd7f1abf6 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/util/CountedBooleanLiveData.kt @@ -0,0 +1,20 @@ +package org.koitharu.kotatsu.base.ui.util + +import androidx.lifecycle.MutableLiveData + +class CountedBooleanLiveData : MutableLiveData(false) { + + private var counter = 0 + + override fun setValue(value: Boolean) { + if (value) { + counter++ + } else { + counter-- + } + val newValue = counter > 0 + if (newValue != value) { + super.setValue(value) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/zip/ZipOutput.kt b/app/src/main/java/org/koitharu/kotatsu/core/zip/ZipOutput.kt index bcdc5b0ee..23a030c4e 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/zip/ZipOutput.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/zip/ZipOutput.kt @@ -2,8 +2,6 @@ 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 @@ -22,16 +20,26 @@ class ZipOutput( setLevel(compressionLevel) } - suspend fun put(name: String, file: File): Unit = runInterruptible(Dispatchers.IO) { + fun put(name: String, file: File) { entryNames.add(name) output.appendFile(file, name) } - suspend fun put(name: String, content: String): Unit = runInterruptible(Dispatchers.IO) { + fun put(name: String, content: String) { entryNames.add(name) output.appendText(content, name) } + fun addDirectory(name: String) { + entryNames.add(name) + val entry = if (name.endsWith("/")) { + ZipEntry(name) + } else { + ZipEntry("$name/") + } + output.putNextEntry(entry) + } + @WorkerThread fun copyEntryFrom(other: ZipFile, entry: ZipEntry): Boolean { return if (entryNames.add(entry.name)) { @@ -47,7 +55,7 @@ class ZipOutput( } } - suspend fun finish() = runInterruptible(Dispatchers.IO) { + fun finish() { output.finish() output.flush() } diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/ChaptersFragment.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/ChaptersFragment.kt index 96f369c56..0dbf3eea5 100644 --- a/app/src/main/java/org/koitharu/kotatsu/details/ui/ChaptersFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/ChaptersFragment.kt @@ -154,11 +154,19 @@ class ChaptersFragment : DownloadService.start( context ?: return false, viewModel.getRemoteManga() ?: viewModel.manga.value ?: return false, - selectionDecoration?.checkedItemsIds + selectionDecoration?.checkedItemsIds?.toSet() ) mode.finish() true } + R.id.action_delete -> { + val ids = selectionDecoration?.checkedItemsIds + if (!ids.isNullOrEmpty()) { + viewModel.deleteChapters(ids.toSet()) + } + mode.finish() + true + } R.id.action_select_all -> { val ids = chaptersAdapter?.items?.map { it.chapter.id } ?: return false selectionDecoration?.checkAll(ids) @@ -188,6 +196,9 @@ class ChaptersFragment : menu.findItem(R.id.action_save).isVisible = items.none { x -> x.chapter.source == MangaSource.LOCAL } + menu.findItem(R.id.action_delete).isVisible = items.all { x -> + x.chapter.source == MangaSource.LOCAL + } mode.title = items.size.toString() return true } diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt index 3ef3d8517..18e0e325b 100644 --- a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt @@ -47,7 +47,9 @@ import org.koitharu.kotatsu.search.ui.global.GlobalSearchActivity import org.koitharu.kotatsu.utils.ShareHelper import org.koitharu.kotatsu.utils.ext.getDisplayMessage -class DetailsActivity : BaseActivity(), TabLayoutMediator.TabConfigurationStrategy, +class DetailsActivity : + BaseActivity(), + TabLayoutMediator.TabConfigurationStrategy, AdapterView.OnItemSelectedListener { private val viewModel by viewModel { @@ -79,6 +81,7 @@ class DetailsActivity : BaseActivity(), TabLayoutMediato viewModel.manga.observe(this, ::onMangaUpdated) viewModel.newChaptersCount.observe(this, ::onNewChaptersChanged) viewModel.onMangaRemoved.observe(this, ::onMangaRemoved) + viewModel.onChaptersRemoved.observe(this, ::onChaptersRemoved) viewModel.onError.observe(this, ::onError) registerReceiver(downloadReceiver, IntentFilter(DownloadService.ACTION_DOWNLOAD_COMPLETE)) @@ -102,6 +105,10 @@ class DetailsActivity : BaseActivity(), TabLayoutMediato finishAfterTransition() } + private fun onChaptersRemoved(count: Int) { + binding.snackbar.show(getString(R.string.removal_completed)) + } + private fun onError(e: Throwable) { when { ExceptionResolver.canResolve(e) -> { @@ -262,7 +269,7 @@ class DetailsActivity : BaseActivity(), TabLayoutMediato fun showChapterMissingDialog(chapterId: Long) { val remoteManga = viewModel.getRemoteManga() if (remoteManga == null) { - binding.snackbar.show(getString( R.string.chapter_is_missing)) + binding.snackbar.show(getString(R.string.chapter_is_missing)) return } MaterialAlertDialogBuilder(this).apply { @@ -340,4 +347,4 @@ class DetailsActivity : BaseActivity(), TabLayoutMediato .putExtra(MangaIntent.KEY_ID, mangaId) } } -} +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt index afc0129ac..53a173369 100644 --- a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt @@ -85,6 +85,7 @@ class DetailsViewModel( .asLiveData(viewModelScope.coroutineContext) val onMangaRemoved = SingleLiveEvent() + val onChaptersRemoved = SingleLiveEvent() val branches = mangaData.map { it?.chapters?.mapToSet { x -> x.branch }?.sortedBy { x -> x }.orEmpty() @@ -183,6 +184,15 @@ class DetailsViewModel( } } + fun deleteChapters(ids: Set) { + launchLoadingJob { + val manga = checkNotNull(mangaData.value) + localMangaRepository.deleteChapters(manga, ids) + reload() + onChaptersRemoved.call(ids.size) + } + } + private fun doLoad() = launchLoadingJob(Dispatchers.Default) { var manga = mangaDataRepository.resolveIntent(intent) ?: throw MangaNotFoundException("Cannot find manga") diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/model/ChapterListItem.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/model/ChapterListItem.kt index 6f37c8e53..2d5b90840 100644 --- a/app/src/main/java/org/koitharu/kotatsu/details/ui/model/ChapterListItem.kt +++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/model/ChapterListItem.kt @@ -40,11 +40,10 @@ class ChapterListItem( override fun hashCode(): Int { var result = chapter.hashCode() result = 31 * result + flags - result = 31 * result + uploadDate.hashCode() + result = 31 * result + (uploadDate?.hashCode() ?: 0) return result } - companion object { const val FLAG_UNREAD = 2 diff --git a/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt b/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt index 4cc868344..3a2067705 100644 --- a/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt +++ b/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt @@ -98,7 +98,7 @@ class DownloadManager( }.getOrNull() outState.value = DownloadState.Preparing(startId, manga, cover) val data = if (manga.chapters.isNullOrEmpty()) repo.getDetails(manga) else manga - output = CbzMangaOutput.createNew(destination, data) + output = CbzMangaOutput.get(destination, data) val coverUrl = data.largeCoverUrl ?: data.coverUrl downloadFile(coverUrl, data.publicUrl, destination, tempFileName).let { file -> output.addCover(file, MimeTypeMap.getFileExtensionFromUrl(coverUrl)) diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/MangaIndex.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/MangaIndex.kt index 07bbbce98..e0eb87865 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/data/MangaIndex.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/data/MangaIndex.kt @@ -92,6 +92,10 @@ class MangaIndex(source: String?) { } } + fun removeChapter(id: Long): Boolean { + return json.getJSONObject("chapters").remove(id.toString()) != null + } + fun setCoverEntry(name: String) { json.put("cover_entry", name) } diff --git a/app/src/main/java/org/koitharu/kotatsu/local/domain/CbzMangaOutput.kt b/app/src/main/java/org/koitharu/kotatsu/local/domain/CbzMangaOutput.kt index 87b772bb7..9278dd83f 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/domain/CbzMangaOutput.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/domain/CbzMangaOutput.kt @@ -10,6 +10,7 @@ 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 @@ -41,7 +42,9 @@ class CbzMangaOutput( append(ext) } } - output.put(name, file) + runInterruptible(Dispatchers.IO) { + output.put(name, file) + } index.setCoverEntry(name) } @@ -53,14 +56,18 @@ class CbzMangaOutput( append(ext) } } - output.put(name, file) + runInterruptible(Dispatchers.IO) { + output.put(name, file) + } index.addChapter(chapter) } suspend fun finalize() { - output.put(ENTRY_NAME_INDEX, index.toString()) - output.finish() - output.close() + runInterruptible(Dispatchers.IO) { + output.put(ENTRY_NAME_INDEX, index.toString()) + output.finish() + output.close() + } file.deleteAwait() output.file.renameTo(file) } @@ -102,10 +109,45 @@ class CbzMangaOutput( const val ENTRY_NAME_INDEX = "index.json" - fun createNew(root: File, manga: Manga): CbzMangaOutput { + fun get(root: File, manga: Manga): CbzMangaOutput { val name = manga.title.toFileNameSafe() + ".cbz" val file = File(root, name) return CbzMangaOutput(file, manga) } + + @WorkerThread + fun filterChapters(subject: CbzMangaOutput, idsToRemove: Set) { + ZipFile(subject.file).use { zip -> + 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) + } + } } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt b/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt index 99d8f7ad9..bf2b69cc6 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt @@ -49,12 +49,11 @@ class LocalMangaRepository(private val storageManager: LocalStorageManager) : Ma manga.source != MangaSource.LOCAL -> requireNotNull(findSavedManga(manga)) { "Manga is not local or saved" } - manga.chapters == null -> getFromFile(Uri.parse(manga.url).toFile()) - else -> manga + else -> getFromFile(Uri.parse(manga.url).toFile()) } override suspend fun getPages(chapter: MangaChapter): List { - return runInterruptible(Dispatchers.IO){ + return runInterruptible(Dispatchers.IO) { val uri = Uri.parse(chapter.url) val file = uri.toFile() val zip = ZipFile(file) @@ -93,6 +92,13 @@ class LocalMangaRepository(private val storageManager: LocalStorageManager) : Ma return file.deleteAwait() } + suspend fun deleteChapters(manga: Manga, ids: Set) = runInterruptible(Dispatchers.IO) { + val uri = Uri.parse(manga.url) + val file = uri.toFile() + val cbz = CbzMangaOutput(file, manga) + CbzMangaOutput.filterChapters(cbz, ids) + } + @SuppressLint("DefaultLocale") fun getFromFile(file: File): Manga = ZipFile(file).use { zip -> val fileUri = file.toUri().toString() diff --git a/app/src/main/res/menu/mode_chapters.xml b/app/src/main/res/menu/mode_chapters.xml index 2e9a6aff0..8e3d52bab 100644 --- a/app/src/main/res/menu/mode_chapters.xml +++ b/app/src/main/res/menu/mode_chapters.xml @@ -9,6 +9,12 @@ android:title="@string/save" app:showAsAction="ifRoom|withText" /> + + Date: Tue, 19 Apr 2022 08:22:54 +0300 Subject: [PATCH 04/20] Delete whole manga if all chapters are removed --- .../koitharu/kotatsu/details/ui/DetailsViewModel.kt | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt index 53a173369..4582e48ad 100644 --- a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt @@ -4,6 +4,7 @@ import androidx.core.os.LocaleListCompat import androidx.lifecycle.asFlow import androidx.lifecycle.asLiveData import androidx.lifecycle.viewModelScope +import java.io.IOException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.* @@ -29,7 +30,6 @@ import org.koitharu.kotatsu.parsers.util.toTitleCase import org.koitharu.kotatsu.tracker.domain.TrackingRepository import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.ext.iterator -import java.io.IOException class DetailsViewModel( private val intent: MangaIntent, @@ -185,9 +185,13 @@ class DetailsViewModel( } fun deleteChapters(ids: Set) { + val m = mangaData.value ?: return + if (m.chapters?.size == ids.size) { + deleteLocal(m) + return + } launchLoadingJob { - val manga = checkNotNull(mangaData.value) - localMangaRepository.deleteChapters(manga, ids) + localMangaRepository.deleteChapters(m, ids) reload() onChaptersRemoved.call(ids.size) } From fa536220ebdc2fbdc031a7e893e8cffed72d9d92 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Tue, 19 Apr 2022 09:25:12 +0300 Subject: [PATCH 05/20] Search and parallelism in LocalMangaRepository.getList --- .../local/domain/LocalMangaRepository.kt | 51 +++++++++++++++---- 1 file changed, 40 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt b/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt index bf2b69cc6..06b5757bf 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt @@ -3,12 +3,17 @@ package org.koitharu.kotatsu.local.domain import android.annotation.SuppressLint import android.net.Uri import android.webkit.MimeTypeMap +import androidx.annotation.WorkerThread import androidx.collection.ArraySet import androidx.core.net.toFile import androidx.core.net.toUri -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.runInterruptible -import kotlinx.coroutines.withContext +import java.io.File +import java.io.IOException +import java.util.* +import java.util.zip.ZipEntry +import java.util.zip.ZipFile +import kotlin.coroutines.CoroutineContext +import kotlinx.coroutines.* import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.local.data.CbzFilter @@ -21,11 +26,8 @@ import org.koitharu.kotatsu.utils.AlphanumComparator import org.koitharu.kotatsu.utils.ext.deleteAwait import org.koitharu.kotatsu.utils.ext.readText import org.koitharu.kotatsu.utils.ext.resolveName -import java.io.File -import java.io.IOException -import java.util.* -import java.util.zip.ZipEntry -import java.util.zip.ZipFile + +private const val MAX_PARALLELISM = 4 class LocalMangaRepository(private val storageManager: LocalStorageManager) : MangaRepository { @@ -38,11 +40,28 @@ class LocalMangaRepository(private val storageManager: LocalStorageManager) : Ma tags: Set?, sortOrder: SortOrder? ): List { - require(offset == 0) { - "LocalMangaRepository does not support pagination" + if (offset > 0) { + return emptyList() } val files = getAllFiles() - return files.mapNotNull { x -> runCatching { getFromFile(x) }.getOrNull() } + val list = coroutineScope { + val dispatcher = Dispatchers.IO.limitedParallelism(MAX_PARALLELISM) + files.map { file -> + getFromFileAsync(file, dispatcher) + }.awaitAll() + }.filterNotNullTo(ArrayList(files.size)) + if (!query.isNullOrEmpty()) { + list.retainAll { x -> + x.title.contains(query, ignoreCase = true) || + x.altTitle?.contains(query, ignoreCase = true) == true + } + } + if (!tags.isNullOrEmpty()) { + list.retainAll { x -> + x.tags.containsAll(tags) + } + } + return list } override suspend fun getDetails(manga: Manga) = when { @@ -99,6 +118,7 @@ class LocalMangaRepository(private val storageManager: LocalStorageManager) : Ma CbzMangaOutput.filterChapters(cbz, ids) } + @WorkerThread @SuppressLint("DefaultLocale") fun getFromFile(file: File): Manga = ZipFile(file).use { zip -> val fileUri = file.toUri().toString() @@ -192,6 +212,15 @@ class LocalMangaRepository(private val storageManager: LocalStorageManager) : Ma } } + private fun CoroutineScope.getFromFileAsync( + file: File, + context: CoroutineContext, + ): Deferred = async(context) { + runInterruptible { + runCatching { getFromFile(file) }.getOrNull() + } + } + private fun zipUri(file: File, entryName: String) = "cbz://${file.path}#$entryName" private fun findFirstImageEntry(entries: Enumeration): ZipEntry? { From 8fc9b27840b787e04e264797998aeee53b2f8a92 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Tue, 19 Apr 2022 10:27:22 +0300 Subject: [PATCH 06/20] Option to slowdown downloads and configure parallelism --- .../kotatsu/core/prefs/AppSettings.kt | 8 +++++ .../download/domain/DownloadManager.kt | 32 +++++++++++++++++-- .../download/ui/service/DownloadService.kt | 7 +--- .../org/koitharu/kotatsu/local/LocalModule.kt | 3 ++ .../settings/ContentSettingsFragment.kt | 10 +++++- .../koitharu/kotatsu/utils/ext/AndroidExt.kt | 1 + app/src/main/res/layout/preference_slider.xml | 19 ++++++----- app/src/main/res/values-ru/strings.xml | 3 ++ app/src/main/res/values/strings.xml | 3 ++ app/src/main/res/xml/pref_content.xml | 25 ++++++++++++--- 10 files changed, 86 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt b/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt index 89cbc1aaf..45232e2ef 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt @@ -141,6 +141,12 @@ class AppSettings(context: Context) { } } + val isDownloadsSlowdownEnabled: Boolean + get() = prefs.getBoolean(KEY_DOWNLOADS_SLOWDOWN, false) + + val downloadsParallelism: Int + get() = prefs.getInt(KEY_DOWNLOADS_PARALLELISM, 2) + val isSuggestionsEnabled: Boolean get() = prefs.getBoolean(KEY_SUGGESTIONS, false) @@ -261,6 +267,8 @@ class AppSettings(context: Context) { const val KEY_SUGGESTIONS_EXCLUDE_NSFW = "suggestions_exclude_nsfw" const val KEY_SUGGESTIONS_EXCLUDE_TAGS = "suggestions_exclude_tags" const val KEY_SEARCH_SINGLE_SOURCE = "search_single_source" + const val KEY_DOWNLOADS_PARALLELISM = "downloads_parallelism" + const val KEY_DOWNLOADS_SLOWDOWN = "downloads_slowdown" // About const val KEY_APP_UPDATE = "app_update" diff --git a/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt b/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt index 3a2067705..4708c0849 100644 --- a/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt +++ b/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt @@ -17,6 +17,7 @@ import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.network.CommonHeaders import org.koitharu.kotatsu.core.parser.MangaRepository +import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.local.data.PagesCache import org.koitharu.kotatsu.local.domain.CbzMangaOutput import org.koitharu.kotatsu.local.domain.LocalMangaRepository @@ -30,8 +31,8 @@ import org.koitharu.kotatsu.utils.progress.ProgressJob import java.io.File private const val MAX_DOWNLOAD_ATTEMPTS = 3 -private const val MAX_PARALLEL_DOWNLOADS = 2 private const val DOWNLOAD_ERROR_DELAY = 500L +private const val SLOWDOWN_DELAY = 200L class DownloadManager( private val coroutineScope: CoroutineScope, @@ -40,9 +41,10 @@ class DownloadManager( private val okHttp: OkHttpClient, private val cache: PagesCache, private val localMangaRepository: LocalMangaRepository, + private val settings: AppSettings, ) { - private val connectivityManager = context.applicationContext.getSystemService( + private val connectivityManager = context.getSystemService( Context.CONNECTIVITY_SERVICE ) as ConnectivityManager private val coverWidth = context.resources.getDimensionPixelSize( @@ -51,7 +53,7 @@ class DownloadManager( private val coverHeight = context.resources.getDimensionPixelSize( androidx.core.R.dimen.compat_notification_large_icon_max_height ) - private val semaphore = Semaphore(MAX_PARALLEL_DOWNLOADS) + private val semaphore = Semaphore(settings.downloadsParallelism) fun downloadManga( manga: Manga, @@ -141,6 +143,10 @@ class DownloadManager( totalPages = pages.size, currentPage = pageIndex, ) + + if (settings.isDownloadsSlowdownEnabled) { + delay(SLOWDOWN_DELAY) + } } } outState.value = DownloadState.PostProcessing(startId, data, cover) @@ -206,4 +212,24 @@ class DownloadManager( error = throwable, ) } + + class Factory( + private val context: Context, + private val imageLoader: ImageLoader, + private val okHttp: OkHttpClient, + private val cache: PagesCache, + private val localMangaRepository: LocalMangaRepository, + private val settings: AppSettings, + ) { + + fun create(coroutineScope: CoroutineScope) = DownloadManager( + coroutineScope = coroutineScope, + context = context, + imageLoader = imageLoader, + okHttp = okHttp, + cache = cache, + localMangaRepository = localMangaRepository, + settings = settings, + ) + } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt index 41bb3e273..13365094c 100644 --- a/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt @@ -49,13 +49,8 @@ class DownloadService : BaseService() { notificationSwitcher = ForegroundNotificationSwitcher(this) val wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager) .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "kotatsu:downloading") - downloadManager = DownloadManager( + downloadManager = get().create( coroutineScope = lifecycleScope + WakeLockNode(wakeLock, TimeUnit.HOURS.toMillis(1)), - context = this, - imageLoader = get(), - okHttp = get(), - cache = get(), - localMangaRepository = get(), ) DownloadNotification.createChannel(this) registerReceiver(controlReceiver, IntentFilter(ACTION_DOWNLOAD_CANCEL)) diff --git a/app/src/main/java/org/koitharu/kotatsu/local/LocalModule.kt b/app/src/main/java/org/koitharu/kotatsu/local/LocalModule.kt index cda2dbad6..928fe706b 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/LocalModule.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/LocalModule.kt @@ -3,6 +3,7 @@ package org.koitharu.kotatsu.local import org.koin.android.ext.koin.androidContext import org.koin.androidx.viewmodel.dsl.viewModel import org.koin.dsl.module +import org.koitharu.kotatsu.download.domain.DownloadManager import org.koitharu.kotatsu.local.data.LocalStorageManager import org.koitharu.kotatsu.local.domain.LocalMangaRepository import org.koitharu.kotatsu.local.ui.LocalListViewModel @@ -16,5 +17,7 @@ val localModule factory { ExternalStorageHelper(androidContext()) } + factory { DownloadManager.Factory(androidContext(), get(), get(), get(), get(), get()) } + viewModel { LocalListViewModel(get(), get(), get(), get()) } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/ContentSettingsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/ContentSettingsFragment.kt index 5e4c7555b..d9c8da758 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/ContentSettingsFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/ContentSettingsFragment.kt @@ -4,7 +4,6 @@ import android.content.SharedPreferences import android.os.Bundle import android.view.View import androidx.preference.Preference -import java.io.File import kotlinx.coroutines.launch import org.koin.android.ext.android.inject import org.koitharu.kotatsu.R @@ -13,8 +12,10 @@ import org.koitharu.kotatsu.base.ui.dialog.StorageSelectDialog import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.local.data.LocalStorageManager import org.koitharu.kotatsu.parsers.model.MangaSource +import org.koitharu.kotatsu.settings.utils.SliderPreference import org.koitharu.kotatsu.utils.ext.getStorageName import org.koitharu.kotatsu.utils.ext.viewLifecycleScope +import java.io.File class ContentSettingsFragment : BasePreferenceFragment(R.string.content), @@ -29,6 +30,13 @@ class ContentSettingsFragment : findPreference(AppSettings.KEY_SUGGESTIONS)?.setSummary( if (settings.isSuggestionsEnabled) R.string.enabled else R.string.disabled ) + findPreference(AppSettings.KEY_DOWNLOADS_PARALLELISM)?.run { + summary = value.toString() + setOnPreferenceChangeListener { preference, newValue -> + preference.summary = newValue.toString() + true + } + } bindRemoteSourcesSummary() } diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/AndroidExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/AndroidExt.kt index 21140be00..d1ff7ae6c 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/AndroidExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/AndroidExt.kt @@ -17,6 +17,7 @@ suspend fun ConnectivityManager.waitForNetwork(): Network { return suspendCancellableCoroutine { cont -> val callback = object : ConnectivityManager.NetworkCallback() { override fun onAvailable(network: Network) { + unregisterNetworkCallback(this) if (cont.isActive) { cont.resume(network) } diff --git a/app/src/main/res/layout/preference_slider.xml b/app/src/main/res/layout/preference_slider.xml index f3dfdc29a..dc98ba093 100644 --- a/app/src/main/res/layout/preference_slider.xml +++ b/app/src/main/res/layout/preference_slider.xml @@ -5,13 +5,12 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="wrap_content" - android:background="?android:attr/selectableItemBackground" android:baselineAligned="false" android:clipChildren="false" android:clipToPadding="false" - android:orientation="horizontal" android:gravity="center_vertical" android:minHeight="?android:attr/listPreferredItemHeightSmall" + android:orientation="horizontal" android:paddingStart="?android:attr/listPreferredItemPaddingStart" android:paddingEnd="?android:attr/listPreferredItemPaddingEnd" tools:ignore="PrivateResource"> @@ -27,17 +26,18 @@ android:clipToPadding="false" android:orientation="vertical"> - + - + Удалить выбранную мангу с накопителя? Удаление завершено Загрузить выбранную мангу со всеми главами? Это может привести к большому расходу трафика и места на накопителе + Загружать параллельно + Замедление загрузки + Помогает избежать блокировки IP-адреса \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a44dfec15..e249d13f7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -274,4 +274,7 @@ Delete selected items from device permanently? Removal completed Are you sure you want to download all selected manga with all its chapters? This action can consume a lot of traffic and storage + Parallel downloads + Download slowdown + Helps avoid blocking your IP address \ No newline at end of file diff --git a/app/src/main/res/xml/pref_content.xml b/app/src/main/res/xml/pref_content.xml index 9508fdf58..53fee4124 100644 --- a/app/src/main/res/xml/pref_content.xml +++ b/app/src/main/res/xml/pref_content.xml @@ -8,17 +8,32 @@ android:key="remote_sources" android:title="@string/remote_sources" /> - - + + + + + + Date: Tue, 19 Apr 2022 11:07:19 +0300 Subject: [PATCH 07/20] Fix download error retry --- app/build.gradle | 6 +-- .../download/domain/DownloadManager.kt | 39 ++++++++----------- .../koitharu/kotatsu/utils/ext/AndroidExt.kt | 5 +++ 3 files changed, 25 insertions(+), 25 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 69e2b2373..4cd4b8cf9 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -6,14 +6,14 @@ plugins { } android { - compileSdkVersion 31 - buildToolsVersion '31.0.0' + compileSdkVersion 32 + buildToolsVersion '32.0.0' namespace 'org.koitharu.kotatsu' defaultConfig { applicationId 'org.koitharu.kotatsu' minSdkVersion 21 - targetSdkVersion 31 + targetSdkVersion 32 versionCode 402 versionName '3.1.1' generatedDensities = [] diff --git a/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt b/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt index 4708c0849..b8183a96b 100644 --- a/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt +++ b/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt @@ -119,7 +119,8 @@ class DownloadManager( for ((chapterIndex, chapter) in chapters.withIndex()) { val pages = repo.getPages(chapter) for ((pageIndex, page) in pages.withIndex()) { - failsafe@ do { + var retryCounter = 0 + failsafe@ while (true) { try { val url = repo.getPageUrl(page) val file = cache[url] ?: downloadFile(url, page.referer, destination, tempFileName) @@ -129,12 +130,18 @@ class DownloadManager( pageNumber = pageIndex, ext = MimeTypeMap.getFileExtensionFromUrl(url), ) + break@failsafe } catch (e: IOException) { - outState.value = DownloadState.WaitingForNetwork(startId, data, cover) - connectivityManager.waitForNetwork() - continue@failsafe + if (retryCounter < MAX_DOWNLOAD_ATTEMPTS) { + outState.value = DownloadState.WaitingForNetwork(startId, data, cover) + delay(DOWNLOAD_ERROR_DELAY) + connectivityManager.waitForNetwork() + retryCounter++ + } else { + throw e + } } - } while (false) + } outState.value = DownloadState.Progress( startId, data, cover, @@ -180,26 +187,14 @@ class DownloadManager( .get() .build() val call = okHttp.newCall(request) - var attempts = MAX_DOWNLOAD_ATTEMPTS val file = File(destination, tempFileName) - while (true) { - try { - val response = call.clone().await() - runInterruptible(Dispatchers.IO) { - file.outputStream().use { out -> - checkNotNull(response.body).byteStream().copyTo(out) - } - } - return file - } catch (e: IOException) { - attempts-- - if (attempts <= 0) { - throw e - } else { - delay(DOWNLOAD_ERROR_DELAY) - } + val response = call.clone().await() + runInterruptible(Dispatchers.IO) { + file.outputStream().use { out -> + checkNotNull(response.body).byteStream().copyTo(out) } } + return file } private fun errorStateHandler(outState: MutableStateFlow) = diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/AndroidExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/AndroidExt.kt index d1ff7ae6c..733bf17d4 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/AndroidExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/AndroidExt.kt @@ -5,6 +5,7 @@ import android.net.ConnectivityManager import android.net.Network import android.net.NetworkRequest import android.net.Uri +import android.os.Build import androidx.work.CoroutineWorker import kotlin.coroutines.resume import kotlinx.coroutines.suspendCancellableCoroutine @@ -14,6 +15,10 @@ val Context.connectivityManager: ConnectivityManager suspend fun ConnectivityManager.waitForNetwork(): Network { val request = NetworkRequest.Builder().build() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + // fast path + activeNetwork?.let { return it } + } return suspendCancellableCoroutine { cont -> val callback = object : ConnectivityManager.NetworkCallback() { override fun onAvailable(network: Network) { From d77646adf1921c9da31e999eac6e548f48d32e28 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Tue, 19 Apr 2022 11:32:42 +0300 Subject: [PATCH 08/20] Fix duplicate zip entry error --- .../koitharu/kotatsu/core/zip/ZipOutput.kt | 67 ++++++++++++++++--- .../org/koitharu/kotatsu/core/zip/ZipUtils.kt | 37 ---------- 2 files changed, 58 insertions(+), 46 deletions(-) delete mode 100644 app/src/main/java/org/koitharu/kotatsu/core/zip/ZipUtils.kt diff --git a/app/src/main/java/org/koitharu/kotatsu/core/zip/ZipOutput.kt b/app/src/main/java/org/koitharu/kotatsu/core/zip/ZipOutput.kt index 23a030c4e..d34e753ab 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/zip/ZipOutput.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/zip/ZipOutput.kt @@ -4,6 +4,7 @@ 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 @@ -20,24 +21,30 @@ class ZipOutput( setLevel(compressionLevel) } - fun put(name: String, file: File) { - entryNames.add(name) - output.appendFile(file, name) + @WorkerThread + fun put(name: String, file: File): Boolean { + return output.appendFile(file, name) } - fun put(name: String, content: String) { - entryNames.add(name) - output.appendText(content, name) + @WorkerThread + fun put(name: String, content: String): Boolean { + return output.appendText(content, name) } - fun addDirectory(name: String) { - entryNames.add(name) + @WorkerThread + fun addDirectory(name: String): Boolean { val entry = if (name.endsWith("/")) { ZipEntry(name) } else { ZipEntry("$name/") } - output.putNextEntry(entry) + return if (entryNames.add(entry.name)) { + output.putNextEntry(entry) + output.closeEntry() + true + } else { + false + } } @WorkerThread @@ -66,4 +73,46 @@ class ZipOutput( 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 + } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/zip/ZipUtils.kt b/app/src/main/java/org/koitharu/kotatsu/core/zip/ZipUtils.kt deleted file mode 100644 index c42f47142..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/core/zip/ZipUtils.kt +++ /dev/null @@ -1,37 +0,0 @@ -package org.koitharu.kotatsu.core.zip - -import androidx.annotation.WorkerThread -import java.io.File -import java.io.FileInputStream -import java.util.zip.ZipEntry -import java.util.zip.ZipOutputStream - -@WorkerThread -fun ZipOutputStream.appendFile(fileToZip: File, name: String) { - if (fileToZip.isDirectory) { - if (name.endsWith("/")) { - putNextEntry(ZipEntry(name)) - } else { - putNextEntry(ZipEntry("$name/")) - } - closeEntry() - fileToZip.listFiles()?.forEach { childFile -> - appendFile(childFile, "$name/${childFile.name}") - } - } else { - FileInputStream(fileToZip).use { fis -> - val zipEntry = ZipEntry(name) - putNextEntry(zipEntry) - fis.copyTo(this) - closeEntry() - } - } -} - -@WorkerThread -fun ZipOutputStream.appendText(content: String, name: String) { - val zipEntry = ZipEntry(name) - putNextEntry(zipEntry) - content.byteInputStream().copyTo(this) - closeEntry() -} \ No newline at end of file From 3c739eed8e7af96fae20353b0d3abe706ece7cbc Mon Sep 17 00:00:00 2001 From: Koitharu Date: Tue, 19 Apr 2022 12:43:51 +0300 Subject: [PATCH 09/20] Fix empty chapters label --- .../kotatsu/details/ui/ChaptersFragment.kt | 7 ++- .../kotatsu/details/ui/DetailsViewModel.kt | 13 ++--- .../kotatsu/utils/PausingDispatcher.kt | 50 +++++++++++++++++++ app/src/main/res/drawable/ic_pause.xml | 12 +++++ app/src/main/res/drawable/ic_resume.xml | 12 +++++ app/src/main/res/menu/opt_downloads.xml | 18 +++++++ 6 files changed, 102 insertions(+), 10 deletions(-) create mode 100644 app/src/main/java/org/koitharu/kotatsu/utils/PausingDispatcher.kt create mode 100644 app/src/main/res/drawable/ic_pause.xml create mode 100644 app/src/main/res/drawable/ic_resume.xml create mode 100644 app/src/main/res/menu/opt_downloads.xml diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/ChaptersFragment.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/ChaptersFragment.kt index 0dbf3eea5..143a87713 100644 --- a/app/src/main/java/org/koitharu/kotatsu/details/ui/ChaptersFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/ChaptersFragment.kt @@ -9,7 +9,6 @@ import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.view.ActionMode import androidx.appcompat.widget.SearchView import androidx.core.graphics.Insets -import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.core.view.updatePadding import org.koin.androidx.viewmodel.ext.android.sharedViewModel @@ -67,8 +66,8 @@ class ChaptersFragment : viewModel.isChaptersReversed.observe(viewLifecycleOwner) { activity?.invalidateOptionsMenu() } - viewModel.hasChapters.observe(viewLifecycleOwner) { - binding.textViewHolder.isGone = it + viewModel.isChaptersEmpty.observe(viewLifecycleOwner) { + binding.textViewHolder.isVisible = it activity?.invalidateOptionsMenu() } } @@ -94,7 +93,7 @@ class ChaptersFragment : override fun onPrepareOptionsMenu(menu: Menu) { super.onPrepareOptionsMenu(menu) menu.findItem(R.id.action_reversed).isChecked = viewModel.isChaptersReversed.value == true - menu.findItem(R.id.action_search).isVisible = viewModel.hasChapters.value == true + menu.findItem(R.id.action_search).isVisible = viewModel.isChaptersEmpty.value == false } override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) { diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt index 4582e48ad..d0318779b 100644 --- a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt @@ -4,7 +4,6 @@ import androidx.core.os.LocaleListCompat import androidx.lifecycle.asFlow import androidx.lifecycle.asLiveData import androidx.lifecycle.viewModelScope -import java.io.IOException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.* @@ -29,7 +28,9 @@ import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.parsers.util.toTitleCase import org.koitharu.kotatsu.tracker.domain.TrackingRepository import org.koitharu.kotatsu.utils.SingleLiveEvent +import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct import org.koitharu.kotatsu.utils.ext.iterator +import java.io.IOException class DetailsViewModel( private val intent: MangaIntent, @@ -89,18 +90,18 @@ class DetailsViewModel( val branches = mangaData.map { it?.chapters?.mapToSet { x -> x.branch }?.sortedBy { x -> x }.orEmpty() - }.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default) + }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default) val selectedBranchIndex = combine( branches.asFlow(), selectedBranch ) { branches, selected -> branches.indexOf(selected) - }.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default) + }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default) - val hasChapters = mangaData.map { - !(it?.chapters.isNullOrEmpty()) - }.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default) + val isChaptersEmpty = mangaData.mapNotNull { m -> + m?.run { chapters.isNullOrEmpty() } + }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, false) val chapters = combine( combine( diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/PausingDispatcher.kt b/app/src/main/java/org/koitharu/kotatsu/utils/PausingDispatcher.kt new file mode 100644 index 000000000..57eb100a4 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/utils/PausingDispatcher.kt @@ -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() + + 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, + ) +} \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_pause.xml b/app/src/main/res/drawable/ic_pause.xml new file mode 100644 index 000000000..e63766078 --- /dev/null +++ b/app/src/main/res/drawable/ic_pause.xml @@ -0,0 +1,12 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_resume.xml b/app/src/main/res/drawable/ic_resume.xml new file mode 100644 index 000000000..448628b18 --- /dev/null +++ b/app/src/main/res/drawable/ic_resume.xml @@ -0,0 +1,12 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/opt_downloads.xml b/app/src/main/res/menu/opt_downloads.xml new file mode 100644 index 000000000..f804ec53f --- /dev/null +++ b/app/src/main/res/menu/opt_downloads.xml @@ -0,0 +1,18 @@ + + + + + + + + \ No newline at end of file From 83eb0d9f2393778e6a1268443cf016aba6130873 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Tue, 19 Apr 2022 12:50:30 +0300 Subject: [PATCH 10/20] Fix isLoading live data --- .../koitharu/kotatsu/base/ui/util/CountedBooleanLiveData.kt | 4 ++-- .../org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/util/CountedBooleanLiveData.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/util/CountedBooleanLiveData.kt index fd7f1abf6..cb54ef7db 100644 --- a/app/src/main/java/org/koitharu/kotatsu/base/ui/util/CountedBooleanLiveData.kt +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/util/CountedBooleanLiveData.kt @@ -13,8 +13,8 @@ class CountedBooleanLiveData : MutableLiveData(false) { counter-- } val newValue = counter > 0 - if (newValue != value) { - super.setValue(value) + if (newValue != this.value) { + super.setValue(newValue) } } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt index 6408c036e..b2a540baa 100644 --- a/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt @@ -137,6 +137,9 @@ class RemoteListViewModel( e.printStackTrace() } listError.value = e + if (!mangaList.value.isNullOrEmpty()) { + onError.postCall(e) + } } } } From cbd3d439cd61114d61cddcdb0210e308c03fd15f Mon Sep 17 00:00:00 2001 From: Koitharu Date: Tue, 19 Apr 2022 13:09:44 +0300 Subject: [PATCH 11/20] Support multiple branches in saved manga --- .../kotatsu/details/ui/DetailsActivity.kt | 51 ++++++++++++------- .../kotatsu/details/ui/DetailsViewModel.kt | 24 +++++---- .../koitharu/kotatsu/local/data/MangaIndex.kt | 2 +- .../kotatsu/local/domain/CbzMangaOutput.kt | 6 +-- 4 files changed, 52 insertions(+), 31 deletions(-) diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt index 18e0e325b..b29596141 100644 --- a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt @@ -41,6 +41,7 @@ import org.koitharu.kotatsu.details.ui.adapter.BranchesAdapter import org.koitharu.kotatsu.download.ui.service.DownloadService import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaSource +import org.koitharu.kotatsu.parsers.util.mapNotNullToSet import org.koitharu.kotatsu.reader.ui.ReaderActivity import org.koitharu.kotatsu.reader.ui.ReaderState import org.koitharu.kotatsu.search.ui.global.GlobalSearchActivity @@ -193,23 +194,9 @@ class DetailsActivity : R.id.action_save -> { viewModel.manga.value?.let { val chaptersCount = it.chapters?.size ?: 0 - if (chaptersCount > 5) { - MaterialAlertDialogBuilder(this) - .setTitle(R.string.save_manga) - .setMessage( - getString( - R.string.large_manga_save_confirm, - resources.getQuantityString( - R.plurals.chapters, - chaptersCount, - chaptersCount - ) - ) - ) - .setNegativeButton(android.R.string.cancel, null) - .setPositiveButton(R.string.save) { _, _ -> - DownloadService.start(this, it) - }.show() + val branches = viewModel.branches.value.orEmpty() + if (chaptersCount > 5 || branches.size > 1) { + showSaveConfirmation(it, chaptersCount, branches) } else { DownloadService.start(this, it) } @@ -335,6 +322,36 @@ class DetailsActivity : } } + private fun showSaveConfirmation(manga: Manga, chaptersCount: Int, branches: List) { + val dialogBuilder = MaterialAlertDialogBuilder(this) + .setTitle(R.string.save_manga) + .setNegativeButton(android.R.string.cancel, null) + if (branches.size > 1) { + val items = Array(branches.size) { i -> branches[i].orEmpty() } + val currentBranch = viewModel.selectedBranchIndex.value ?: -1 + val checkedIndices = BooleanArray(branches.size) { i -> i == currentBranch } + dialogBuilder.setMultiChoiceItems(items, checkedIndices) { _, i, checked -> + checkedIndices[i] = checked + }.setPositiveButton(R.string.save) { _, _ -> + val selectedBranches = branches.filterIndexedTo(HashSet()) { i, _ -> checkedIndices[i] } + val chaptersIds = manga.chapters?.mapNotNullToSet { c -> + if (c.branch in selectedBranches) c.id else null + } + DownloadService.start(this, manga, chaptersIds) + } + } else { + dialogBuilder.setMessage( + getString( + R.string.large_manga_save_confirm, + resources.getQuantityString(R.plurals.chapters, chaptersCount, chaptersCount) + ) + ).setPositiveButton(R.string.save) { _, _ -> + DownloadService.start(this, manga) + } + } + dialogBuilder.show() + } + companion object { fun newIntent(context: Context, manga: Manga): Intent { diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt index d0318779b..cba0e11b6 100644 --- a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt @@ -267,10 +267,10 @@ class DetailsViewModel( val dateFormat = settings.getDateFormat() for (i in sourceChapters.indices) { val chapter = sourceChapters[i] + val localChapter = chaptersMap.remove(chapter.id) if (chapter.branch != branch) { continue } - val localChapter = chaptersMap.remove(chapter.id) result += localChapter?.toListItem( isCurrent = i == currentIndex, isUnread = i > currentIndex, @@ -289,15 +289,19 @@ class DetailsViewModel( } if (chaptersMap.isNotEmpty()) { // some chapters on device but not online source result.ensureCapacity(result.size + chaptersMap.size) - chaptersMap.values.mapTo(result) { - it.toListItem( - isCurrent = false, - isUnread = true, - isNew = false, - isMissing = false, - isDownloaded = false, - dateFormat = dateFormat, - ) + chaptersMap.values.mapNotNullTo(result) { + if (it.branch == branch) { + it.toListItem( + isCurrent = false, + isUnread = true, + isNew = false, + isMissing = false, + isDownloaded = false, + dateFormat = dateFormat, + ) + } else { + null + } } result.sortBy { it.chapter.number } } diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/MangaIndex.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/MangaIndex.kt index e0eb87865..3a585be9c 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/data/MangaIndex.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/data/MangaIndex.kt @@ -87,7 +87,7 @@ class MangaIndex(source: String?) { jo.put("uploadDate", chapter.uploadDate) jo.put("scanlator", chapter.scanlator) jo.put("branch", chapter.branch) - jo.put("entries", "%03d\\d{3}".format(chapter.number)) + jo.put("entries", "%08d_%03d\\d{3}".format(chapter.branch.hashCode(), chapter.number)) chapters.put(chapter.id.toString(), jo) } } diff --git a/app/src/main/java/org/koitharu/kotatsu/local/domain/CbzMangaOutput.kt b/app/src/main/java/org/koitharu/kotatsu/local/domain/CbzMangaOutput.kt index 9278dd83f..53e2d474a 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/domain/CbzMangaOutput.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/domain/CbzMangaOutput.kt @@ -36,7 +36,7 @@ class CbzMangaOutput( suspend fun addCover(file: File, ext: String) { val name = buildString { - append(FILENAME_PATTERN.format(0, 0)) + append(FILENAME_PATTERN.format(0, 0, 0)) if (ext.isNotEmpty() && ext.length <= 4) { append('.') append(ext) @@ -50,7 +50,7 @@ class CbzMangaOutput( suspend fun addPage(chapter: MangaChapter, file: File, pageNumber: Int, ext: String) { val name = buildString { - append(FILENAME_PATTERN.format(chapter.number, pageNumber)) + append(FILENAME_PATTERN.format(chapter.branch.hashCode(), chapter.number, pageNumber)) if (ext.isNotEmpty() && ext.length <= 4) { append('.') append(ext) @@ -105,7 +105,7 @@ class CbzMangaOutput( companion object { - private const val FILENAME_PATTERN = "%03d%03d" + private const val FILENAME_PATTERN = "%08d_%03d%03d" const val ENTRY_NAME_INDEX = "index.json" From e63ae12c8cca79823141b42119166314b315c125 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Tue, 19 Apr 2022 16:04:24 +0300 Subject: [PATCH 12/20] Delete local chapters in a service --- app/src/main/AndroidManifest.xml | 1 + .../kotatsu/base/ui/CoroutineIntentService.kt | 37 +++++++++ .../kotatsu/details/ui/ChaptersFragment.kt | 16 +++- .../kotatsu/details/ui/DetailsActivity.kt | 24 +++--- .../kotatsu/details/ui/DetailsViewModel.kt | 19 +---- .../local/ui/LocalChaptersRemoveService.kt | 80 +++++++++++++++++++ app/src/main/res/values-ru/strings.xml | 2 + app/src/main/res/values/strings.xml | 2 + 8 files changed, 149 insertions(+), 32 deletions(-) create mode 100644 app/src/main/java/org/koitharu/kotatsu/base/ui/CoroutineIntentService.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/local/ui/LocalChaptersRemoveService.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6c6c112b8..bba1d89b0 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -102,6 +102,7 @@ + diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/CoroutineIntentService.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/CoroutineIntentService.kt new file mode 100644 index 000000000..241d13f94 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/CoroutineIntentService.kt @@ -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?) +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/ChaptersFragment.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/ChaptersFragment.kt index 143a87713..91698a76b 100644 --- a/app/src/main/java/org/koitharu/kotatsu/details/ui/ChaptersFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/ChaptersFragment.kt @@ -11,6 +11,7 @@ import androidx.appcompat.widget.SearchView import androidx.core.graphics.Insets import androidx.core.view.isVisible import androidx.core.view.updatePadding +import com.google.android.material.snackbar.Snackbar import org.koin.androidx.viewmodel.ext.android.sharedViewModel import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.ui.BaseFragment @@ -21,6 +22,7 @@ import org.koitharu.kotatsu.details.ui.adapter.ChaptersAdapter import org.koitharu.kotatsu.details.ui.adapter.ChaptersSelectionDecoration import org.koitharu.kotatsu.details.ui.model.ChapterListItem import org.koitharu.kotatsu.download.ui.service.DownloadService +import org.koitharu.kotatsu.local.ui.LocalChaptersRemoveService import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.reader.ui.ReaderActivity import org.koitharu.kotatsu.reader.ui.ReaderState @@ -160,8 +162,18 @@ class ChaptersFragment : } R.id.action_delete -> { val ids = selectionDecoration?.checkedItemsIds - if (!ids.isNullOrEmpty()) { - viewModel.deleteChapters(ids.toSet()) + val manga = viewModel.manga.value + when { + ids.isNullOrEmpty() || manga == null -> Unit + ids.size == manga.chapters?.size -> viewModel.deleteLocal() + else -> { + LocalChaptersRemoveService.start(requireContext(), manga, ids) + Snackbar.make( + binding.recyclerViewChapters, + R.string.chapters_will_removed_background, + Snackbar.LENGTH_LONG + ).show() + } } mode.finish() true diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt index b29596141..a1920bf80 100644 --- a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt @@ -82,7 +82,6 @@ class DetailsActivity : viewModel.manga.observe(this, ::onMangaUpdated) viewModel.newChaptersCount.observe(this, ::onNewChaptersChanged) viewModel.onMangaRemoved.observe(this, ::onMangaRemoved) - viewModel.onChaptersRemoved.observe(this, ::onChaptersRemoved) viewModel.onError.observe(this, ::onError) registerReceiver(downloadReceiver, IntentFilter(DownloadService.ACTION_DOWNLOAD_COMPLETE)) @@ -106,10 +105,6 @@ class DetailsActivity : finishAfterTransition() } - private fun onChaptersRemoved(count: Int) { - binding.snackbar.show(getString(R.string.removal_completed)) - } - private fun onError(e: Throwable) { when { ExceptionResolver.canResolve(e) -> { @@ -179,16 +174,15 @@ class DetailsActivity : true } R.id.action_delete -> { - viewModel.manga.value?.let { m -> - MaterialAlertDialogBuilder(this) - .setTitle(R.string.delete_manga) - .setMessage(getString(R.string.text_delete_local_manga, m.title)) - .setPositiveButton(R.string.delete) { _, _ -> - viewModel.deleteLocal(m) - } - .setNegativeButton(android.R.string.cancel, null) - .show() - } + val title = viewModel.manga.value?.title.orEmpty() + MaterialAlertDialogBuilder(this) + .setTitle(R.string.delete_manga) + .setMessage(getString(R.string.text_delete_local_manga, title)) + .setPositiveButton(R.string.delete) { _, _ -> + viewModel.deleteLocal() + } + .setNegativeButton(android.R.string.cancel, null) + .show() true } R.id.action_save -> { diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt index cba0e11b6..0ee363efd 100644 --- a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt @@ -86,7 +86,6 @@ class DetailsViewModel( .asLiveData(viewModelScope.coroutineContext) val onMangaRemoved = SingleLiveEvent() - val onChaptersRemoved = SingleLiveEvent() val branches = mangaData.map { it?.chapters?.mapToSet { x -> x.branch }?.sortedBy { x -> x }.orEmpty() @@ -136,8 +135,11 @@ class DetailsViewModel( loadingJob = doLoad() } - fun deleteLocal(manga: Manga) { + fun deleteLocal() { + val m = mangaData.value ?: return launchLoadingJob(Dispatchers.Default) { + val manga = if (m.source == MangaSource.LOCAL) m else localMangaRepository.findSavedManga(m) + checkNotNull(manga) { "Cannot find saved manga for ${m.title}" } val original = localMangaRepository.getRemoteManga(manga) localMangaRepository.delete(manga) || throw IOException("Unable to delete file") runCatching { @@ -185,19 +187,6 @@ class DetailsViewModel( } } - fun deleteChapters(ids: Set) { - val m = mangaData.value ?: return - if (m.chapters?.size == ids.size) { - deleteLocal(m) - return - } - launchLoadingJob { - localMangaRepository.deleteChapters(m, ids) - reload() - onChaptersRemoved.call(ids.size) - } - } - private fun doLoad() = launchLoadingJob(Dispatchers.Default) { var manga = mangaDataRepository.resolveIntent(intent) ?: throw MangaNotFoundException("Cannot find manga") diff --git a/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalChaptersRemoveService.kt b/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalChaptersRemoveService.kt new file mode 100644 index 000000000..3bc53726c --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalChaptersRemoveService.kt @@ -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() + + override suspend fun processIntent(intent: Intent?) { + val manga = intent?.getParcelableExtra(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) { + 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) + } + } +} \ No newline at end of file diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index a4c27b6a0..3b28783b5 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -274,4 +274,6 @@ Загружать параллельно Замедление загрузки Помогает избежать блокировки IP-адреса + Обработка сохранённой манги + Главы будут удалены в фоновом режиме. Это может занять какое-то время \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e249d13f7..828ad9fe6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -277,4 +277,6 @@ Parallel downloads Download slowdown Helps avoid blocking your IP address + Saved manga processing + Chapters will be removed in the background. It can take some time \ No newline at end of file From d64bd9d9d35e234ae9cca8565628e11d71b479cb Mon Sep 17 00:00:00 2001 From: Koitharu Date: Wed, 20 Apr 2022 11:17:35 +0300 Subject: [PATCH 13/20] Estimate remeaning download time --- .gitignore | 1 + .../ui/service/DownloadNotification.kt | 11 ++++- .../download/ui/service/DownloadService.kt | 21 ++++++--- .../kotatsu/utils/progress/Progress.kt | 2 +- .../utils/progress/TimeLeftEstimator.kt | 47 +++++++++++++++++++ 5 files changed, 72 insertions(+), 10 deletions(-) create mode 100644 app/src/main/java/org/koitharu/kotatsu/utils/progress/TimeLeftEstimator.kt diff --git a/.gitignore b/.gitignore index a8c7b78f4..d4fcf16ce 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ /.idea/workspace.xml /.idea/navEditor.xml /.idea/assetWizardSettings.xml +/.idea/kotlinScripting.xml .DS_Store /build /captures diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadNotification.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadNotification.kt index dd3fa3035..528908bfb 100644 --- a/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadNotification.kt +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadNotification.kt @@ -6,6 +6,7 @@ import android.app.NotificationManager import android.app.PendingIntent import android.content.Context import android.os.Build +import android.text.format.DateUtils import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat @@ -49,7 +50,7 @@ class DownloadNotification(private val context: Context, startId: Int) { builder.setSilent(true) } - fun create(state: DownloadState): Notification { + fun create(state: DownloadState, timeLeft: Long): Notification { builder.setContentTitle(state.manga.title) builder.setContentText(context.getString(R.string.manga_downloading_)) builder.setProgress(1, 0, true) @@ -117,7 +118,13 @@ class DownloadNotification(private val context: Context, startId: Int) { } is DownloadState.Progress -> { builder.setProgress(state.max, state.progress, false) - builder.setContentText((state.percent * 100).format() + "%") + if (timeLeft > 0L) { + val eta = DateUtils.getRelativeTimeSpanString(timeLeft, 0L, DateUtils.SECOND_IN_MILLIS) + builder.setContentText(eta) + } else { + val percent = (state.percent * 100).format() + builder.setContentText(context.getString(R.string.percent_string_pattern, percent)) + } builder.setCategory(NotificationCompat.CATEGORY_PROGRESS) builder.setStyle(null) builder.setOngoing(true) diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt index 13365094c..a88562989 100644 --- a/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt @@ -11,10 +11,7 @@ import android.widget.Toast import androidx.core.content.ContextCompat import androidx.lifecycle.lifecycleScope import com.google.android.material.dialog.MaterialAlertDialogBuilder -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.mapLatest -import kotlinx.coroutines.flow.transformWhile +import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import kotlinx.coroutines.plus import org.koin.android.ext.android.get @@ -32,6 +29,7 @@ import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.utils.ext.connectivityManager import org.koitharu.kotatsu.utils.ext.throttle import org.koitharu.kotatsu.utils.progress.ProgressJob +import org.koitharu.kotatsu.utils.progress.TimeLeftEstimator import java.util.concurrent.TimeUnit class DownloadService : BaseService() { @@ -99,13 +97,22 @@ class DownloadService : BaseService() { private fun listenJob(job: ProgressJob) { lifecycleScope.launch { val startId = job.progressValue.startId + val timeLeftEstimator = TimeLeftEstimator() val notification = DownloadNotification(this@DownloadService, startId) - notificationSwitcher.notify(startId, notification.create(job.progressValue)) + notificationSwitcher.notify(startId, notification.create(job.progressValue, -1L)) job.progressAsFlow() + .onEach { state -> + if (state is DownloadState.Progress) { + timeLeftEstimator.tick(value = state.progress, total = state.max) + } else { + timeLeftEstimator.emptyTick() + } + } .throttle { state -> if (state is DownloadState.Progress) 400L else 0L } .whileActive() .collect { state -> - notificationSwitcher.notify(startId, notification.create(state)) + val timeLeft = timeLeftEstimator.getEstimatedTimeLeft() + notificationSwitcher.notify(startId, notification.create(state, timeLeft)) } job.join() (job.progressValue as? DownloadState.Done)?.let { @@ -119,7 +126,7 @@ class DownloadService : BaseService() { if (job.isCancelled) { null } else { - notification.create(job.progressValue) + notification.create(job.progressValue, -1L) } ) stopSelf(startId) diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/progress/Progress.kt b/app/src/main/java/org/koitharu/kotatsu/utils/progress/Progress.kt index 7dff7fbf5..5723cae17 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/progress/Progress.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/progress/Progress.kt @@ -6,7 +6,7 @@ import kotlinx.parcelize.Parcelize @Parcelize data class Progress( val value: Int, - val total: Int + val total: Int, ) : Parcelable, Comparable { override fun compareTo(other: Progress): Int { diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/progress/TimeLeftEstimator.kt b/app/src/main/java/org/koitharu/kotatsu/utils/progress/TimeLeftEstimator.kt new file mode 100644 index 000000000..5cb7aafc5 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/utils/progress/TimeLeftEstimator.kt @@ -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() + 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, + ) +} \ No newline at end of file From 1f411b753076523a2718b25887abe5b9e27141bf Mon Sep 17 00:00:00 2001 From: Koitharu Date: Wed, 20 Apr 2022 11:50:55 +0300 Subject: [PATCH 14/20] Cleanup temporary files --- .../download/ui/service/DownloadService.kt | 11 ++++--- .../koitharu/kotatsu/local/data/CbzFilter.kt | 4 +++ .../kotatsu/local/data/TempFileFilter.kt | 11 +++++++ .../local/domain/LocalMangaRepository.kt | 31 ++++++++++++------- .../kotatsu/local/ui/LocalListViewModel.kt | 20 +++++++++++- 5 files changed, 60 insertions(+), 17 deletions(-) create mode 100644 app/src/main/java/org/koitharu/kotatsu/local/data/TempFileFilter.kt diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt index a88562989..05c6df6bd 100644 --- a/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt @@ -44,6 +44,7 @@ class DownloadService : BaseService() { override fun onCreate() { super.onCreate() + isRunning = true notificationSwitcher = ForegroundNotificationSwitcher(this) val wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager) .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "kotatsu:downloading") @@ -81,6 +82,7 @@ class DownloadService : BaseService() { override fun onDestroy() { unregisterReceiver(controlReceiver) binder = null + isRunning = false super.onDestroy() } @@ -162,11 +164,12 @@ class DownloadService : BaseService() { companion object { - const val ACTION_DOWNLOAD_COMPLETE = - "${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_COMPLETE" + var isRunning: Boolean = false + private set - private const val ACTION_DOWNLOAD_CANCEL = - "${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_CANCEL" + const val ACTION_DOWNLOAD_COMPLETE = "${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_COMPLETE" + + private const val ACTION_DOWNLOAD_CANCEL = "${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_CANCEL" private const val EXTRA_MANGA = "manga" private const val EXTRA_CHAPTERS_IDS = "chapters_ids" diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/CbzFilter.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/CbzFilter.kt index 106cbaacd..8b8fca986 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/data/CbzFilter.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/data/CbzFilter.kt @@ -7,6 +7,10 @@ import java.util.* class CbzFilter : FilenameFilter { override fun accept(dir: File, name: String): Boolean { + return isFileSupported(name) + } + + fun isFileSupported(name: String): Boolean { val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT) return ext == "cbz" || ext == "zip" } diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/TempFileFilter.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/TempFileFilter.kt new file mode 100644 index 000000000..8aef4fead --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/local/data/TempFileFilter.kt @@ -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) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt b/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt index 06b5757bf..43860e451 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt @@ -7,18 +7,13 @@ import androidx.annotation.WorkerThread import androidx.collection.ArraySet import androidx.core.net.toFile import androidx.core.net.toUri -import java.io.File -import java.io.IOException -import java.util.* -import java.util.zip.ZipEntry -import java.util.zip.ZipFile -import kotlin.coroutines.CoroutineContext import kotlinx.coroutines.* import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.local.data.CbzFilter import org.koitharu.kotatsu.local.data.LocalStorageManager import org.koitharu.kotatsu.local.data.MangaIndex +import org.koitharu.kotatsu.local.data.TempFileFilter import org.koitharu.kotatsu.parsers.model.* import org.koitharu.kotatsu.parsers.util.longHashCode import org.koitharu.kotatsu.parsers.util.toCamelCase @@ -26,6 +21,12 @@ import org.koitharu.kotatsu.utils.AlphanumComparator import org.koitharu.kotatsu.utils.ext.deleteAwait import org.koitharu.kotatsu.utils.ext.readText import org.koitharu.kotatsu.utils.ext.resolveName +import java.io.File +import java.io.IOException +import java.util.* +import java.util.zip.ZipEntry +import java.util.zip.ZipFile +import kotlin.coroutines.CoroutineContext private const val MAX_PARALLELISM = 4 @@ -245,7 +246,7 @@ class LocalMangaRepository(private val storageManager: LocalStorageManager) : Ma withContext(Dispatchers.IO) { val name = contentResolver.resolveName(uri) ?: throw IOException("Cannot fetch name from uri: $uri") - if (!isFileSupported(name)) { + if (!filenameFilter.isFileSupported(name)) { throw UnsupportedFileException("Unsupported file on $uri") } val dest = File( @@ -262,15 +263,21 @@ class LocalMangaRepository(private val storageManager: LocalStorageManager) : Ma } } - fun isFileSupported(name: String): Boolean { - val ext = name.substringAfterLast('.').lowercase(Locale.ROOT) - return ext == "cbz" || ext == "zip" - } - suspend fun getOutputDir(): File? { return storageManager.getDefaultWriteableDir() } + suspend fun cleanup() { + val dirs = storageManager.getWriteableDirs() + runInterruptible(Dispatchers.IO) { + dirs.flatMap { dir -> + dir.listFiles(TempFileFilter())?.toList().orEmpty() + }.forEach { file -> + file.delete() + } + } + } + private suspend fun getAllFiles() = storageManager.getReadableDirs().flatMap { dir -> dir.listFiles(filenameFilter)?.toList().orEmpty() } diff --git a/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt index f4568b23f..07dea24b5 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt @@ -3,16 +3,18 @@ package org.koitharu.kotatsu.local.ui import android.net.Uri import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope -import java.io.IOException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.os.ShortcutsRepository import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.download.ui.service.DownloadService import org.koitharu.kotatsu.history.domain.HistoryRepository import org.koitharu.kotatsu.list.ui.MangaListViewModel import org.koitharu.kotatsu.list.ui.model.* @@ -21,6 +23,7 @@ import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct import org.koitharu.kotatsu.utils.progress.Progress +import java.io.IOException class LocalListViewModel( private val repository: LocalMangaRepository, @@ -64,6 +67,7 @@ class LocalListViewModel( init { onRefresh() + cleanup() } override fun onRefresh() { @@ -116,4 +120,18 @@ class LocalListViewModel( listError.value = e } } + + private fun cleanup() { + if (!DownloadService.isRunning) { + viewModelScope.launch { + runCatching { + repository.cleanup() + }.onFailure { error -> + if (BuildConfig.DEBUG) { + error.printStackTrace() + } + } + } + } + } } \ No newline at end of file From 695feef4a6e4a061c5490191cc6c44d254ef2822 Mon Sep 17 00:00:00 2001 From: Xtimms Date: Thu, 21 Apr 2022 20:15:59 +0300 Subject: [PATCH 15/20] Improve simple manga list --- .../ui/FavouritesContainerFragment.kt | 3 +- .../kotatsu/list/ui/MangaListFragment.kt | 8 +- .../list/ui/MangaSelectionDecoration.kt | 2 +- app/src/main/res/layout/fragment_list.xml | 5 +- app/src/main/res/layout/item_feed.xml | 28 +++--- app/src/main/res/layout/item_manga_list.xml | 97 +++++++++---------- 6 files changed, 72 insertions(+), 71 deletions(-) diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/FavouritesContainerFragment.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/FavouritesContainerFragment.kt index 27ae2696d..72b0566b8 100644 --- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/FavouritesContainerFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/FavouritesContainerFragment.kt @@ -25,6 +25,7 @@ import org.koitharu.kotatsu.main.ui.AppBarOwner import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.utils.ext.measureHeight +import org.koitharu.kotatsu.utils.ext.resolveDp import java.util.* class FavouritesContainerFragment : @@ -85,7 +86,7 @@ class FavouritesContainerFragment : top = headerHeight - insets.top ) binding.pager.updatePadding( - top = -headerHeight + top = -headerHeight + resources.resolveDp(8) // 8 dp is needed so that the top of the list is not attached to tabs (visible when ActionMode is active) ) binding.tabs.apply { updatePadding( diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListFragment.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListFragment.kt index d0f5f9d78..4b8c2b570 100644 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListFragment.kt @@ -20,6 +20,7 @@ import org.koitharu.kotatsu.base.ui.list.FitHeightGridLayoutManager import org.koitharu.kotatsu.base.ui.list.FitHeightLinearLayoutManager import org.koitharu.kotatsu.base.ui.list.PaginationScrollListener import org.koitharu.kotatsu.base.ui.list.decor.SpacingItemDecoration +import org.koitharu.kotatsu.base.ui.list.decor.TypedSpacingItemDecoration import org.koitharu.kotatsu.browser.cloudflare.CloudFlareDialog import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver @@ -243,8 +244,11 @@ abstract class MangaListFragment : ListMode.LIST -> { layoutManager = FitHeightLinearLayoutManager(context) val spacing = resources.getDimensionPixelOffset(R.dimen.list_spacing) - addItemDecoration(SpacingItemDecoration(spacing)) - updatePadding(left = spacing, right = spacing) + val decoration = TypedSpacingItemDecoration( + MangaListAdapter.ITEM_TYPE_MANGA_LIST to 0, + fallbackSpacing = spacing + ) + addItemDecoration(decoration) } ListMode.DETAILED_LIST -> { layoutManager = FitHeightLinearLayoutManager(context) diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaSelectionDecoration.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaSelectionDecoration.kt index 106d141f4..5c0623e55 100644 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaSelectionDecoration.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaSelectionDecoration.kt @@ -51,7 +51,7 @@ class MangaSelectionDecoration(context: Context) : AbstractSelectionItemDecorati bounds: RectF, state: RecyclerView.State, ) { - val radius = (child as? CardView)?.radius ?: 0f + val radius = (child as? CardView)?.radius ?: 32f paint.color = fillColor paint.style = Paint.Style.FILL canvas.drawRoundRect(bounds, radius, radius, paint) diff --git a/app/src/main/res/layout/fragment_list.xml b/app/src/main/res/layout/fragment_list.xml index 75194b71d..6fd2271c0 100644 --- a/app/src/main/res/layout/fragment_list.xml +++ b/app/src/main/res/layout/fragment_list.xml @@ -13,7 +13,10 @@ android:layout_height="match_parent" android:clipToPadding="false" android:orientation="vertical" - android:padding="@dimen/grid_spacing_outer" + android:paddingLeft="@dimen/list_spacing" + android:paddingRight="@dimen/list_spacing" + android:paddingTop="@dimen/grid_spacing_outer" + android:paddingBottom="@dimen/grid_spacing_outer" app:fastScrollEnabled="true" tools:layoutManager="org.koitharu.kotatsu.base.ui.list.FitHeightLinearLayoutManager" tools:listitem="@layout/item_manga_list" /> diff --git a/app/src/main/res/layout/item_feed.xml b/app/src/main/res/layout/item_feed.xml index 7b29d3eee..1dcb660b8 100644 --- a/app/src/main/res/layout/item_feed.xml +++ b/app/src/main/res/layout/item_feed.xml @@ -9,17 +9,15 @@ android:clipChildren="false" android:padding="@dimen/list_spacing"> - @@ -28,13 +26,13 @@ android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginStart="16dp" - android:layout_marginEnd="2dp" android:ellipsize="end" - android:singleLine="true" - android:textAppearance="?attr/textAppearanceBodyLarge" + android:maxLines="1" + android:textAppearance="?attr/textAppearanceBodyMedium" + app:layout_constraintBottom_toTopOf="@+id/textView_summary" app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toEndOf="@id/imageView_cover" - app:layout_constraintTop_toTopOf="@id/imageView_cover" + app:layout_constraintStart_toEndOf="@+id/imageView_cover" + app:layout_constraintTop_toTopOf="@+id/imageView_cover" tools:text="@tools:sample/lorem" /> - + \ No newline at end of file diff --git a/app/src/main/res/layout/item_manga_list.xml b/app/src/main/res/layout/item_manga_list.xml index fef7a5c33..5694c2289 100644 --- a/app/src/main/res/layout/item_manga_list.xml +++ b/app/src/main/res/layout/item_manga_list.xml @@ -1,55 +1,52 @@ - - - - - - - - - - - - - - - - - + android:layout_height="wrap_content" + android:background="@drawable/list_selector" + android:clipChildren="false" + android:padding="@dimen/list_spacing"> + + + + + + + + \ No newline at end of file From e91d9ee38e041aab0398388c631a9c908999cb38 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Sat, 23 Apr 2022 13:54:48 +0300 Subject: [PATCH 16/20] Fix list selection corners --- .../list/ui/MangaSelectionDecoration.kt | 22 +++++++++++-------- app/src/main/res/drawable/list_selector.xml | 6 ++--- app/src/main/res/values/dimens.xml | 1 + 3 files changed, 17 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaSelectionDecoration.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaSelectionDecoration.kt index 5c0623e55..3ef530824 100644 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaSelectionDecoration.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaSelectionDecoration.kt @@ -28,6 +28,7 @@ class MangaSelectionDecoration(context: Context) : AbstractSelectionItemDecorati ColorUtils.blendARGB(strokeColor, context.getThemeColor(materialR.attr.colorSurface), 0.8f), 0x74 ) + private val defaultRadius = context.resources.getDimension(R.dimen.list_selector_corner) init { hasBackground = false @@ -51,21 +52,24 @@ class MangaSelectionDecoration(context: Context) : AbstractSelectionItemDecorati bounds: RectF, state: RecyclerView.State, ) { - val radius = (child as? CardView)?.radius ?: 32f + val isCard = child is CardView + val radius = (child as? CardView)?.radius ?: defaultRadius paint.color = fillColor paint.style = Paint.Style.FILL canvas.drawRoundRect(bounds, radius, radius, paint) paint.color = strokeColor paint.style = Paint.Style.STROKE canvas.drawRoundRect(bounds, radius, radius, paint) - checkIcon?.run { - setBounds( - (bounds.left + iconOffset).toInt(), - (bounds.top + iconOffset).toInt(), - (bounds.left + iconOffset + intrinsicWidth).toInt(), - (bounds.top + iconOffset + intrinsicHeight).toInt(), - ) - draw(canvas) + if (isCard) { + checkIcon?.run { + setBounds( + (bounds.left + iconOffset).toInt(), + (bounds.top + iconOffset).toInt(), + (bounds.left + iconOffset + intrinsicWidth).toInt(), + (bounds.top + iconOffset + intrinsicHeight).toInt(), + ) + draw(canvas) + } } } } \ No newline at end of file diff --git a/app/src/main/res/drawable/list_selector.xml b/app/src/main/res/drawable/list_selector.xml index 69666c05d..b1763b045 100644 --- a/app/src/main/res/drawable/list_selector.xml +++ b/app/src/main/res/drawable/list_selector.xml @@ -11,7 +11,7 @@ android:bottom="2dp" android:left="2dp"> - + @@ -22,7 +22,7 @@ android:bottom="2dp" android:left="2dp"> - + @@ -32,7 +32,7 @@ android:bottom="2dp" android:left="2dp"> - + diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index c2d158769..282d15034 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -20,6 +20,7 @@ 48dp 16dp 2dp + 12dp 124dp 4dp From 7bffb5f22d70ae073a0b170d976aa629aac12f36 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Sat, 23 Apr 2022 14:45:45 +0300 Subject: [PATCH 17/20] Select source domains using AutoCompleteTextView --- app/build.gradle | 2 +- .../kotatsu/settings/SourceSettingsExt.kt | 32 ++++------ .../utils/AutoCompleteTextViewPreference.kt | 58 ++++++++++++++++++ ...preference_dialog_autocompletetextview.xml | 59 +++++++++++++++++++ app/src/main/res/values/attrs.xml | 1 + app/src/main/res/values/styles.xml | 4 ++ 6 files changed, 136 insertions(+), 20 deletions(-) create mode 100644 app/src/main/java/org/koitharu/kotatsu/settings/utils/AutoCompleteTextViewPreference.kt create mode 100644 app/src/main/res/layout/preference_dialog_autocompletetextview.xml diff --git a/app/build.gradle b/app/build.gradle index c48ed31fd..efa110f85 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -65,7 +65,7 @@ android { } dependencies { implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar']) - implementation('com.github.nv95:kotatsu-parsers:c1aa8a45dc') { + implementation('com.github.nv95:kotatsu-parsers:1d171d41fe') { exclude group: 'org.json', module: 'json' } diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/SourceSettingsExt.kt b/app/src/main/java/org/koitharu/kotatsu/settings/SourceSettingsExt.kt index 0392bb9c7..3a7c8843c 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/SourceSettingsExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/SourceSettingsExt.kt @@ -1,15 +1,15 @@ package org.koitharu.kotatsu.settings import android.view.inputmethod.EditorInfo -import androidx.preference.* +import androidx.preference.EditTextPreference +import androidx.preference.Preference +import androidx.preference.PreferenceFragmentCompat import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.parser.RemoteMangaRepository import org.koitharu.kotatsu.parsers.config.ConfigKey +import org.koitharu.kotatsu.settings.utils.AutoCompleteTextViewPreference import org.koitharu.kotatsu.settings.utils.EditTextBindListener import org.koitharu.kotatsu.settings.utils.EditTextDefaultSummaryProvider -import org.koitharu.kotatsu.utils.ext.setDefaultValueCompat - -private const val KEY_DOMAIN = "domain" fun PreferenceFragmentCompat.addPreferencesFromRepository(repository: RemoteMangaRepository) { val configKeys = repository.getConfigKeys() @@ -19,23 +19,17 @@ fun PreferenceFragmentCompat.addPreferencesFromRepository(repository: RemoteMang is ConfigKey.Domain -> { val presetValues = key.presetValues if (presetValues.isNullOrEmpty()) { - EditTextPreference(requireContext()).apply { - summaryProvider = EditTextDefaultSummaryProvider(key.defaultValue) - setOnBindEditTextListener( - EditTextBindListener( - inputType = EditorInfo.TYPE_CLASS_TEXT or EditorInfo.TYPE_TEXT_VARIATION_URI, - hint = key.defaultValue, - ) - ) - } + EditTextPreference(requireContext()) } else { - DropDownPreference(requireContext()).apply { - entries = presetValues - entryValues = entries - summaryProvider = ListPreference.SimpleSummaryProvider.getInstance() - setDefaultValueCompat(key.defaultValue) - } + AutoCompleteTextViewPreference(requireContext()).apply { entries = presetValues } }.apply { + summaryProvider = EditTextDefaultSummaryProvider(key.defaultValue) + setOnBindEditTextListener( + EditTextBindListener( + inputType = EditorInfo.TYPE_CLASS_TEXT or EditorInfo.TYPE_TEXT_VARIATION_URI, + hint = key.defaultValue, + ) + ) setTitle(R.string.domain) setDialogTitle(R.string.domain) } diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/utils/AutoCompleteTextViewPreference.kt b/app/src/main/java/org/koitharu/kotatsu/settings/utils/AutoCompleteTextViewPreference.kt new file mode 100644 index 000000000..fe1d3f15c --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/settings/utils/AutoCompleteTextViewPreference.kt @@ -0,0 +1,58 @@ +package org.koitharu.kotatsu.settings.utils + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import android.view.ViewGroup +import android.widget.ArrayAdapter +import android.widget.AutoCompleteTextView +import android.widget.EditText +import androidx.annotation.ArrayRes +import androidx.annotation.AttrRes +import androidx.annotation.StyleRes +import androidx.preference.EditTextPreference +import org.koitharu.kotatsu.R + +class AutoCompleteTextViewPreference @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + @AttrRes defStyleAttr: Int = R.attr.autoCompleteTextViewPreferenceStyle, + @StyleRes defStyleRes: Int = R.style.Preference_AutoCompleteTextView, +) : EditTextPreference(context, attrs, defStyleAttr, defStyleRes) { + + private val autoCompleteBindListener = AutoCompleteBindListener() + var entries: Array = emptyArray() + + init { + super.setOnBindEditTextListener(autoCompleteBindListener) + } + + fun setEntries(@ArrayRes arrayResId: Int) { + this.entries = context.resources.getStringArray(arrayResId) + } + + fun setEntries(entries: Collection) { + this.entries = entries.toTypedArray() + } + + override fun setOnBindEditTextListener(onBindEditTextListener: OnBindEditTextListener?) { + autoCompleteBindListener.delegate = onBindEditTextListener + } + + private inner class AutoCompleteBindListener : OnBindEditTextListener { + + var delegate: OnBindEditTextListener? = null + + override fun onBindEditText(editText: EditText) { + delegate?.onBindEditText(editText) + if (editText !is AutoCompleteTextView || entries.isEmpty()) { + return + } + editText.threshold = 0 + editText.setAdapter(ArrayAdapter(editText.context, android.R.layout.simple_spinner_dropdown_item, entries)) + (editText.parent as? ViewGroup)?.findViewById(R.id.dropdown)?.setOnClickListener { + editText.showDropDown() + } + } + } +} \ No newline at end of file diff --git a/app/src/main/res/layout/preference_dialog_autocompletetextview.xml b/app/src/main/res/layout/preference_dialog_autocompletetextview.xml new file mode 100644 index 000000000..38f92332d --- /dev/null +++ b/app/src/main/res/layout/preference_dialog_autocompletetextview.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml index 449fc3a29..c14ddff76 100644 --- a/app/src/main/res/values/attrs.xml +++ b/app/src/main/res/values/attrs.xml @@ -3,6 +3,7 @@ + diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 2ad34b167..2333f5d68 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -156,4 +156,8 @@ @layout/preference_dialog_multiautocompletetextview + + From 75fcd317584a02b8f5b9ecc0f92dd960c56b7a65 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Sat, 23 Apr 2022 15:31:52 +0300 Subject: [PATCH 18/20] Fix locking app on screen rotation --- .../org/koitharu/kotatsu/main/ui/protect/AppProtectHelper.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/org/koitharu/kotatsu/main/ui/protect/AppProtectHelper.kt b/app/src/main/java/org/koitharu/kotatsu/main/ui/protect/AppProtectHelper.kt index 35f7321a5..a92d866d2 100644 --- a/app/src/main/java/org/koitharu/kotatsu/main/ui/protect/AppProtectHelper.kt +++ b/app/src/main/java/org/koitharu/kotatsu/main/ui/protect/AppProtectHelper.kt @@ -34,7 +34,7 @@ class AppProtectHelper(private val settings: AppSettings) : Application.Activity override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit override fun onActivityDestroyed(activity: Activity) { - if (activity !is ProtectActivity && activity.isTaskRoot) { + if (activity !is ProtectActivity && activity.isFinishing && activity.isTaskRoot) { restoreLock() } } From acf7102d075ea4d60826c6224346874d45268f52 Mon Sep 17 00:00:00 2001 From: lowak Date: Sun, 17 Apr 2022 12:07:28 +0200 Subject: [PATCH 19/20] Translated using Weblate (Swedish) Currently translated at 100.0% (274 of 274 strings) Co-authored-by: lowak Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sv/ Translation: Kotatsu/Strings --- app/src/main/res/values-sv/strings.xml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 6a84c9707..f477537f4 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -53,7 +53,7 @@ Dela %s Sök manga Cache - Visa en notifikation om en nyare version av äpplen finns tillgänglig + Visa en avisering om en nyare version av appen finns tillgänglig Nyaste Sorteringsordning Mörkt @@ -90,25 +90,25 @@ Rensat Rensa cache för miniatyrer Öppna i webbläsare - Notifikationer + Aviseringar Rensa sökhistorik Domän Endast gester Internlagring Externlagring - Kolla om det finns en nyare version av appen + Håll appen uppdaterad En ny version av appen finns tillgänglig %1$d av %2$d aktiva Nya kapitel Den här mangan har %s. Spara hela\? Spara Ladda ned - Notifikationsinställningar + Aviseringsinställningar LED-indikator - Notifiera om uppdateringar på manga du läser + Avisera om uppdateringar på manga du läser Läs från början Starta om - Notifikationsljud + Aviseringsljud Vibration Favoritkategorier Inga tillgängliga uppdateringar From c73af2d45f2770a3eb27054cc746340cb5e1f3fe Mon Sep 17 00:00:00 2001 From: Koitharu Date: Sat, 23 Apr 2022 15:37:16 +0300 Subject: [PATCH 20/20] Update version --- app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index efa110f85..6b6350b7d 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -14,8 +14,8 @@ android { applicationId 'org.koitharu.kotatsu' minSdkVersion 21 targetSdkVersion 32 - versionCode 402 - versionName '3.1.1' + versionCode 403 + versionName '3.2' generatedDensities = [] testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"