diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseViewModel.kt index 50d8fe803..39233e28f 100644 --- a/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseViewModel.kt @@ -1,18 +1,18 @@ package org.koitharu.kotatsu.base.ui -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlin.coroutines.CoroutineContext import kotlin.coroutines.EmptyCoroutineContext import kotlinx.coroutines.* import org.koitharu.kotatsu.BuildConfig +import org.koitharu.kotatsu.base.ui.util.CountedBooleanLiveData import org.koitharu.kotatsu.utils.SingleLiveEvent abstract class BaseViewModel : ViewModel() { val onError = SingleLiveEvent() - val isLoading = MutableLiveData(false) + val isLoading = CountedBooleanLiveData() protected fun launchJob( context: CoroutineContext = EmptyCoroutineContext, diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/util/CountedBooleanLiveData.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/util/CountedBooleanLiveData.kt new file mode 100644 index 000000000..fd7f1abf6 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/util/CountedBooleanLiveData.kt @@ -0,0 +1,20 @@ +package org.koitharu.kotatsu.base.ui.util + +import androidx.lifecycle.MutableLiveData + +class CountedBooleanLiveData : MutableLiveData(false) { + + private var counter = 0 + + override fun setValue(value: Boolean) { + if (value) { + counter++ + } else { + counter-- + } + val newValue = counter > 0 + if (newValue != value) { + super.setValue(value) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/zip/ZipOutput.kt b/app/src/main/java/org/koitharu/kotatsu/core/zip/ZipOutput.kt index bcdc5b0ee..23a030c4e 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/zip/ZipOutput.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/zip/ZipOutput.kt @@ -2,8 +2,6 @@ package org.koitharu.kotatsu.core.zip import androidx.annotation.WorkerThread import androidx.collection.ArraySet -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.runInterruptible import okio.Closeable import java.io.File import java.util.zip.Deflater @@ -22,16 +20,26 @@ class ZipOutput( setLevel(compressionLevel) } - suspend fun put(name: String, file: File): Unit = runInterruptible(Dispatchers.IO) { + fun put(name: String, file: File) { entryNames.add(name) output.appendFile(file, name) } - suspend fun put(name: String, content: String): Unit = runInterruptible(Dispatchers.IO) { + fun put(name: String, content: String) { entryNames.add(name) output.appendText(content, name) } + fun addDirectory(name: String) { + entryNames.add(name) + val entry = if (name.endsWith("/")) { + ZipEntry(name) + } else { + ZipEntry("$name/") + } + output.putNextEntry(entry) + } + @WorkerThread fun copyEntryFrom(other: ZipFile, entry: ZipEntry): Boolean { return if (entryNames.add(entry.name)) { @@ -47,7 +55,7 @@ class ZipOutput( } } - suspend fun finish() = runInterruptible(Dispatchers.IO) { + fun finish() { output.finish() output.flush() } diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/ChaptersFragment.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/ChaptersFragment.kt index 96f369c56..0dbf3eea5 100644 --- a/app/src/main/java/org/koitharu/kotatsu/details/ui/ChaptersFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/ChaptersFragment.kt @@ -154,11 +154,19 @@ class ChaptersFragment : DownloadService.start( context ?: return false, viewModel.getRemoteManga() ?: viewModel.manga.value ?: return false, - selectionDecoration?.checkedItemsIds + selectionDecoration?.checkedItemsIds?.toSet() ) mode.finish() true } + R.id.action_delete -> { + val ids = selectionDecoration?.checkedItemsIds + if (!ids.isNullOrEmpty()) { + viewModel.deleteChapters(ids.toSet()) + } + mode.finish() + true + } R.id.action_select_all -> { val ids = chaptersAdapter?.items?.map { it.chapter.id } ?: return false selectionDecoration?.checkAll(ids) @@ -188,6 +196,9 @@ class ChaptersFragment : menu.findItem(R.id.action_save).isVisible = items.none { x -> x.chapter.source == MangaSource.LOCAL } + menu.findItem(R.id.action_delete).isVisible = items.all { x -> + x.chapter.source == MangaSource.LOCAL + } mode.title = items.size.toString() return true } diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt index 3ef3d8517..18e0e325b 100644 --- a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt @@ -47,7 +47,9 @@ import org.koitharu.kotatsu.search.ui.global.GlobalSearchActivity import org.koitharu.kotatsu.utils.ShareHelper import org.koitharu.kotatsu.utils.ext.getDisplayMessage -class DetailsActivity : BaseActivity(), TabLayoutMediator.TabConfigurationStrategy, +class DetailsActivity : + BaseActivity(), + TabLayoutMediator.TabConfigurationStrategy, AdapterView.OnItemSelectedListener { private val viewModel by viewModel { @@ -79,6 +81,7 @@ class DetailsActivity : BaseActivity(), TabLayoutMediato viewModel.manga.observe(this, ::onMangaUpdated) viewModel.newChaptersCount.observe(this, ::onNewChaptersChanged) viewModel.onMangaRemoved.observe(this, ::onMangaRemoved) + viewModel.onChaptersRemoved.observe(this, ::onChaptersRemoved) viewModel.onError.observe(this, ::onError) registerReceiver(downloadReceiver, IntentFilter(DownloadService.ACTION_DOWNLOAD_COMPLETE)) @@ -102,6 +105,10 @@ class DetailsActivity : BaseActivity(), TabLayoutMediato finishAfterTransition() } + private fun onChaptersRemoved(count: Int) { + binding.snackbar.show(getString(R.string.removal_completed)) + } + private fun onError(e: Throwable) { when { ExceptionResolver.canResolve(e) -> { @@ -262,7 +269,7 @@ class DetailsActivity : BaseActivity(), TabLayoutMediato fun showChapterMissingDialog(chapterId: Long) { val remoteManga = viewModel.getRemoteManga() if (remoteManga == null) { - binding.snackbar.show(getString( R.string.chapter_is_missing)) + binding.snackbar.show(getString(R.string.chapter_is_missing)) return } MaterialAlertDialogBuilder(this).apply { @@ -340,4 +347,4 @@ class DetailsActivity : BaseActivity(), TabLayoutMediato .putExtra(MangaIntent.KEY_ID, mangaId) } } -} +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt index afc0129ac..53a173369 100644 --- a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt @@ -85,6 +85,7 @@ class DetailsViewModel( .asLiveData(viewModelScope.coroutineContext) val onMangaRemoved = SingleLiveEvent() + val onChaptersRemoved = SingleLiveEvent() val branches = mangaData.map { it?.chapters?.mapToSet { x -> x.branch }?.sortedBy { x -> x }.orEmpty() @@ -183,6 +184,15 @@ class DetailsViewModel( } } + fun deleteChapters(ids: Set) { + launchLoadingJob { + val manga = checkNotNull(mangaData.value) + localMangaRepository.deleteChapters(manga, ids) + reload() + onChaptersRemoved.call(ids.size) + } + } + private fun doLoad() = launchLoadingJob(Dispatchers.Default) { var manga = mangaDataRepository.resolveIntent(intent) ?: throw MangaNotFoundException("Cannot find manga") diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/model/ChapterListItem.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/model/ChapterListItem.kt index 6f37c8e53..2d5b90840 100644 --- a/app/src/main/java/org/koitharu/kotatsu/details/ui/model/ChapterListItem.kt +++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/model/ChapterListItem.kt @@ -40,11 +40,10 @@ class ChapterListItem( override fun hashCode(): Int { var result = chapter.hashCode() result = 31 * result + flags - result = 31 * result + uploadDate.hashCode() + result = 31 * result + (uploadDate?.hashCode() ?: 0) return result } - companion object { const val FLAG_UNREAD = 2 diff --git a/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt b/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt index 4cc868344..3a2067705 100644 --- a/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt +++ b/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt @@ -98,7 +98,7 @@ class DownloadManager( }.getOrNull() outState.value = DownloadState.Preparing(startId, manga, cover) val data = if (manga.chapters.isNullOrEmpty()) repo.getDetails(manga) else manga - output = CbzMangaOutput.createNew(destination, data) + output = CbzMangaOutput.get(destination, data) val coverUrl = data.largeCoverUrl ?: data.coverUrl downloadFile(coverUrl, data.publicUrl, destination, tempFileName).let { file -> output.addCover(file, MimeTypeMap.getFileExtensionFromUrl(coverUrl)) diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/MangaIndex.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/MangaIndex.kt index 07bbbce98..e0eb87865 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/data/MangaIndex.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/data/MangaIndex.kt @@ -92,6 +92,10 @@ class MangaIndex(source: String?) { } } + fun removeChapter(id: Long): Boolean { + return json.getJSONObject("chapters").remove(id.toString()) != null + } + fun setCoverEntry(name: String) { json.put("cover_entry", name) } diff --git a/app/src/main/java/org/koitharu/kotatsu/local/domain/CbzMangaOutput.kt b/app/src/main/java/org/koitharu/kotatsu/local/domain/CbzMangaOutput.kt index 87b772bb7..9278dd83f 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/domain/CbzMangaOutput.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/domain/CbzMangaOutput.kt @@ -10,6 +10,7 @@ import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.util.toFileNameSafe import org.koitharu.kotatsu.utils.ext.deleteAwait +import org.koitharu.kotatsu.utils.ext.readText import java.io.File import java.util.zip.ZipFile @@ -41,7 +42,9 @@ class CbzMangaOutput( append(ext) } } - output.put(name, file) + runInterruptible(Dispatchers.IO) { + output.put(name, file) + } index.setCoverEntry(name) } @@ -53,14 +56,18 @@ class CbzMangaOutput( append(ext) } } - output.put(name, file) + runInterruptible(Dispatchers.IO) { + output.put(name, file) + } index.addChapter(chapter) } suspend fun finalize() { - output.put(ENTRY_NAME_INDEX, index.toString()) - output.finish() - output.close() + runInterruptible(Dispatchers.IO) { + output.put(ENTRY_NAME_INDEX, index.toString()) + output.finish() + output.close() + } file.deleteAwait() output.file.renameTo(file) } @@ -102,10 +109,45 @@ class CbzMangaOutput( const val ENTRY_NAME_INDEX = "index.json" - fun createNew(root: File, manga: Manga): CbzMangaOutput { + fun get(root: File, manga: Manga): CbzMangaOutput { val name = manga.title.toFileNameSafe() + ".cbz" val file = File(root, name) return CbzMangaOutput(file, manga) } + + @WorkerThread + fun filterChapters(subject: CbzMangaOutput, idsToRemove: Set) { + ZipFile(subject.file).use { zip -> + val index = MangaIndex(zip.readText(zip.getEntry(ENTRY_NAME_INDEX))) + idsToRemove.forEach { id -> index.removeChapter(id) } + val patterns = requireNotNull(index.getMangaInfo()?.chapters).map { + index.getChapterNamesPattern(it) + } + val coverEntryName = index.getCoverEntry() + for (entry in zip.entries()) { + when { + entry.name == ENTRY_NAME_INDEX -> { + subject.output.put(ENTRY_NAME_INDEX, index.toString()) + } + entry.isDirectory -> { + subject.output.addDirectory(entry.name) + } + entry.name == coverEntryName -> { + subject.output.copyEntryFrom(zip, entry) + } + else -> { + val name = entry.name.substringBefore('.') + if (patterns.any { it.matches(name) }) { + subject.output.copyEntryFrom(zip, entry) + } + } + } + } + subject.output.finish() + subject.output.close() + subject.file.delete() + subject.output.file.renameTo(subject.file) + } + } } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt b/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt index 99d8f7ad9..bf2b69cc6 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt @@ -49,12 +49,11 @@ class LocalMangaRepository(private val storageManager: LocalStorageManager) : Ma manga.source != MangaSource.LOCAL -> requireNotNull(findSavedManga(manga)) { "Manga is not local or saved" } - manga.chapters == null -> getFromFile(Uri.parse(manga.url).toFile()) - else -> manga + else -> getFromFile(Uri.parse(manga.url).toFile()) } override suspend fun getPages(chapter: MangaChapter): List { - return runInterruptible(Dispatchers.IO){ + return runInterruptible(Dispatchers.IO) { val uri = Uri.parse(chapter.url) val file = uri.toFile() val zip = ZipFile(file) @@ -93,6 +92,13 @@ class LocalMangaRepository(private val storageManager: LocalStorageManager) : Ma return file.deleteAwait() } + suspend fun deleteChapters(manga: Manga, ids: Set) = runInterruptible(Dispatchers.IO) { + val uri = Uri.parse(manga.url) + val file = uri.toFile() + val cbz = CbzMangaOutput(file, manga) + CbzMangaOutput.filterChapters(cbz, ids) + } + @SuppressLint("DefaultLocale") fun getFromFile(file: File): Manga = ZipFile(file).use { zip -> val fileUri = file.toUri().toString() diff --git a/app/src/main/res/menu/mode_chapters.xml b/app/src/main/res/menu/mode_chapters.xml index 2e9a6aff0..8e3d52bab 100644 --- a/app/src/main/res/menu/mode_chapters.xml +++ b/app/src/main/res/menu/mode_chapters.xml @@ -9,6 +9,12 @@ android:title="@string/save" app:showAsAction="ifRoom|withText" /> + +