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 { 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 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/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"> - + - + + + + + + + + + + \ 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 e42e5ec06..3b28783b5 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -271,4 +271,9 @@ Удалить выбранную мангу с накопителя? Удаление завершено Загрузить выбранную мангу со всеми главами? Это может привести к большому расходу трафика и места на накопителе + Загружать параллельно + Замедление загрузки + Помогает избежать блокировки 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..828ad9fe6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -274,4 +274,9 @@ 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 + Saved manga processing + Chapters will be removed in the background. It can take some time \ 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" /> - - + + + + + +