Removing selected chapters from local storage

pull/149/head
Koitharu 4 years ago
parent 16c8641a07
commit be66106336
No known key found for this signature in database
GPG Key ID: 8E861F8CE6E7CE27

@ -1,18 +1,18 @@
package org.koitharu.kotatsu.base.ui package org.koitharu.kotatsu.base.ui
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext import kotlin.coroutines.EmptyCoroutineContext
import kotlinx.coroutines.* import kotlinx.coroutines.*
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.base.ui.util.CountedBooleanLiveData
import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.SingleLiveEvent
abstract class BaseViewModel : ViewModel() { abstract class BaseViewModel : ViewModel() {
val onError = SingleLiveEvent<Throwable>() val onError = SingleLiveEvent<Throwable>()
val isLoading = MutableLiveData(false) val isLoading = CountedBooleanLiveData()
protected fun launchJob( protected fun launchJob(
context: CoroutineContext = EmptyCoroutineContext, 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.annotation.WorkerThread
import androidx.collection.ArraySet import androidx.collection.ArraySet
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import okio.Closeable import okio.Closeable
import java.io.File import java.io.File
import java.util.zip.Deflater import java.util.zip.Deflater
@ -22,16 +20,26 @@ class ZipOutput(
setLevel(compressionLevel) setLevel(compressionLevel)
} }
suspend fun put(name: String, file: File): Unit = runInterruptible(Dispatchers.IO) { fun put(name: String, file: File) {
entryNames.add(name) entryNames.add(name)
output.appendFile(file, 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) entryNames.add(name)
output.appendText(content, 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 @WorkerThread
fun copyEntryFrom(other: ZipFile, entry: ZipEntry): Boolean { fun copyEntryFrom(other: ZipFile, entry: ZipEntry): Boolean {
return if (entryNames.add(entry.name)) { return if (entryNames.add(entry.name)) {
@ -47,7 +55,7 @@ class ZipOutput(
} }
} }
suspend fun finish() = runInterruptible(Dispatchers.IO) { fun finish() {
output.finish() output.finish()
output.flush() output.flush()
} }

@ -154,11 +154,19 @@ class ChaptersFragment :
DownloadService.start( DownloadService.start(
context ?: return false, context ?: return false,
viewModel.getRemoteManga() ?: viewModel.manga.value ?: return false, viewModel.getRemoteManga() ?: viewModel.manga.value ?: return false,
selectionDecoration?.checkedItemsIds selectionDecoration?.checkedItemsIds?.toSet()
) )
mode.finish() mode.finish()
true true
} }
R.id.action_delete -> {
val ids = selectionDecoration?.checkedItemsIds
if (!ids.isNullOrEmpty()) {
viewModel.deleteChapters(ids.toSet())
}
mode.finish()
true
}
R.id.action_select_all -> { R.id.action_select_all -> {
val ids = chaptersAdapter?.items?.map { it.chapter.id } ?: return false val ids = chaptersAdapter?.items?.map { it.chapter.id } ?: return false
selectionDecoration?.checkAll(ids) selectionDecoration?.checkAll(ids)
@ -188,6 +196,9 @@ class ChaptersFragment :
menu.findItem(R.id.action_save).isVisible = items.none { x -> menu.findItem(R.id.action_save).isVisible = items.none { x ->
x.chapter.source == MangaSource.LOCAL 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() mode.title = items.size.toString()
return true 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.ShareHelper
import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.utils.ext.getDisplayMessage
class DetailsActivity : BaseActivity<ActivityDetailsBinding>(), TabLayoutMediator.TabConfigurationStrategy, class DetailsActivity :
BaseActivity<ActivityDetailsBinding>(),
TabLayoutMediator.TabConfigurationStrategy,
AdapterView.OnItemSelectedListener { AdapterView.OnItemSelectedListener {
private val viewModel by viewModel<DetailsViewModel> { private val viewModel by viewModel<DetailsViewModel> {
@ -79,6 +81,7 @@ class DetailsActivity : BaseActivity<ActivityDetailsBinding>(), TabLayoutMediato
viewModel.manga.observe(this, ::onMangaUpdated) viewModel.manga.observe(this, ::onMangaUpdated)
viewModel.newChaptersCount.observe(this, ::onNewChaptersChanged) viewModel.newChaptersCount.observe(this, ::onNewChaptersChanged)
viewModel.onMangaRemoved.observe(this, ::onMangaRemoved) viewModel.onMangaRemoved.observe(this, ::onMangaRemoved)
viewModel.onChaptersRemoved.observe(this, ::onChaptersRemoved)
viewModel.onError.observe(this, ::onError) viewModel.onError.observe(this, ::onError)
registerReceiver(downloadReceiver, IntentFilter(DownloadService.ACTION_DOWNLOAD_COMPLETE)) registerReceiver(downloadReceiver, IntentFilter(DownloadService.ACTION_DOWNLOAD_COMPLETE))
@ -102,6 +105,10 @@ class DetailsActivity : BaseActivity<ActivityDetailsBinding>(), TabLayoutMediato
finishAfterTransition() finishAfterTransition()
} }
private fun onChaptersRemoved(count: Int) {
binding.snackbar.show(getString(R.string.removal_completed))
}
private fun onError(e: Throwable) { private fun onError(e: Throwable) {
when { when {
ExceptionResolver.canResolve(e) -> { ExceptionResolver.canResolve(e) -> {
@ -262,7 +269,7 @@ class DetailsActivity : BaseActivity<ActivityDetailsBinding>(), TabLayoutMediato
fun showChapterMissingDialog(chapterId: Long) { fun showChapterMissingDialog(chapterId: Long) {
val remoteManga = viewModel.getRemoteManga() val remoteManga = viewModel.getRemoteManga()
if (remoteManga == null) { if (remoteManga == null) {
binding.snackbar.show(getString( R.string.chapter_is_missing)) binding.snackbar.show(getString(R.string.chapter_is_missing))
return return
} }
MaterialAlertDialogBuilder(this).apply { MaterialAlertDialogBuilder(this).apply {
@ -340,4 +347,4 @@ class DetailsActivity : BaseActivity<ActivityDetailsBinding>(), TabLayoutMediato
.putExtra(MangaIntent.KEY_ID, mangaId) .putExtra(MangaIntent.KEY_ID, mangaId)
} }
} }
} }

@ -85,6 +85,7 @@ class DetailsViewModel(
.asLiveData(viewModelScope.coroutineContext) .asLiveData(viewModelScope.coroutineContext)
val onMangaRemoved = SingleLiveEvent<Manga>() val onMangaRemoved = SingleLiveEvent<Manga>()
val onChaptersRemoved = SingleLiveEvent<Int>()
val branches = mangaData.map { val branches = mangaData.map {
it?.chapters?.mapToSet { x -> x.branch }?.sortedBy { x -> x }.orEmpty() 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) { private fun doLoad() = launchLoadingJob(Dispatchers.Default) {
var manga = mangaDataRepository.resolveIntent(intent) var manga = mangaDataRepository.resolveIntent(intent)
?: throw MangaNotFoundException("Cannot find manga") ?: throw MangaNotFoundException("Cannot find manga")

@ -40,11 +40,10 @@ class ChapterListItem(
override fun hashCode(): Int { override fun hashCode(): Int {
var result = chapter.hashCode() var result = chapter.hashCode()
result = 31 * result + flags result = 31 * result + flags
result = 31 * result + uploadDate.hashCode() result = 31 * result + (uploadDate?.hashCode() ?: 0)
return result return result
} }
companion object { companion object {
const val FLAG_UNREAD = 2 const val FLAG_UNREAD = 2

@ -98,7 +98,7 @@ class DownloadManager(
}.getOrNull() }.getOrNull()
outState.value = DownloadState.Preparing(startId, manga, cover) outState.value = DownloadState.Preparing(startId, manga, cover)
val data = if (manga.chapters.isNullOrEmpty()) repo.getDetails(manga) else manga 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 val coverUrl = data.largeCoverUrl ?: data.coverUrl
downloadFile(coverUrl, data.publicUrl, destination, tempFileName).let { file -> downloadFile(coverUrl, data.publicUrl, destination, tempFileName).let { file ->
output.addCover(file, MimeTypeMap.getFileExtensionFromUrl(coverUrl)) 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) { fun setCoverEntry(name: String) {
json.put("cover_entry", name) 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.model.MangaChapter
import org.koitharu.kotatsu.parsers.util.toFileNameSafe import org.koitharu.kotatsu.parsers.util.toFileNameSafe
import org.koitharu.kotatsu.utils.ext.deleteAwait import org.koitharu.kotatsu.utils.ext.deleteAwait
import org.koitharu.kotatsu.utils.ext.readText
import java.io.File import java.io.File
import java.util.zip.ZipFile import java.util.zip.ZipFile
@ -41,7 +42,9 @@ class CbzMangaOutput(
append(ext) append(ext)
} }
} }
output.put(name, file) runInterruptible(Dispatchers.IO) {
output.put(name, file)
}
index.setCoverEntry(name) index.setCoverEntry(name)
} }
@ -53,14 +56,18 @@ class CbzMangaOutput(
append(ext) append(ext)
} }
} }
output.put(name, file) runInterruptible(Dispatchers.IO) {
output.put(name, file)
}
index.addChapter(chapter) index.addChapter(chapter)
} }
suspend fun finalize() { suspend fun finalize() {
output.put(ENTRY_NAME_INDEX, index.toString()) runInterruptible(Dispatchers.IO) {
output.finish() output.put(ENTRY_NAME_INDEX, index.toString())
output.close() output.finish()
output.close()
}
file.deleteAwait() file.deleteAwait()
output.file.renameTo(file) output.file.renameTo(file)
} }
@ -102,10 +109,45 @@ class CbzMangaOutput(
const val ENTRY_NAME_INDEX = "index.json" 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 name = manga.title.toFileNameSafe() + ".cbz"
val file = File(root, name) val file = File(root, name)
return CbzMangaOutput(file, manga) 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.source != MangaSource.LOCAL -> requireNotNull(findSavedManga(manga)) {
"Manga is not local or saved" "Manga is not local or saved"
} }
manga.chapters == null -> getFromFile(Uri.parse(manga.url).toFile()) else -> getFromFile(Uri.parse(manga.url).toFile())
else -> manga
} }
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> { override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
return runInterruptible(Dispatchers.IO){ return runInterruptible(Dispatchers.IO) {
val uri = Uri.parse(chapter.url) val uri = Uri.parse(chapter.url)
val file = uri.toFile() val file = uri.toFile()
val zip = ZipFile(file) val zip = ZipFile(file)
@ -93,6 +92,13 @@ class LocalMangaRepository(private val storageManager: LocalStorageManager) : Ma
return file.deleteAwait() 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") @SuppressLint("DefaultLocale")
fun getFromFile(file: File): Manga = ZipFile(file).use { zip -> fun getFromFile(file: File): Manga = ZipFile(file).use { zip ->
val fileUri = file.toUri().toString() val fileUri = file.toUri().toString()

@ -9,6 +9,12 @@
android:title="@string/save" android:title="@string/save"
app:showAsAction="ifRoom|withText" /> app:showAsAction="ifRoom|withText" />
<item
android:id="@+id/action_delete"
android:icon="@drawable/ic_delete"
android:title="@string/delete"
app:showAsAction="ifRoom|withText" />
<item <item
android:id="@+id/action_select_all" android:id="@+id/action_select_all"
android:icon="?actionModeSelectAllDrawable" android:icon="?actionModeSelectAllDrawable"

Loading…
Cancel
Save