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/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/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/base/ui/util/CountedBooleanLiveData.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/util/CountedBooleanLiveData.kt
new file mode 100644
index 000000000..cb54ef7db
--- /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 != this.value) {
+ super.setValue(newValue)
+ }
+ }
+}
\ No newline at end of file
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/prefs/AppSettings.kt b/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt
index e3b17c5dc..5784153d3 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
@@ -151,6 +151,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)
@@ -270,6 +276,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/core/zip/ZipOutput.kt b/app/src/main/java/org/koitharu/kotatsu/core/zip/ZipOutput.kt
new file mode 100644
index 000000000..d34e753ab
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/core/zip/ZipOutput.kt
@@ -0,0 +1,118 @@
+package org.koitharu.kotatsu.core.zip
+
+import androidx.annotation.WorkerThread
+import androidx.collection.ArraySet
+import okio.Closeable
+import java.io.File
+import java.io.FileInputStream
+import java.util.zip.Deflater
+import java.util.zip.ZipEntry
+import java.util.zip.ZipFile
+import java.util.zip.ZipOutputStream
+
+class ZipOutput(
+ val file: File,
+ compressionLevel: Int = Deflater.DEFAULT_COMPRESSION,
+) : Closeable {
+
+ private val entryNames = ArraySet()
+ private var isClosed = false
+ private val output = ZipOutputStream(file.outputStream()).apply {
+ setLevel(compressionLevel)
+ }
+
+ @WorkerThread
+ fun put(name: String, file: File): Boolean {
+ return output.appendFile(file, name)
+ }
+
+ @WorkerThread
+ fun put(name: String, content: String): Boolean {
+ return output.appendText(content, name)
+ }
+
+ @WorkerThread
+ fun addDirectory(name: String): Boolean {
+ val entry = if (name.endsWith("/")) {
+ ZipEntry(name)
+ } else {
+ ZipEntry("$name/")
+ }
+ return if (entryNames.add(entry.name)) {
+ output.putNextEntry(entry)
+ output.closeEntry()
+ true
+ } else {
+ false
+ }
+ }
+
+ @WorkerThread
+ fun copyEntryFrom(other: ZipFile, entry: ZipEntry): Boolean {
+ return if (entryNames.add(entry.name)) {
+ val zipEntry = ZipEntry(entry.name)
+ output.putNextEntry(zipEntry)
+ other.getInputStream(entry).use { input ->
+ input.copyTo(output)
+ }
+ output.closeEntry()
+ true
+ } else {
+ false
+ }
+ }
+
+ fun finish() {
+ output.finish()
+ output.flush()
+ }
+
+ override fun close() {
+ if (!isClosed) {
+ output.close()
+ isClosed = true
+ }
+ }
+
+ @WorkerThread
+ private fun ZipOutputStream.appendFile(fileToZip: File, name: String): Boolean {
+ if (fileToZip.isDirectory) {
+ val entry = if (name.endsWith("/")) {
+ ZipEntry(name)
+ } else {
+ ZipEntry("$name/")
+ }
+ if (!entryNames.add(entry.name)) {
+ return false
+ }
+ putNextEntry(entry)
+ closeEntry()
+ fileToZip.listFiles()?.forEach { childFile ->
+ appendFile(childFile, "$name/${childFile.name}")
+ }
+ } else {
+ FileInputStream(fileToZip).use { fis ->
+ if (!entryNames.add(name)) {
+ return false
+ }
+ val zipEntry = ZipEntry(name)
+ putNextEntry(zipEntry)
+ fis.copyTo(this)
+ closeEntry()
+ }
+ }
+ return true
+ }
+
+ @WorkerThread
+ private fun ZipOutputStream.appendText(content: String, name: String): Boolean {
+ if (!entryNames.add(name)) {
+ return false
+ }
+ val zipEntry = ZipEntry(name)
+ putNextEntry(zipEntry)
+ content.byteInputStream().copyTo(this)
+ closeEntry()
+ return true
+ }
+}
\ 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 96f369c56..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
@@ -9,9 +9,9 @@ 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 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
@@ -22,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
@@ -67,8 +68,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 +95,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) {
@@ -154,11 +155,29 @@ 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
+ 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
+ }
R.id.action_select_all -> {
val ids = chaptersAdapter?.items?.map { it.chapter.id } ?: return false
selectionDecoration?.checkAll(ids)
@@ -188,6 +207,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..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
@@ -41,13 +41,16 @@ 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
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 {
@@ -171,38 +174,23 @@ class DetailsActivity : BaseActivity(), TabLayoutMediato
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 -> {
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)
}
@@ -262,7 +250,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 {
@@ -328,6 +316,36 @@ class DetailsActivity : BaseActivity(), TabLayoutMediato
}
}
+ 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 {
@@ -340,4 +358,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..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
@@ -28,6 +28,7 @@ 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
@@ -88,18 +89,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(
@@ -134,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 {
@@ -252,10 +256,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,
@@ -274,15 +278,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/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 3373273ea..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
@@ -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,9 @@ 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.core.prefs.AppSettings
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,11 +28,11 @@ 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
private const val DOWNLOAD_ERROR_DELAY = 500L
-private const val TEMP_PAGE_FILE = "page.tmp"
+private const val SLOWDOWN_DELAY = 200L
class DownloadManager(
private val coroutineScope: CoroutineScope,
@@ -41,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(
@@ -52,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,
@@ -80,7 +81,8 @@ class DownloadManager(
var cover: Drawable? = null
val destination = localMangaRepository.getOutputDir()
checkNotNull(destination) { context.getString(R.string.cannot_find_available_storage) }
- var output: MangaZip? = null
+ val tempFileName = "${manga.id}_$startId.tmp"
+ var output: CbzMangaOutput? = null
try {
if (manga.source == MangaSource.LOCAL) {
manga = localMangaRepository.getRemoteManga(manga) ?: error("Cannot obtain remote manga instance")
@@ -98,10 +100,9 @@ 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.get(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(
@@ -118,22 +119,29 @@ 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)
+ val file = cache[url] ?: downloadFile(url, page.referer, destination, tempFileName)
output.addPage(
chapter = chapter,
file = file,
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,
@@ -142,12 +150,15 @@ class DownloadManager(
totalPages = pages.size,
currentPage = pageIndex,
)
+
+ if (settings.isDownloadsSlowdownEnabled) {
+ delay(SLOWDOWN_DELAY)
+ }
}
}
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) {
@@ -161,14 +172,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)
@@ -176,26 +187,14 @@ class DownloadManager(
.get()
.build()
val call = okHttp.newCall(request)
- var attempts = MAX_DOWNLOAD_ATTEMPTS
- val file = File(destination, TEMP_PAGE_FILE)
- 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 file = File(destination, tempFileName)
+ 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) =
@@ -208,4 +207,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/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 41bb3e273..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
@@ -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() {
@@ -46,16 +44,12 @@ 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")
- 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))
@@ -88,6 +82,7 @@ class DownloadService : BaseService() {
override fun onDestroy() {
unregisterReceiver(controlReceiver)
binder = null
+ isRunning = false
super.onDestroy()
}
@@ -104,13 +99,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 {
@@ -124,7 +128,7 @@ class DownloadService : BaseService() {
if (job.isCancelled) {
null
} else {
- notification.create(job.progressValue)
+ notification.create(job.progressValue, -1L)
}
)
stopSelf(startId)
@@ -160,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/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/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/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/MangaIndex.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/MangaIndex.kt
index f7d1a8aaf..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
@@ -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())
}
@@ -84,11 +87,15 @@ 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)
}
}
+ 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/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/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/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..53e2d474a
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/local/domain/CbzMangaOutput.kt
@@ -0,0 +1,153 @@
+package org.koitharu.kotatsu.local.domain
+
+import androidx.annotation.WorkerThread
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.runInterruptible
+import okio.Closeable
+import org.koitharu.kotatsu.core.zip.ZipOutput
+import org.koitharu.kotatsu.local.data.MangaIndex
+import org.koitharu.kotatsu.parsers.model.Manga
+import org.koitharu.kotatsu.parsers.model.MangaChapter
+import org.koitharu.kotatsu.parsers.util.toFileNameSafe
+import org.koitharu.kotatsu.utils.ext.deleteAwait
+import org.koitharu.kotatsu.utils.ext.readText
+import java.io.File
+import java.util.zip.ZipFile
+
+class CbzMangaOutput(
+ val file: File,
+ manga: Manga,
+) : Closeable {
+
+ private val output = ZipOutput(File(file.path + ".tmp"))
+ private val index = MangaIndex(null)
+
+ init {
+ index.setMangaInfo(manga, false)
+ }
+
+ suspend fun mergeWithExisting() {
+ if (file.exists()) {
+ runInterruptible(Dispatchers.IO) {
+ mergeWith(file)
+ }
+ }
+ }
+
+ suspend fun addCover(file: File, ext: String) {
+ val name = buildString {
+ append(FILENAME_PATTERN.format(0, 0, 0))
+ if (ext.isNotEmpty() && ext.length <= 4) {
+ append('.')
+ append(ext)
+ }
+ }
+ runInterruptible(Dispatchers.IO) {
+ output.put(name, file)
+ }
+ index.setCoverEntry(name)
+ }
+
+ suspend fun addPage(chapter: MangaChapter, file: File, pageNumber: Int, ext: String) {
+ val name = buildString {
+ append(FILENAME_PATTERN.format(chapter.branch.hashCode(), chapter.number, pageNumber))
+ if (ext.isNotEmpty() && ext.length <= 4) {
+ append('.')
+ append(ext)
+ }
+ }
+ runInterruptible(Dispatchers.IO) {
+ output.put(name, file)
+ }
+ index.addChapter(chapter)
+ }
+
+ suspend fun finalize() {
+ runInterruptible(Dispatchers.IO) {
+ output.put(ENTRY_NAME_INDEX, index.toString())
+ output.finish()
+ output.close()
+ }
+ file.deleteAwait()
+ output.file.renameTo(file)
+ }
+
+ suspend fun cleanup() {
+ output.file.deleteAwait()
+ }
+
+ override fun close() {
+ output.close()
+ }
+
+ @WorkerThread
+ private fun mergeWith(other: File) {
+ var otherIndex: MangaIndex? = null
+ ZipFile(other).use { zip ->
+ for (entry in zip.entries()) {
+ if (entry.name == ENTRY_NAME_INDEX) {
+ otherIndex = MangaIndex(
+ zip.getInputStream(entry).use {
+ it.reader().readText()
+ }
+ )
+ } else {
+ output.copyEntryFrom(zip, entry)
+ }
+ }
+ }
+ otherIndex?.getMangaInfo()?.chapters?.let { chapters ->
+ for (chapter in chapters) {
+ index.addChapter(chapter)
+ }
+ }
+ }
+
+ companion object {
+
+ private const val FILENAME_PATTERN = "%08d_%03d%03d"
+
+ const val ENTRY_NAME_INDEX = "index.json"
+
+ fun get(root: File, manga: Manga): CbzMangaOutput {
+ val name = manga.title.toFileNameSafe() + ".cbz"
+ val file = File(root, name)
+ return CbzMangaOutput(file, manga)
+ }
+
+ @WorkerThread
+ fun filterChapters(subject: CbzMangaOutput, idsToRemove: Set) {
+ 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 c6137a485..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
@@ -3,18 +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 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.MangaZip
+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
@@ -27,6 +26,9 @@ 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
class LocalMangaRepository(private val storageManager: LocalStorageManager) : MangaRepository {
@@ -39,27 +41,43 @@ 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 {
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)
- 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)
@@ -94,10 +112,18 @@ 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)
+ }
+
+ @WorkerThread
@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 +184,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 +196,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
@@ -187,6 +213,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? {
@@ -211,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(
@@ -228,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/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/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
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)
+ }
}
}
}
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 496fafcea..8bfd5e39a 100644
--- a/app/src/main/java/org/koitharu/kotatsu/settings/ContentSettingsFragment.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/settings/ContentSettingsFragment.kt
@@ -12,6 +12,7 @@ import org.koitharu.kotatsu.base.ui.BasePreferenceFragment
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.settings.utils.SliderPreference
import org.koitharu.kotatsu.utils.ext.getStorageName
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
@@ -28,6 +29,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/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
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/java/org/koitharu/kotatsu/utils/ext/AndroidExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/AndroidExt.kt
index 21140be00..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,9 +15,14 @@ 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) {
+ unregisterNetworkCallback(this)
if (cont.isActive) {
cont.resume(network)
}
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