Merge branch 'feature/direct-download' into devel
This commit is contained in:
@@ -102,6 +102,7 @@
|
|||||||
<service
|
<service
|
||||||
android:name="org.koitharu.kotatsu.download.ui.service.DownloadService"
|
android:name="org.koitharu.kotatsu.download.ui.service.DownloadService"
|
||||||
android:foregroundServiceType="dataSync" />
|
android:foregroundServiceType="dataSync" />
|
||||||
|
<service android:name="org.koitharu.kotatsu.local.ui.LocalChaptersRemoveService" />
|
||||||
<service
|
<service
|
||||||
android:name="org.koitharu.kotatsu.widget.shelf.ShelfWidgetService"
|
android:name="org.koitharu.kotatsu.widget.shelf.ShelfWidgetService"
|
||||||
android:permission="android.permission.BIND_REMOTEVIEWS" />
|
android:permission="android.permission.BIND_REMOTEVIEWS" />
|
||||||
|
|||||||
@@ -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,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?)
|
||||||
|
}
|
||||||
@@ -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 != this.value) {
|
||||||
|
super.setValue(newValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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
|
val isSuggestionsEnabled: Boolean
|
||||||
get() = prefs.getBoolean(KEY_SUGGESTIONS, false)
|
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_NSFW = "suggestions_exclude_nsfw"
|
||||||
const val KEY_SUGGESTIONS_EXCLUDE_TAGS = "suggestions_exclude_tags"
|
const val KEY_SUGGESTIONS_EXCLUDE_TAGS = "suggestions_exclude_tags"
|
||||||
const val KEY_SEARCH_SINGLE_SOURCE = "search_single_source"
|
const val KEY_SEARCH_SINGLE_SOURCE = "search_single_source"
|
||||||
|
const val KEY_DOWNLOADS_PARALLELISM = "downloads_parallelism"
|
||||||
|
const val KEY_DOWNLOADS_SLOWDOWN = "downloads_slowdown"
|
||||||
|
|
||||||
// About
|
// About
|
||||||
const val KEY_APP_UPDATE = "app_update"
|
const val KEY_APP_UPDATE = "app_update"
|
||||||
|
|||||||
118
app/src/main/java/org/koitharu/kotatsu/core/zip/ZipOutput.kt
Normal file
118
app/src/main/java/org/koitharu/kotatsu/core/zip/ZipOutput.kt
Normal file
@@ -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<String>()
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,9 +9,9 @@ import androidx.appcompat.app.AppCompatActivity
|
|||||||
import androidx.appcompat.view.ActionMode
|
import androidx.appcompat.view.ActionMode
|
||||||
import androidx.appcompat.widget.SearchView
|
import androidx.appcompat.widget.SearchView
|
||||||
import androidx.core.graphics.Insets
|
import androidx.core.graphics.Insets
|
||||||
import androidx.core.view.isGone
|
|
||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
import androidx.core.view.updatePadding
|
import androidx.core.view.updatePadding
|
||||||
|
import com.google.android.material.snackbar.Snackbar
|
||||||
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
|
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.base.ui.BaseFragment
|
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.adapter.ChaptersSelectionDecoration
|
||||||
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
|
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
|
||||||
import org.koitharu.kotatsu.download.ui.service.DownloadService
|
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.parsers.model.MangaSource
|
||||||
import org.koitharu.kotatsu.reader.ui.ReaderActivity
|
import org.koitharu.kotatsu.reader.ui.ReaderActivity
|
||||||
import org.koitharu.kotatsu.reader.ui.ReaderState
|
import org.koitharu.kotatsu.reader.ui.ReaderState
|
||||||
@@ -67,8 +68,8 @@ class ChaptersFragment :
|
|||||||
viewModel.isChaptersReversed.observe(viewLifecycleOwner) {
|
viewModel.isChaptersReversed.observe(viewLifecycleOwner) {
|
||||||
activity?.invalidateOptionsMenu()
|
activity?.invalidateOptionsMenu()
|
||||||
}
|
}
|
||||||
viewModel.hasChapters.observe(viewLifecycleOwner) {
|
viewModel.isChaptersEmpty.observe(viewLifecycleOwner) {
|
||||||
binding.textViewHolder.isGone = it
|
binding.textViewHolder.isVisible = it
|
||||||
activity?.invalidateOptionsMenu()
|
activity?.invalidateOptionsMenu()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -94,7 +95,7 @@ class ChaptersFragment :
|
|||||||
override fun onPrepareOptionsMenu(menu: Menu) {
|
override fun onPrepareOptionsMenu(menu: Menu) {
|
||||||
super.onPrepareOptionsMenu(menu)
|
super.onPrepareOptionsMenu(menu)
|
||||||
menu.findItem(R.id.action_reversed).isChecked = viewModel.isChaptersReversed.value == true
|
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) {
|
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
|
||||||
@@ -154,11 +155,29 @@ 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
|
||||||
|
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 -> {
|
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 +207,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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,13 +41,16 @@ import org.koitharu.kotatsu.details.ui.adapter.BranchesAdapter
|
|||||||
import org.koitharu.kotatsu.download.ui.service.DownloadService
|
import org.koitharu.kotatsu.download.ui.service.DownloadService
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
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.ReaderActivity
|
||||||
import org.koitharu.kotatsu.reader.ui.ReaderState
|
import org.koitharu.kotatsu.reader.ui.ReaderState
|
||||||
import org.koitharu.kotatsu.search.ui.global.GlobalSearchActivity
|
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> {
|
||||||
@@ -171,38 +174,23 @@ class DetailsActivity : BaseActivity<ActivityDetailsBinding>(), TabLayoutMediato
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
R.id.action_delete -> {
|
R.id.action_delete -> {
|
||||||
viewModel.manga.value?.let { m ->
|
val title = viewModel.manga.value?.title.orEmpty()
|
||||||
MaterialAlertDialogBuilder(this)
|
MaterialAlertDialogBuilder(this)
|
||||||
.setTitle(R.string.delete_manga)
|
.setTitle(R.string.delete_manga)
|
||||||
.setMessage(getString(R.string.text_delete_local_manga, m.title))
|
.setMessage(getString(R.string.text_delete_local_manga, title))
|
||||||
.setPositiveButton(R.string.delete) { _, _ ->
|
.setPositiveButton(R.string.delete) { _, _ ->
|
||||||
viewModel.deleteLocal(m)
|
viewModel.deleteLocal()
|
||||||
}
|
}
|
||||||
.setNegativeButton(android.R.string.cancel, null)
|
.setNegativeButton(android.R.string.cancel, null)
|
||||||
.show()
|
.show()
|
||||||
}
|
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
R.id.action_save -> {
|
R.id.action_save -> {
|
||||||
viewModel.manga.value?.let {
|
viewModel.manga.value?.let {
|
||||||
val chaptersCount = it.chapters?.size ?: 0
|
val chaptersCount = it.chapters?.size ?: 0
|
||||||
if (chaptersCount > 5) {
|
val branches = viewModel.branches.value.orEmpty()
|
||||||
MaterialAlertDialogBuilder(this)
|
if (chaptersCount > 5 || branches.size > 1) {
|
||||||
.setTitle(R.string.save_manga)
|
showSaveConfirmation(it, chaptersCount, branches)
|
||||||
.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()
|
|
||||||
} else {
|
} else {
|
||||||
DownloadService.start(this, it)
|
DownloadService.start(this, it)
|
||||||
}
|
}
|
||||||
@@ -262,7 +250,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 {
|
||||||
@@ -328,6 +316,36 @@ class DetailsActivity : BaseActivity<ActivityDetailsBinding>(), TabLayoutMediato
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun showSaveConfirmation(manga: Manga, chaptersCount: Int, branches: List<String?>) {
|
||||||
|
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 {
|
companion object {
|
||||||
|
|
||||||
fun newIntent(context: Context, manga: Manga): Intent {
|
fun newIntent(context: Context, manga: Manga): Intent {
|
||||||
@@ -340,4 +358,4 @@ class DetailsActivity : BaseActivity<ActivityDetailsBinding>(), TabLayoutMediato
|
|||||||
.putExtra(MangaIntent.KEY_ID, mangaId)
|
.putExtra(MangaIntent.KEY_ID, mangaId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -28,6 +28,7 @@ import org.koitharu.kotatsu.parsers.util.mapToSet
|
|||||||
import org.koitharu.kotatsu.parsers.util.toTitleCase
|
import org.koitharu.kotatsu.parsers.util.toTitleCase
|
||||||
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
|
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
|
||||||
import org.koitharu.kotatsu.utils.SingleLiveEvent
|
import org.koitharu.kotatsu.utils.SingleLiveEvent
|
||||||
|
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
|
||||||
import org.koitharu.kotatsu.utils.ext.iterator
|
import org.koitharu.kotatsu.utils.ext.iterator
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
|
||||||
@@ -88,18 +89,18 @@ class DetailsViewModel(
|
|||||||
|
|
||||||
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()
|
||||||
}.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default)
|
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default)
|
||||||
|
|
||||||
val selectedBranchIndex = combine(
|
val selectedBranchIndex = combine(
|
||||||
branches.asFlow(),
|
branches.asFlow(),
|
||||||
selectedBranch
|
selectedBranch
|
||||||
) { branches, selected ->
|
) { branches, selected ->
|
||||||
branches.indexOf(selected)
|
branches.indexOf(selected)
|
||||||
}.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default)
|
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default)
|
||||||
|
|
||||||
val hasChapters = mangaData.map {
|
val isChaptersEmpty = mangaData.mapNotNull { m ->
|
||||||
!(it?.chapters.isNullOrEmpty())
|
m?.run { chapters.isNullOrEmpty() }
|
||||||
}.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default)
|
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, false)
|
||||||
|
|
||||||
val chapters = combine(
|
val chapters = combine(
|
||||||
combine(
|
combine(
|
||||||
@@ -134,8 +135,11 @@ class DetailsViewModel(
|
|||||||
loadingJob = doLoad()
|
loadingJob = doLoad()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun deleteLocal(manga: Manga) {
|
fun deleteLocal() {
|
||||||
|
val m = mangaData.value ?: return
|
||||||
launchLoadingJob(Dispatchers.Default) {
|
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)
|
val original = localMangaRepository.getRemoteManga(manga)
|
||||||
localMangaRepository.delete(manga) || throw IOException("Unable to delete file")
|
localMangaRepository.delete(manga) || throw IOException("Unable to delete file")
|
||||||
runCatching {
|
runCatching {
|
||||||
@@ -252,10 +256,10 @@ class DetailsViewModel(
|
|||||||
val dateFormat = settings.getDateFormat()
|
val dateFormat = settings.getDateFormat()
|
||||||
for (i in sourceChapters.indices) {
|
for (i in sourceChapters.indices) {
|
||||||
val chapter = sourceChapters[i]
|
val chapter = sourceChapters[i]
|
||||||
|
val localChapter = chaptersMap.remove(chapter.id)
|
||||||
if (chapter.branch != branch) {
|
if (chapter.branch != branch) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
val localChapter = chaptersMap.remove(chapter.id)
|
|
||||||
result += localChapter?.toListItem(
|
result += localChapter?.toListItem(
|
||||||
isCurrent = i == currentIndex,
|
isCurrent = i == currentIndex,
|
||||||
isUnread = i > currentIndex,
|
isUnread = i > currentIndex,
|
||||||
@@ -274,15 +278,19 @@ class DetailsViewModel(
|
|||||||
}
|
}
|
||||||
if (chaptersMap.isNotEmpty()) { // some chapters on device but not online source
|
if (chaptersMap.isNotEmpty()) { // some chapters on device but not online source
|
||||||
result.ensureCapacity(result.size + chaptersMap.size)
|
result.ensureCapacity(result.size + chaptersMap.size)
|
||||||
chaptersMap.values.mapTo(result) {
|
chaptersMap.values.mapNotNullTo(result) {
|
||||||
it.toListItem(
|
if (it.branch == branch) {
|
||||||
isCurrent = false,
|
it.toListItem(
|
||||||
isUnread = true,
|
isCurrent = false,
|
||||||
isNew = false,
|
isUnread = true,
|
||||||
isMissing = false,
|
isNew = false,
|
||||||
isDownloaded = false,
|
isMissing = false,
|
||||||
dateFormat = dateFormat,
|
isDownloaded = false,
|
||||||
)
|
dateFormat = dateFormat,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
result.sortBy { it.chapter.number }
|
result.sortBy { it.chapter.number }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import android.webkit.MimeTypeMap
|
|||||||
import coil.ImageLoader
|
import coil.ImageLoader
|
||||||
import coil.request.ImageRequest
|
import coil.request.ImageRequest
|
||||||
import coil.size.Scale
|
import coil.size.Scale
|
||||||
import java.io.File
|
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.sync.Semaphore
|
import kotlinx.coroutines.sync.Semaphore
|
||||||
@@ -18,8 +17,9 @@ import org.koitharu.kotatsu.BuildConfig
|
|||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.core.network.CommonHeaders
|
import org.koitharu.kotatsu.core.network.CommonHeaders
|
||||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
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.data.PagesCache
|
||||||
|
import org.koitharu.kotatsu.local.domain.CbzMangaOutput
|
||||||
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
|
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
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.referer
|
||||||
import org.koitharu.kotatsu.utils.ext.waitForNetwork
|
import org.koitharu.kotatsu.utils.ext.waitForNetwork
|
||||||
import org.koitharu.kotatsu.utils.progress.ProgressJob
|
import org.koitharu.kotatsu.utils.progress.ProgressJob
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
private const val MAX_DOWNLOAD_ATTEMPTS = 3
|
private const val MAX_DOWNLOAD_ATTEMPTS = 3
|
||||||
private const val MAX_PARALLEL_DOWNLOADS = 2
|
|
||||||
private const val DOWNLOAD_ERROR_DELAY = 500L
|
private const val DOWNLOAD_ERROR_DELAY = 500L
|
||||||
private const val TEMP_PAGE_FILE = "page.tmp"
|
private const val SLOWDOWN_DELAY = 200L
|
||||||
|
|
||||||
class DownloadManager(
|
class DownloadManager(
|
||||||
private val coroutineScope: CoroutineScope,
|
private val coroutineScope: CoroutineScope,
|
||||||
@@ -41,9 +41,10 @@ class DownloadManager(
|
|||||||
private val okHttp: OkHttpClient,
|
private val okHttp: OkHttpClient,
|
||||||
private val cache: PagesCache,
|
private val cache: PagesCache,
|
||||||
private val localMangaRepository: LocalMangaRepository,
|
private val localMangaRepository: LocalMangaRepository,
|
||||||
|
private val settings: AppSettings,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private val connectivityManager = context.applicationContext.getSystemService(
|
private val connectivityManager = context.getSystemService(
|
||||||
Context.CONNECTIVITY_SERVICE
|
Context.CONNECTIVITY_SERVICE
|
||||||
) as ConnectivityManager
|
) as ConnectivityManager
|
||||||
private val coverWidth = context.resources.getDimensionPixelSize(
|
private val coverWidth = context.resources.getDimensionPixelSize(
|
||||||
@@ -52,7 +53,7 @@ class DownloadManager(
|
|||||||
private val coverHeight = context.resources.getDimensionPixelSize(
|
private val coverHeight = context.resources.getDimensionPixelSize(
|
||||||
androidx.core.R.dimen.compat_notification_large_icon_max_height
|
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(
|
fun downloadManga(
|
||||||
manga: Manga,
|
manga: Manga,
|
||||||
@@ -80,7 +81,8 @@ class DownloadManager(
|
|||||||
var cover: Drawable? = null
|
var cover: Drawable? = null
|
||||||
val destination = localMangaRepository.getOutputDir()
|
val destination = localMangaRepository.getOutputDir()
|
||||||
checkNotNull(destination) { context.getString(R.string.cannot_find_available_storage) }
|
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 {
|
try {
|
||||||
if (manga.source == MangaSource.LOCAL) {
|
if (manga.source == MangaSource.LOCAL) {
|
||||||
manga = localMangaRepository.getRemoteManga(manga) ?: error("Cannot obtain remote manga instance")
|
manga = localMangaRepository.getRemoteManga(manga) ?: error("Cannot obtain remote manga instance")
|
||||||
@@ -98,10 +100,9 @@ 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 = MangaZip.findInDir(destination, data)
|
output = CbzMangaOutput.get(destination, data)
|
||||||
output.prepare(data)
|
|
||||||
val coverUrl = data.largeCoverUrl ?: data.coverUrl
|
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))
|
output.addCover(file, MimeTypeMap.getFileExtensionFromUrl(coverUrl))
|
||||||
}
|
}
|
||||||
val chapters = checkNotNull(
|
val chapters = checkNotNull(
|
||||||
@@ -118,22 +119,29 @@ class DownloadManager(
|
|||||||
for ((chapterIndex, chapter) in chapters.withIndex()) {
|
for ((chapterIndex, chapter) in chapters.withIndex()) {
|
||||||
val pages = repo.getPages(chapter)
|
val pages = repo.getPages(chapter)
|
||||||
for ((pageIndex, page) in pages.withIndex()) {
|
for ((pageIndex, page) in pages.withIndex()) {
|
||||||
failsafe@ do {
|
var retryCounter = 0
|
||||||
|
failsafe@ while (true) {
|
||||||
try {
|
try {
|
||||||
val url = repo.getPageUrl(page)
|
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(
|
output.addPage(
|
||||||
chapter = chapter,
|
chapter = chapter,
|
||||||
file = file,
|
file = file,
|
||||||
pageNumber = pageIndex,
|
pageNumber = pageIndex,
|
||||||
ext = MimeTypeMap.getFileExtensionFromUrl(url),
|
ext = MimeTypeMap.getFileExtensionFromUrl(url),
|
||||||
)
|
)
|
||||||
|
break@failsafe
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
outState.value = DownloadState.WaitingForNetwork(startId, data, cover)
|
if (retryCounter < MAX_DOWNLOAD_ATTEMPTS) {
|
||||||
connectivityManager.waitForNetwork()
|
outState.value = DownloadState.WaitingForNetwork(startId, data, cover)
|
||||||
continue@failsafe
|
delay(DOWNLOAD_ERROR_DELAY)
|
||||||
|
connectivityManager.waitForNetwork()
|
||||||
|
retryCounter++
|
||||||
|
} else {
|
||||||
|
throw e
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} while (false)
|
}
|
||||||
|
|
||||||
outState.value = DownloadState.Progress(
|
outState.value = DownloadState.Progress(
|
||||||
startId, data, cover,
|
startId, data, cover,
|
||||||
@@ -142,12 +150,15 @@ class DownloadManager(
|
|||||||
totalPages = pages.size,
|
totalPages = pages.size,
|
||||||
currentPage = pageIndex,
|
currentPage = pageIndex,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (settings.isDownloadsSlowdownEnabled) {
|
||||||
|
delay(SLOWDOWN_DELAY)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
outState.value = DownloadState.PostProcessing(startId, data, cover)
|
outState.value = DownloadState.PostProcessing(startId, data, cover)
|
||||||
if (!output.compress()) {
|
output.mergeWithExisting()
|
||||||
throw RuntimeException("Cannot create target file")
|
output.finalize()
|
||||||
}
|
|
||||||
val localManga = localMangaRepository.getFromFile(output.file)
|
val localManga = localMangaRepository.getFromFile(output.file)
|
||||||
outState.value = DownloadState.Done(startId, data, cover, localManga)
|
outState.value = DownloadState.Done(startId, data, cover, localManga)
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
@@ -161,14 +172,14 @@ class DownloadManager(
|
|||||||
} finally {
|
} finally {
|
||||||
withContext(NonCancellable) {
|
withContext(NonCancellable) {
|
||||||
output?.cleanup()
|
output?.cleanup()
|
||||||
File(destination, TEMP_PAGE_FILE).deleteAwait()
|
File(destination, tempFileName).deleteAwait()
|
||||||
}
|
}
|
||||||
coroutineContext[WakeLockNode]?.release()
|
coroutineContext[WakeLockNode]?.release()
|
||||||
semaphore.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()
|
val request = Request.Builder()
|
||||||
.url(url)
|
.url(url)
|
||||||
.header(CommonHeaders.REFERER, referer)
|
.header(CommonHeaders.REFERER, referer)
|
||||||
@@ -176,26 +187,14 @@ class DownloadManager(
|
|||||||
.get()
|
.get()
|
||||||
.build()
|
.build()
|
||||||
val call = okHttp.newCall(request)
|
val call = okHttp.newCall(request)
|
||||||
var attempts = MAX_DOWNLOAD_ATTEMPTS
|
val file = File(destination, tempFileName)
|
||||||
val file = File(destination, TEMP_PAGE_FILE)
|
val response = call.clone().await()
|
||||||
while (true) {
|
runInterruptible(Dispatchers.IO) {
|
||||||
try {
|
file.outputStream().use { out ->
|
||||||
val response = call.clone().await()
|
checkNotNull(response.body).byteStream().copyTo(out)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return file
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun errorStateHandler(outState: MutableStateFlow<DownloadState>) =
|
private fun errorStateHandler(outState: MutableStateFlow<DownloadState>) =
|
||||||
@@ -208,4 +207,24 @@ class DownloadManager(
|
|||||||
error = throwable,
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,7 @@ import android.app.NotificationManager
|
|||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import android.text.format.DateUtils
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.app.NotificationManagerCompat
|
import androidx.core.app.NotificationManagerCompat
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
@@ -49,7 +50,7 @@ class DownloadNotification(private val context: Context, startId: Int) {
|
|||||||
builder.setSilent(true)
|
builder.setSilent(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun create(state: DownloadState): Notification {
|
fun create(state: DownloadState, timeLeft: Long): Notification {
|
||||||
builder.setContentTitle(state.manga.title)
|
builder.setContentTitle(state.manga.title)
|
||||||
builder.setContentText(context.getString(R.string.manga_downloading_))
|
builder.setContentText(context.getString(R.string.manga_downloading_))
|
||||||
builder.setProgress(1, 0, true)
|
builder.setProgress(1, 0, true)
|
||||||
@@ -117,7 +118,13 @@ class DownloadNotification(private val context: Context, startId: Int) {
|
|||||||
}
|
}
|
||||||
is DownloadState.Progress -> {
|
is DownloadState.Progress -> {
|
||||||
builder.setProgress(state.max, state.progress, false)
|
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.setCategory(NotificationCompat.CATEGORY_PROGRESS)
|
||||||
builder.setStyle(null)
|
builder.setStyle(null)
|
||||||
builder.setOngoing(true)
|
builder.setOngoing(true)
|
||||||
|
|||||||
@@ -11,10 +11,7 @@ import android.widget.Toast
|
|||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.*
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.mapLatest
|
|
||||||
import kotlinx.coroutines.flow.transformWhile
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.plus
|
import kotlinx.coroutines.plus
|
||||||
import org.koin.android.ext.android.get
|
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.connectivityManager
|
||||||
import org.koitharu.kotatsu.utils.ext.throttle
|
import org.koitharu.kotatsu.utils.ext.throttle
|
||||||
import org.koitharu.kotatsu.utils.progress.ProgressJob
|
import org.koitharu.kotatsu.utils.progress.ProgressJob
|
||||||
|
import org.koitharu.kotatsu.utils.progress.TimeLeftEstimator
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
class DownloadService : BaseService() {
|
class DownloadService : BaseService() {
|
||||||
@@ -46,16 +44,12 @@ class DownloadService : BaseService() {
|
|||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
isRunning = true
|
||||||
notificationSwitcher = ForegroundNotificationSwitcher(this)
|
notificationSwitcher = ForegroundNotificationSwitcher(this)
|
||||||
val wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager)
|
val wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager)
|
||||||
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "kotatsu:downloading")
|
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "kotatsu:downloading")
|
||||||
downloadManager = DownloadManager(
|
downloadManager = get<DownloadManager.Factory>().create(
|
||||||
coroutineScope = lifecycleScope + WakeLockNode(wakeLock, TimeUnit.HOURS.toMillis(1)),
|
coroutineScope = lifecycleScope + WakeLockNode(wakeLock, TimeUnit.HOURS.toMillis(1)),
|
||||||
context = this,
|
|
||||||
imageLoader = get(),
|
|
||||||
okHttp = get(),
|
|
||||||
cache = get(),
|
|
||||||
localMangaRepository = get(),
|
|
||||||
)
|
)
|
||||||
DownloadNotification.createChannel(this)
|
DownloadNotification.createChannel(this)
|
||||||
registerReceiver(controlReceiver, IntentFilter(ACTION_DOWNLOAD_CANCEL))
|
registerReceiver(controlReceiver, IntentFilter(ACTION_DOWNLOAD_CANCEL))
|
||||||
@@ -88,6 +82,7 @@ class DownloadService : BaseService() {
|
|||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
unregisterReceiver(controlReceiver)
|
unregisterReceiver(controlReceiver)
|
||||||
binder = null
|
binder = null
|
||||||
|
isRunning = false
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,13 +99,22 @@ class DownloadService : BaseService() {
|
|||||||
private fun listenJob(job: ProgressJob<DownloadState>) {
|
private fun listenJob(job: ProgressJob<DownloadState>) {
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
val startId = job.progressValue.startId
|
val startId = job.progressValue.startId
|
||||||
|
val timeLeftEstimator = TimeLeftEstimator()
|
||||||
val notification = DownloadNotification(this@DownloadService, startId)
|
val notification = DownloadNotification(this@DownloadService, startId)
|
||||||
notificationSwitcher.notify(startId, notification.create(job.progressValue))
|
notificationSwitcher.notify(startId, notification.create(job.progressValue, -1L))
|
||||||
job.progressAsFlow()
|
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 }
|
.throttle { state -> if (state is DownloadState.Progress) 400L else 0L }
|
||||||
.whileActive()
|
.whileActive()
|
||||||
.collect { state ->
|
.collect { state ->
|
||||||
notificationSwitcher.notify(startId, notification.create(state))
|
val timeLeft = timeLeftEstimator.getEstimatedTimeLeft()
|
||||||
|
notificationSwitcher.notify(startId, notification.create(state, timeLeft))
|
||||||
}
|
}
|
||||||
job.join()
|
job.join()
|
||||||
(job.progressValue as? DownloadState.Done)?.let {
|
(job.progressValue as? DownloadState.Done)?.let {
|
||||||
@@ -124,7 +128,7 @@ class DownloadService : BaseService() {
|
|||||||
if (job.isCancelled) {
|
if (job.isCancelled) {
|
||||||
null
|
null
|
||||||
} else {
|
} else {
|
||||||
notification.create(job.progressValue)
|
notification.create(job.progressValue, -1L)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
stopSelf(startId)
|
stopSelf(startId)
|
||||||
@@ -160,11 +164,12 @@ class DownloadService : BaseService() {
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
const val ACTION_DOWNLOAD_COMPLETE =
|
var isRunning: Boolean = false
|
||||||
"${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_COMPLETE"
|
private set
|
||||||
|
|
||||||
private const val ACTION_DOWNLOAD_CANCEL =
|
const val ACTION_DOWNLOAD_COMPLETE = "${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_COMPLETE"
|
||||||
"${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_CANCEL"
|
|
||||||
|
private const val ACTION_DOWNLOAD_CANCEL = "${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_CANCEL"
|
||||||
|
|
||||||
private const val EXTRA_MANGA = "manga"
|
private const val EXTRA_MANGA = "manga"
|
||||||
private const val EXTRA_CHAPTERS_IDS = "chapters_ids"
|
private const val EXTRA_CHAPTERS_IDS = "chapters_ids"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.local
|
|||||||
import org.koin.android.ext.koin.androidContext
|
import org.koin.android.ext.koin.androidContext
|
||||||
import org.koin.androidx.viewmodel.dsl.viewModel
|
import org.koin.androidx.viewmodel.dsl.viewModel
|
||||||
import org.koin.dsl.module
|
import org.koin.dsl.module
|
||||||
|
import org.koitharu.kotatsu.download.domain.DownloadManager
|
||||||
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
||||||
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
|
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
|
||||||
import org.koitharu.kotatsu.local.ui.LocalListViewModel
|
import org.koitharu.kotatsu.local.ui.LocalListViewModel
|
||||||
@@ -16,5 +17,7 @@ val localModule
|
|||||||
|
|
||||||
factory { ExternalStorageHelper(androidContext()) }
|
factory { ExternalStorageHelper(androidContext()) }
|
||||||
|
|
||||||
|
factory { DownloadManager.Factory(androidContext(), get(), get(), get(), get(), get()) }
|
||||||
|
|
||||||
viewModel { LocalListViewModel(get(), get(), get(), get()) }
|
viewModel { LocalListViewModel(get(), get(), get(), get()) }
|
||||||
}
|
}
|
||||||
@@ -9,11 +9,11 @@ import coil.fetch.FetchResult
|
|||||||
import coil.fetch.Fetcher
|
import coil.fetch.Fetcher
|
||||||
import coil.fetch.SourceResult
|
import coil.fetch.SourceResult
|
||||||
import coil.size.Size
|
import coil.size.Size
|
||||||
|
import java.util.zip.ZipFile
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.runInterruptible
|
import kotlinx.coroutines.runInterruptible
|
||||||
import okio.buffer
|
import okio.buffer
|
||||||
import okio.source
|
import okio.source
|
||||||
import java.util.zip.ZipFile
|
|
||||||
|
|
||||||
class CbzFetcher : Fetcher<Uri> {
|
class CbzFetcher : Fetcher<Uri> {
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ import java.util.*
|
|||||||
class CbzFilter : FilenameFilter {
|
class CbzFilter : FilenameFilter {
|
||||||
|
|
||||||
override fun accept(dir: File, name: String): Boolean {
|
override fun accept(dir: File, name: String): Boolean {
|
||||||
|
return isFileSupported(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isFileSupported(name: String): Boolean {
|
||||||
val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT)
|
val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT)
|
||||||
return ext == "cbz" || ext == "zip"
|
return ext == "cbz" || ext == "zip"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,14 +28,17 @@ class MangaIndex(source: String?) {
|
|||||||
json.put("state", manga.state?.name)
|
json.put("state", manga.state?.name)
|
||||||
json.put("source", manga.source.name)
|
json.put("source", manga.source.name)
|
||||||
json.put("cover_large", manga.largeCoverUrl)
|
json.put("cover_large", manga.largeCoverUrl)
|
||||||
json.put("tags", JSONArray().also { a ->
|
json.put(
|
||||||
for (tag in manga.tags) {
|
"tags",
|
||||||
val jo = JSONObject()
|
JSONArray().also { a ->
|
||||||
jo.put("key", tag.key)
|
for (tag in manga.tags) {
|
||||||
jo.put("title", tag.title)
|
val jo = JSONObject()
|
||||||
a.put(jo)
|
jo.put("key", tag.key)
|
||||||
|
jo.put("title", tag.title)
|
||||||
|
a.put(jo)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
)
|
||||||
if (!append || !json.has("chapters")) {
|
if (!append || !json.has("chapters")) {
|
||||||
json.put("chapters", JSONObject())
|
json.put("chapters", JSONObject())
|
||||||
}
|
}
|
||||||
@@ -84,11 +87,15 @@ class MangaIndex(source: String?) {
|
|||||||
jo.put("uploadDate", chapter.uploadDate)
|
jo.put("uploadDate", chapter.uploadDate)
|
||||||
jo.put("scanlator", chapter.scanlator)
|
jo.put("scanlator", chapter.scanlator)
|
||||||
jo.put("branch", chapter.branch)
|
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)
|
chapters.put(chapter.id.toString(), jo)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,18 +3,17 @@ package org.koitharu.kotatsu.local.domain
|
|||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.webkit.MimeTypeMap
|
import android.webkit.MimeTypeMap
|
||||||
|
import androidx.annotation.WorkerThread
|
||||||
import androidx.collection.ArraySet
|
import androidx.collection.ArraySet
|
||||||
import androidx.core.net.toFile
|
import androidx.core.net.toFile
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.*
|
||||||
import kotlinx.coroutines.runInterruptible
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException
|
import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException
|
||||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||||
import org.koitharu.kotatsu.local.data.CbzFilter
|
import org.koitharu.kotatsu.local.data.CbzFilter
|
||||||
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
||||||
import org.koitharu.kotatsu.local.data.MangaIndex
|
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.model.*
|
||||||
import org.koitharu.kotatsu.parsers.util.longHashCode
|
import org.koitharu.kotatsu.parsers.util.longHashCode
|
||||||
import org.koitharu.kotatsu.parsers.util.toCamelCase
|
import org.koitharu.kotatsu.parsers.util.toCamelCase
|
||||||
@@ -27,6 +26,9 @@ import java.io.IOException
|
|||||||
import java.util.*
|
import java.util.*
|
||||||
import java.util.zip.ZipEntry
|
import java.util.zip.ZipEntry
|
||||||
import java.util.zip.ZipFile
|
import java.util.zip.ZipFile
|
||||||
|
import kotlin.coroutines.CoroutineContext
|
||||||
|
|
||||||
|
private const val MAX_PARALLELISM = 4
|
||||||
|
|
||||||
class LocalMangaRepository(private val storageManager: LocalStorageManager) : MangaRepository {
|
class LocalMangaRepository(private val storageManager: LocalStorageManager) : MangaRepository {
|
||||||
|
|
||||||
@@ -39,27 +41,43 @@ class LocalMangaRepository(private val storageManager: LocalStorageManager) : Ma
|
|||||||
tags: Set<MangaTag>?,
|
tags: Set<MangaTag>?,
|
||||||
sortOrder: SortOrder?
|
sortOrder: SortOrder?
|
||||||
): List<Manga> {
|
): List<Manga> {
|
||||||
require(offset == 0) {
|
if (offset > 0) {
|
||||||
"LocalMangaRepository does not support pagination"
|
return emptyList()
|
||||||
}
|
}
|
||||||
val files = getAllFiles()
|
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 {
|
override suspend fun getDetails(manga: Manga) = when {
|
||||||
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)
|
||||||
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()
|
var entries = zip.entries().asSequence()
|
||||||
entries = if (index != null) {
|
entries = if (index != null) {
|
||||||
val pattern = index.getChapterNamesPattern(chapter)
|
val pattern = index.getChapterNamesPattern(chapter)
|
||||||
@@ -94,10 +112,18 @@ 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
@WorkerThread
|
||||||
@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()
|
||||||
val entry = zip.getEntry(MangaZip.INDEX_ENTRY)
|
val entry = zip.getEntry(CbzMangaOutput.ENTRY_NAME_INDEX)
|
||||||
val index = entry?.let(zip::readText)?.let(::MangaIndex)
|
val index = entry?.let(zip::readText)?.let(::MangaIndex)
|
||||||
val info = index?.getMangaInfo()
|
val info = index?.getMangaInfo()
|
||||||
if (index != null && info != null) {
|
if (index != null && info != null) {
|
||||||
@@ -158,7 +184,7 @@ class LocalMangaRepository(private val storageManager: LocalStorageManager) : Ma
|
|||||||
}.getOrNull() ?: return null
|
}.getOrNull() ?: return null
|
||||||
return runInterruptible(Dispatchers.IO) {
|
return runInterruptible(Dispatchers.IO) {
|
||||||
ZipFile(file).use { zip ->
|
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)
|
val index = entry?.let(zip::readText)?.let(::MangaIndex)
|
||||||
index?.getMangaInfo()
|
index?.getMangaInfo()
|
||||||
}
|
}
|
||||||
@@ -170,7 +196,7 @@ class LocalMangaRepository(private val storageManager: LocalStorageManager) : Ma
|
|||||||
return runInterruptible(Dispatchers.IO) {
|
return runInterruptible(Dispatchers.IO) {
|
||||||
for (file in files) {
|
for (file in files) {
|
||||||
val index = ZipFile(file).use { zip ->
|
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)
|
entry?.let(zip::readText)?.let(::MangaIndex)
|
||||||
} ?: continue
|
} ?: continue
|
||||||
val info = index.getMangaInfo() ?: 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<Manga?> = async(context) {
|
||||||
|
runInterruptible {
|
||||||
|
runCatching { getFromFile(file) }.getOrNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun zipUri(file: File, entryName: String) = "cbz://${file.path}#$entryName"
|
private fun zipUri(file: File, entryName: String) = "cbz://${file.path}#$entryName"
|
||||||
|
|
||||||
private fun findFirstImageEntry(entries: Enumeration<out ZipEntry>): ZipEntry? {
|
private fun findFirstImageEntry(entries: Enumeration<out ZipEntry>): ZipEntry? {
|
||||||
@@ -211,7 +246,7 @@ class LocalMangaRepository(private val storageManager: LocalStorageManager) : Ma
|
|||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val name = contentResolver.resolveName(uri)
|
val name = contentResolver.resolveName(uri)
|
||||||
?: throw IOException("Cannot fetch name from uri: $uri")
|
?: throw IOException("Cannot fetch name from uri: $uri")
|
||||||
if (!isFileSupported(name)) {
|
if (!filenameFilter.isFileSupported(name)) {
|
||||||
throw UnsupportedFileException("Unsupported file on $uri")
|
throw UnsupportedFileException("Unsupported file on $uri")
|
||||||
}
|
}
|
||||||
val dest = File(
|
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? {
|
suspend fun getOutputDir(): File? {
|
||||||
return storageManager.getDefaultWriteableDir()
|
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 ->
|
private suspend fun getAllFiles() = storageManager.getReadableDirs().flatMap { dir ->
|
||||||
dir.listFiles(filenameFilter)?.toList().orEmpty()
|
dir.listFiles(filenameFilter)?.toList().orEmpty()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<LocalMangaRepository>()
|
||||||
|
|
||||||
|
override suspend fun processIntent(intent: Intent?) {
|
||||||
|
val manga = intent?.getParcelableExtra<ParcelableManga>(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<Long>) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,16 +3,18 @@ package org.koitharu.kotatsu.local.ui
|
|||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.lifecycle.MutableLiveData
|
import androidx.lifecycle.MutableLiveData
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import java.io.IOException
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.koitharu.kotatsu.BuildConfig
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.core.os.ShortcutsRepository
|
import org.koitharu.kotatsu.core.os.ShortcutsRepository
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
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.history.domain.HistoryRepository
|
||||||
import org.koitharu.kotatsu.list.ui.MangaListViewModel
|
import org.koitharu.kotatsu.list.ui.MangaListViewModel
|
||||||
import org.koitharu.kotatsu.list.ui.model.*
|
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.SingleLiveEvent
|
||||||
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
|
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
|
||||||
import org.koitharu.kotatsu.utils.progress.Progress
|
import org.koitharu.kotatsu.utils.progress.Progress
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
class LocalListViewModel(
|
class LocalListViewModel(
|
||||||
private val repository: LocalMangaRepository,
|
private val repository: LocalMangaRepository,
|
||||||
@@ -64,6 +67,7 @@ class LocalListViewModel(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
onRefresh()
|
onRefresh()
|
||||||
|
cleanup()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onRefresh() {
|
override fun onRefresh() {
|
||||||
@@ -116,4 +120,18 @@ class LocalListViewModel(
|
|||||||
listError.value = e
|
listError.value = e
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun cleanup() {
|
||||||
|
if (!DownloadService.isRunning) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching {
|
||||||
|
repository.cleanup()
|
||||||
|
}.onFailure { error ->
|
||||||
|
if (BuildConfig.DEBUG) {
|
||||||
|
error.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -137,6 +137,9 @@ class RemoteListViewModel(
|
|||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
}
|
}
|
||||||
listError.value = e
|
listError.value = e
|
||||||
|
if (!mangaList.value.isNullOrEmpty()) {
|
||||||
|
onError.postCall(e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import org.koitharu.kotatsu.base.ui.BasePreferenceFragment
|
|||||||
import org.koitharu.kotatsu.base.ui.dialog.StorageSelectDialog
|
import org.koitharu.kotatsu.base.ui.dialog.StorageSelectDialog
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
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.getStorageName
|
||||||
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
|
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
|
||||||
|
|
||||||
@@ -28,6 +29,13 @@ class ContentSettingsFragment :
|
|||||||
findPreference<Preference>(AppSettings.KEY_SUGGESTIONS)?.setSummary(
|
findPreference<Preference>(AppSettings.KEY_SUGGESTIONS)?.setSummary(
|
||||||
if (settings.isSuggestionsEnabled) R.string.enabled else R.string.disabled
|
if (settings.isSuggestionsEnabled) R.string.enabled else R.string.disabled
|
||||||
)
|
)
|
||||||
|
findPreference<SliderPreference>(AppSettings.KEY_DOWNLOADS_PARALLELISM)?.run {
|
||||||
|
summary = value.toString()
|
||||||
|
setOnPreferenceChangeListener { preference, newValue ->
|
||||||
|
preference.summary = newValue.toString()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
bindRemoteSourcesSummary()
|
bindRemoteSourcesSummary()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,12 +5,8 @@ import android.app.backup.BackupDataInput
|
|||||||
import android.app.backup.BackupDataOutput
|
import android.app.backup.BackupDataOutput
|
||||||
import android.app.backup.FullBackupDataOutput
|
import android.app.backup.FullBackupDataOutput
|
||||||
import android.os.ParcelFileDescriptor
|
import android.os.ParcelFileDescriptor
|
||||||
import kotlinx.coroutines.NonCancellable
|
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import org.koitharu.kotatsu.core.backup.BackupArchive
|
import org.koitharu.kotatsu.core.backup.*
|
||||||
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.db.MangaDatabase
|
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||||
import java.io.*
|
import java.io.*
|
||||||
|
|
||||||
@@ -46,7 +42,7 @@ class AppBackupAgent : BackupAgent() {
|
|||||||
mode: Long,
|
mode: Long,
|
||||||
mtime: Long
|
mtime: Long
|
||||||
) {
|
) {
|
||||||
if (destination?.name?.endsWith(".bak") == true) {
|
if (destination?.name?.endsWith(".bk.zip") == true) {
|
||||||
restoreBackupFile(data.fileDescriptor, size)
|
restoreBackupFile(data.fileDescriptor, size)
|
||||||
destination.delete()
|
destination.delete()
|
||||||
} else {
|
} else {
|
||||||
@@ -56,14 +52,14 @@ class AppBackupAgent : BackupAgent() {
|
|||||||
|
|
||||||
private fun createBackupFile() = runBlocking {
|
private fun createBackupFile() = runBlocking {
|
||||||
val repository = BackupRepository(MangaDatabase.create(applicationContext))
|
val repository = BackupRepository(MangaDatabase.create(applicationContext))
|
||||||
val backup = BackupArchive.createNew(this@AppBackupAgent)
|
BackupZipOutput(this@AppBackupAgent).use { backup ->
|
||||||
backup.put(repository.createIndex())
|
backup.put(repository.createIndex())
|
||||||
backup.put(repository.dumpHistory())
|
backup.put(repository.dumpHistory())
|
||||||
backup.put(repository.dumpCategories())
|
backup.put(repository.dumpCategories())
|
||||||
backup.put(repository.dumpFavourites())
|
backup.put(repository.dumpFavourites())
|
||||||
backup.flush()
|
backup.finish()
|
||||||
backup.cleanup()
|
backup.file
|
||||||
backup.file
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun restoreBackupFile(fd: FileDescriptor, size: Long) {
|
private fun restoreBackupFile(fd: FileDescriptor, size: Long) {
|
||||||
@@ -74,18 +70,15 @@ class AppBackupAgent : BackupAgent() {
|
|||||||
input.copyLimitedTo(output, size)
|
input.copyLimitedTo(output, size)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val backup = BackupArchive(tempFile)
|
val backup = BackupZipInput(tempFile)
|
||||||
try {
|
try {
|
||||||
runBlocking {
|
runBlocking {
|
||||||
backup.unpack()
|
|
||||||
repository.upsertHistory(backup.getEntry(BackupEntry.HISTORY))
|
repository.upsertHistory(backup.getEntry(BackupEntry.HISTORY))
|
||||||
repository.upsertCategories(backup.getEntry(BackupEntry.CATEGORIES))
|
repository.upsertCategories(backup.getEntry(BackupEntry.CATEGORIES))
|
||||||
repository.upsertFavourites(backup.getEntry(BackupEntry.FAVOURITES))
|
repository.upsertFavourites(backup.getEntry(BackupEntry.FAVOURITES))
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
runBlocking(NonCancellable) {
|
backup.close()
|
||||||
backup.cleanup()
|
|
||||||
}
|
|
||||||
tempFile.delete()
|
tempFile.delete()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ package org.koitharu.kotatsu.settings.backup
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.lifecycle.MutableLiveData
|
import androidx.lifecycle.MutableLiveData
|
||||||
import org.koitharu.kotatsu.base.ui.BaseViewModel
|
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.BackupRepository
|
||||||
|
import org.koitharu.kotatsu.core.backup.BackupZipOutput
|
||||||
import org.koitharu.kotatsu.utils.SingleLiveEvent
|
import org.koitharu.kotatsu.utils.SingleLiveEvent
|
||||||
import org.koitharu.kotatsu.utils.progress.Progress
|
import org.koitharu.kotatsu.utils.progress.Progress
|
||||||
import java.io.File
|
import java.io.File
|
||||||
@@ -19,23 +19,25 @@ class BackupViewModel(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
launchLoadingJob {
|
launchLoadingJob {
|
||||||
val backup = BackupArchive.createNew(context)
|
val file = BackupZipOutput(context).use { backup ->
|
||||||
backup.put(repository.createIndex())
|
backup.put(repository.createIndex())
|
||||||
|
|
||||||
progress.value = Progress(0, 3)
|
progress.value = Progress(0, 3)
|
||||||
backup.put(repository.dumpHistory())
|
backup.put(repository.dumpHistory())
|
||||||
|
|
||||||
progress.value = Progress(1, 3)
|
progress.value = Progress(1, 3)
|
||||||
backup.put(repository.dumpCategories())
|
backup.put(repository.dumpCategories())
|
||||||
|
|
||||||
progress.value = Progress(2, 3)
|
progress.value = Progress(2, 3)
|
||||||
backup.put(repository.dumpFavourites())
|
backup.put(repository.dumpFavourites())
|
||||||
|
|
||||||
progress.value = Progress(3, 3)
|
progress.value = Progress(3, 3)
|
||||||
backup.flush()
|
backup.finish()
|
||||||
progress.value = null
|
progress.value = null
|
||||||
backup.cleanup()
|
backup.close()
|
||||||
onBackupDone.call(backup.file)
|
backup.file
|
||||||
|
}
|
||||||
|
onBackupDone.call(file)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,19 +3,17 @@ package org.koitharu.kotatsu.settings.backup
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.lifecycle.MutableLiveData
|
import androidx.lifecycle.MutableLiveData
|
||||||
import java.io.File
|
|
||||||
import java.io.FileNotFoundException
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.NonCancellable
|
|
||||||
import kotlinx.coroutines.runInterruptible
|
import kotlinx.coroutines.runInterruptible
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import org.koitharu.kotatsu.base.ui.BaseViewModel
|
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.BackupEntry
|
||||||
|
import org.koitharu.kotatsu.core.backup.BackupZipInput
|
||||||
import org.koitharu.kotatsu.core.backup.CompositeResult
|
import org.koitharu.kotatsu.core.backup.CompositeResult
|
||||||
import org.koitharu.kotatsu.core.backup.RestoreRepository
|
import org.koitharu.kotatsu.core.backup.RestoreRepository
|
||||||
import org.koitharu.kotatsu.utils.SingleLiveEvent
|
import org.koitharu.kotatsu.utils.SingleLiveEvent
|
||||||
import org.koitharu.kotatsu.utils.progress.Progress
|
import org.koitharu.kotatsu.utils.progress.Progress
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileNotFoundException
|
||||||
|
|
||||||
class RestoreViewModel(
|
class RestoreViewModel(
|
||||||
uri: Uri?,
|
uri: Uri?,
|
||||||
@@ -40,10 +38,9 @@ class RestoreViewModel(
|
|||||||
input.copyTo(output)
|
input.copyTo(output)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
BackupArchive(tempFile)
|
BackupZipInput(tempFile)
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
backup.unpack()
|
|
||||||
val result = CompositeResult()
|
val result = CompositeResult()
|
||||||
|
|
||||||
progress.value = Progress(0, 3)
|
progress.value = Progress(0, 3)
|
||||||
@@ -58,10 +55,8 @@ class RestoreViewModel(
|
|||||||
progress.value = Progress(3, 3)
|
progress.value = Progress(3, 3)
|
||||||
onRestoreDone.call(result)
|
onRestoreDone.call(result)
|
||||||
} finally {
|
} finally {
|
||||||
withContext(NonCancellable) {
|
backup.close()
|
||||||
backup.cleanup()
|
backup.file.delete()
|
||||||
backup.file.delete()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<Task>()
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import android.net.ConnectivityManager
|
|||||||
import android.net.Network
|
import android.net.Network
|
||||||
import android.net.NetworkRequest
|
import android.net.NetworkRequest
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
import androidx.work.CoroutineWorker
|
import androidx.work.CoroutineWorker
|
||||||
import kotlin.coroutines.resume
|
import kotlin.coroutines.resume
|
||||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
@@ -14,9 +15,14 @@ val Context.connectivityManager: ConnectivityManager
|
|||||||
|
|
||||||
suspend fun ConnectivityManager.waitForNetwork(): Network {
|
suspend fun ConnectivityManager.waitForNetwork(): Network {
|
||||||
val request = NetworkRequest.Builder().build()
|
val request = NetworkRequest.Builder().build()
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||||
|
// fast path
|
||||||
|
activeNetwork?.let { return it }
|
||||||
|
}
|
||||||
return suspendCancellableCoroutine { cont ->
|
return suspendCancellableCoroutine { cont ->
|
||||||
val callback = object : ConnectivityManager.NetworkCallback() {
|
val callback = object : ConnectivityManager.NetworkCallback() {
|
||||||
override fun onAvailable(network: Network) {
|
override fun onAvailable(network: Network) {
|
||||||
|
unregisterNetworkCallback(this)
|
||||||
if (cont.isActive) {
|
if (cont.isActive) {
|
||||||
cont.resume(network)
|
cont.resume(network)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import kotlinx.parcelize.Parcelize
|
|||||||
@Parcelize
|
@Parcelize
|
||||||
data class Progress(
|
data class Progress(
|
||||||
val value: Int,
|
val value: Int,
|
||||||
val total: Int
|
val total: Int,
|
||||||
) : Parcelable, Comparable<Progress> {
|
) : Parcelable, Comparable<Progress> {
|
||||||
|
|
||||||
override fun compareTo(other: Progress): Int {
|
override fun compareTo(other: Progress): Int {
|
||||||
|
|||||||
@@ -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<Int>()
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
12
app/src/main/res/drawable/ic_pause.xml
Normal file
12
app/src/main/res/drawable/ic_pause.xml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:tint="?colorControlNormal"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#000"
|
||||||
|
android:pathData="M14,19H18V5H14M6,19H10V5H6V19Z" />
|
||||||
|
</vector>
|
||||||
12
app/src/main/res/drawable/ic_resume.xml
Normal file
12
app/src/main/res/drawable/ic_resume.xml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:tint="?colorControlNormal"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#000"
|
||||||
|
android:pathData="M8,5.14V19.14L19,12.14L8,5.14Z" />
|
||||||
|
</vector>
|
||||||
@@ -5,13 +5,12 @@
|
|||||||
xmlns:tools="http://schemas.android.com/tools"
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:background="?android:attr/selectableItemBackground"
|
|
||||||
android:baselineAligned="false"
|
android:baselineAligned="false"
|
||||||
android:clipChildren="false"
|
android:clipChildren="false"
|
||||||
android:clipToPadding="false"
|
android:clipToPadding="false"
|
||||||
android:orientation="horizontal"
|
|
||||||
android:gravity="center_vertical"
|
android:gravity="center_vertical"
|
||||||
android:minHeight="?android:attr/listPreferredItemHeightSmall"
|
android:minHeight="?android:attr/listPreferredItemHeightSmall"
|
||||||
|
android:orientation="horizontal"
|
||||||
android:paddingStart="?android:attr/listPreferredItemPaddingStart"
|
android:paddingStart="?android:attr/listPreferredItemPaddingStart"
|
||||||
android:paddingEnd="?android:attr/listPreferredItemPaddingEnd"
|
android:paddingEnd="?android:attr/listPreferredItemPaddingEnd"
|
||||||
tools:ignore="PrivateResource">
|
tools:ignore="PrivateResource">
|
||||||
@@ -27,17 +26,18 @@
|
|||||||
android:clipToPadding="false"
|
android:clipToPadding="false"
|
||||||
android:orientation="vertical">
|
android:orientation="vertical">
|
||||||
|
|
||||||
<RelativeLayout
|
<LinearLayout
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="0dp"
|
android:orientation="horizontal"
|
||||||
android:layout_weight="1">
|
android:baselineAligned="true"
|
||||||
|
android:baselineAlignedChildIndex="0"
|
||||||
|
android:layout_height="wrap_content">
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@android:id/title"
|
android:id="@android:id/title"
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
|
android:layout_weight="1"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_alignParentStart="true"
|
|
||||||
android:layout_toStartOf="@android:id/summary"
|
|
||||||
android:ellipsize="marquee"
|
android:ellipsize="marquee"
|
||||||
android:labelFor="@id/seekbar"
|
android:labelFor="@id/seekbar"
|
||||||
android:singleLine="true"
|
android:singleLine="true"
|
||||||
@@ -49,12 +49,11 @@
|
|||||||
style="@style/PreferenceSummaryTextStyle"
|
style="@style/PreferenceSummaryTextStyle"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_alignParentEnd="true"
|
|
||||||
android:singleLine="true"
|
android:singleLine="true"
|
||||||
android:textAlignment="viewStart"
|
android:textAlignment="viewStart"
|
||||||
android:textColor="?android:attr/textColorSecondary" />
|
android:textColor="?android:attr/textColorSecondary" />
|
||||||
|
|
||||||
</RelativeLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
<com.google.android.material.slider.Slider
|
<com.google.android.material.slider.Slider
|
||||||
android:id="@+id/slider"
|
android:id="@+id/slider"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
18
app/src/main/res/menu/opt_downloads.xml
Normal file
18
app/src/main/res/menu/opt_downloads.xml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<menu
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||||
|
|
||||||
|
<item
|
||||||
|
android:icon="@drawable/ic_pause"
|
||||||
|
android:id="@+id/action_pause"
|
||||||
|
android:title="Pause"
|
||||||
|
app:showAsAction="ifRoom|withText" />
|
||||||
|
|
||||||
|
<item
|
||||||
|
android:icon="@drawable/ic_resume"
|
||||||
|
android:id="@+id/action_resume"
|
||||||
|
android:title="Resume"
|
||||||
|
app:showAsAction="ifRoom|withText" />
|
||||||
|
|
||||||
|
</menu>
|
||||||
@@ -271,4 +271,9 @@
|
|||||||
<string name="text_delete_local_manga_batch">Удалить выбранную мангу с накопителя?</string>
|
<string name="text_delete_local_manga_batch">Удалить выбранную мангу с накопителя?</string>
|
||||||
<string name="removal_completed">Удаление завершено</string>
|
<string name="removal_completed">Удаление завершено</string>
|
||||||
<string name="batch_manga_save_confirm">Загрузить выбранную мангу со всеми главами? Это может привести к большому расходу трафика и места на накопителе</string>
|
<string name="batch_manga_save_confirm">Загрузить выбранную мангу со всеми главами? Это может привести к большому расходу трафика и места на накопителе</string>
|
||||||
|
<string name="parallel_downloads">Загружать параллельно</string>
|
||||||
|
<string name="download_slowdown">Замедление загрузки</string>
|
||||||
|
<string name="download_slowdown_summary">Помогает избежать блокировки IP-адреса</string>
|
||||||
|
<string name="local_manga_processing">Обработка сохранённой манги</string>
|
||||||
|
<string name="chapters_will_removed_background">Главы будут удалены в фоновом режиме. Это может занять какое-то время</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -274,4 +274,9 @@
|
|||||||
<string name="text_delete_local_manga_batch">Delete selected items from device permanently?</string>
|
<string name="text_delete_local_manga_batch">Delete selected items from device permanently?</string>
|
||||||
<string name="removal_completed">Removal completed</string>
|
<string name="removal_completed">Removal completed</string>
|
||||||
<string name="batch_manga_save_confirm">Are you sure you want to download all selected manga with all its chapters? This action can consume a lot of traffic and storage</string>
|
<string name="batch_manga_save_confirm">Are you sure you want to download all selected manga with all its chapters? This action can consume a lot of traffic and storage</string>
|
||||||
|
<string name="parallel_downloads">Parallel downloads</string>
|
||||||
|
<string name="download_slowdown">Download slowdown</string>
|
||||||
|
<string name="download_slowdown_summary">Helps avoid blocking your IP address</string>
|
||||||
|
<string name="local_manga_processing">Saved manga processing</string>
|
||||||
|
<string name="chapters_will_removed_background">Chapters will be removed in the background. It can take some time</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -8,17 +8,32 @@
|
|||||||
android:key="remote_sources"
|
android:key="remote_sources"
|
||||||
android:title="@string/remote_sources" />
|
android:title="@string/remote_sources" />
|
||||||
|
|
||||||
<Preference
|
|
||||||
android:key="local_storage"
|
|
||||||
android:persistent="false"
|
|
||||||
android:title="@string/manga_save_location" />
|
|
||||||
|
|
||||||
<PreferenceScreen
|
<PreferenceScreen
|
||||||
android:fragment="org.koitharu.kotatsu.settings.SuggestionsSettingsFragment"
|
android:fragment="org.koitharu.kotatsu.settings.SuggestionsSettingsFragment"
|
||||||
android:key="suggestions"
|
android:key="suggestions"
|
||||||
android:persistent="false"
|
android:persistent="false"
|
||||||
android:title="@string/suggestions" />
|
android:title="@string/suggestions" />
|
||||||
|
|
||||||
|
<Preference
|
||||||
|
android:key="local_storage"
|
||||||
|
android:persistent="false"
|
||||||
|
android:title="@string/manga_save_location"
|
||||||
|
app:allowDividerAbove="true" />
|
||||||
|
|
||||||
|
<SwitchPreferenceCompat
|
||||||
|
android:defaultValue="false"
|
||||||
|
android:key="downloads_slowdown"
|
||||||
|
android:summary="@string/download_slowdown_summary"
|
||||||
|
android:title="@string/download_slowdown" />
|
||||||
|
|
||||||
|
<org.koitharu.kotatsu.settings.utils.SliderPreference
|
||||||
|
android:key="downloads_parallelism"
|
||||||
|
android:stepSize="1"
|
||||||
|
android:title="@string/parallel_downloads"
|
||||||
|
android:valueFrom="1"
|
||||||
|
android:valueTo="5"
|
||||||
|
app:defaultValue="2" />
|
||||||
|
|
||||||
<PreferenceScreen
|
<PreferenceScreen
|
||||||
android:fragment="org.koitharu.kotatsu.settings.backup.BackupSettingsFragment"
|
android:fragment="org.koitharu.kotatsu.settings.backup.BackupSettingsFragment"
|
||||||
android:title="@string/backup_restore"
|
android:title="@string/backup_restore"
|
||||||
|
|||||||
Reference in New Issue
Block a user