Removing selected chapters from local storage
This commit is contained in:
@@ -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<Throwable>()
|
||||
val isLoading = MutableLiveData(false)
|
||||
val isLoading = CountedBooleanLiveData()
|
||||
|
||||
protected fun launchJob(
|
||||
context: CoroutineContext = EmptyCoroutineContext,
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package org.koitharu.kotatsu.base.ui.util
|
||||
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
|
||||
class CountedBooleanLiveData : MutableLiveData<Boolean>(false) {
|
||||
|
||||
private var counter = 0
|
||||
|
||||
override fun setValue(value: Boolean) {
|
||||
if (value) {
|
||||
counter++
|
||||
} else {
|
||||
counter--
|
||||
}
|
||||
val newValue = counter > 0
|
||||
if (newValue != value) {
|
||||
super.setValue(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<ActivityDetailsBinding>(), TabLayoutMediator.TabConfigurationStrategy,
|
||||
class DetailsActivity :
|
||||
BaseActivity<ActivityDetailsBinding>(),
|
||||
TabLayoutMediator.TabConfigurationStrategy,
|
||||
AdapterView.OnItemSelectedListener {
|
||||
|
||||
private val viewModel by viewModel<DetailsViewModel> {
|
||||
@@ -79,6 +81,7 @@ class DetailsActivity : BaseActivity<ActivityDetailsBinding>(), 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<ActivityDetailsBinding>(), 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<ActivityDetailsBinding>(), 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<ActivityDetailsBinding>(), TabLayoutMediato
|
||||
.putExtra(MangaIntent.KEY_ID, mangaId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -85,6 +85,7 @@ class DetailsViewModel(
|
||||
.asLiveData(viewModelScope.coroutineContext)
|
||||
|
||||
val onMangaRemoved = SingleLiveEvent<Manga>()
|
||||
val onChaptersRemoved = SingleLiveEvent<Int>()
|
||||
|
||||
val branches = mangaData.map {
|
||||
it?.chapters?.mapToSet { x -> x.branch }?.sortedBy { x -> x }.orEmpty()
|
||||
@@ -183,6 +184,15 @@ class DetailsViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteChapters(ids: Set<Long>) {
|
||||
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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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<Long>) {
|
||||
ZipFile(subject.file).use { zip ->
|
||||
val index = MangaIndex(zip.readText(zip.getEntry(ENTRY_NAME_INDEX)))
|
||||
idsToRemove.forEach { id -> index.removeChapter(id) }
|
||||
val patterns = requireNotNull(index.getMangaInfo()?.chapters).map {
|
||||
index.getChapterNamesPattern(it)
|
||||
}
|
||||
val coverEntryName = index.getCoverEntry()
|
||||
for (entry in zip.entries()) {
|
||||
when {
|
||||
entry.name == ENTRY_NAME_INDEX -> {
|
||||
subject.output.put(ENTRY_NAME_INDEX, index.toString())
|
||||
}
|
||||
entry.isDirectory -> {
|
||||
subject.output.addDirectory(entry.name)
|
||||
}
|
||||
entry.name == coverEntryName -> {
|
||||
subject.output.copyEntryFrom(zip, entry)
|
||||
}
|
||||
else -> {
|
||||
val name = entry.name.substringBefore('.')
|
||||
if (patterns.any { it.matches(name) }) {
|
||||
subject.output.copyEntryFrom(zip, entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
subject.output.finish()
|
||||
subject.output.close()
|
||||
subject.file.delete()
|
||||
subject.output.file.renameTo(subject.file)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<MangaPage> {
|
||||
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<Long>) = 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()
|
||||
|
||||
@@ -9,6 +9,12 @@
|
||||
android:title="@string/save"
|
||||
app:showAsAction="ifRoom|withText" />
|
||||
|
||||
<item
|
||||
android:id="@+id/action_delete"
|
||||
android:icon="@drawable/ic_delete"
|
||||
android:title="@string/delete"
|
||||
app:showAsAction="ifRoom|withText" />
|
||||
|
||||
<item
|
||||
android:id="@+id/action_select_all"
|
||||
android:icon="?actionModeSelectAllDrawable"
|
||||
|
||||
Reference in New Issue
Block a user