Support batch manga import
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
package org.koitharu.kotatsu.local.data
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.os.StatFs
|
||||
import androidx.annotation.WorkerThread
|
||||
@@ -21,6 +22,9 @@ class LocalStorageManager(
|
||||
private val settings: AppSettings,
|
||||
) {
|
||||
|
||||
val contentResolver: ContentResolver
|
||||
get() = context.contentResolver
|
||||
|
||||
fun createHttpCache(): Cache {
|
||||
val directory = File(context.externalCacheDir ?: context.cacheDir, "http")
|
||||
directory.mkdirs()
|
||||
|
||||
@@ -8,6 +8,8 @@ import androidx.core.net.toFile
|
||||
import androidx.core.net.toUri
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException
|
||||
import org.koitharu.kotatsu.core.model.*
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.local.data.CbzFilter
|
||||
@@ -15,11 +17,9 @@ import org.koitharu.kotatsu.local.data.LocalStorageManager
|
||||
import org.koitharu.kotatsu.local.data.MangaIndex
|
||||
import org.koitharu.kotatsu.local.data.MangaZip
|
||||
import org.koitharu.kotatsu.utils.AlphanumComparator
|
||||
import org.koitharu.kotatsu.utils.ext.deleteAwait
|
||||
import org.koitharu.kotatsu.utils.ext.longHashCode
|
||||
import org.koitharu.kotatsu.utils.ext.readText
|
||||
import org.koitharu.kotatsu.utils.ext.toCamelCase
|
||||
import org.koitharu.kotatsu.utils.ext.*
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.util.*
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipFile
|
||||
@@ -197,6 +197,28 @@ class LocalMangaRepository(private val storageManager: LocalStorageManager) : Ma
|
||||
|
||||
override suspend fun getTags() = emptySet<MangaTag>()
|
||||
|
||||
suspend fun import(uri: Uri) {
|
||||
val contentResolver = storageManager.contentResolver
|
||||
withContext(Dispatchers.IO) {
|
||||
val name = contentResolver.resolveName(uri)
|
||||
?: throw IOException("Cannot fetch name from uri: $uri")
|
||||
if (!isFileSupported(name)) {
|
||||
throw UnsupportedFileException("Unsupported file on $uri")
|
||||
}
|
||||
val dest = File(
|
||||
getOutputDir() ?: throw IOException("External files dir unavailable"),
|
||||
name,
|
||||
)
|
||||
runInterruptible {
|
||||
contentResolver.openInputStream(uri)?.use { source ->
|
||||
dest.outputStream().use { output ->
|
||||
source.copyTo(output)
|
||||
}
|
||||
}
|
||||
} ?: throw IOException("Cannot open input stream: $uri")
|
||||
}
|
||||
}
|
||||
|
||||
fun isFileSupported(name: String): Boolean {
|
||||
val ext = name.substringAfterLast('.').lowercase(Locale.ROOT)
|
||||
return ext == "cbz" || ext == "zip"
|
||||
|
||||
@@ -18,14 +18,16 @@ import org.koitharu.kotatsu.core.model.Manga
|
||||
import org.koitharu.kotatsu.download.ui.service.DownloadService
|
||||
import org.koitharu.kotatsu.list.ui.MangaListFragment
|
||||
import org.koitharu.kotatsu.utils.ext.ellipsize
|
||||
import org.koitharu.kotatsu.utils.progress.Progress
|
||||
|
||||
class LocalListFragment : MangaListFragment(), ActivityResultCallback<Uri?> {
|
||||
class LocalListFragment : MangaListFragment(), ActivityResultCallback<List<@JvmSuppressWildcards Uri>> {
|
||||
|
||||
override val viewModel by viewModel<LocalListViewModel>()
|
||||
private val importCall = registerForActivityResult(
|
||||
ActivityResultContracts.OpenDocument(),
|
||||
ActivityResultContracts.OpenMultipleDocuments(),
|
||||
this
|
||||
)
|
||||
private var importSnackbar: Snackbar? = null
|
||||
private val downloadReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
if (intent?.action == DownloadService.ACTION_DOWNLOAD_COMPLETE) {
|
||||
@@ -45,6 +47,12 @@ class LocalListFragment : MangaListFragment(), ActivityResultCallback<Uri?> {
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
viewModel.onMangaRemoved.observe(viewLifecycleOwner, ::onItemRemoved)
|
||||
viewModel.importProgress.observe(viewLifecycleOwner, ::onImportProgressChanged)
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
importSnackbar = null
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
override fun onDetach() {
|
||||
@@ -84,10 +92,9 @@ class LocalListFragment : MangaListFragment(), ActivityResultCallback<Uri?> {
|
||||
return context?.getString(R.string.local_storage)
|
||||
}
|
||||
|
||||
override fun onActivityResult(result: Uri?) {
|
||||
if (result != null) {
|
||||
viewModel.importFile(context?.applicationContext ?: return, result)
|
||||
}
|
||||
override fun onActivityResult(result: List<@JvmSuppressWildcards Uri>) {
|
||||
if (result.isEmpty()) return
|
||||
viewModel.importFiles(result)
|
||||
}
|
||||
|
||||
override fun onCreatePopupMenu(inflater: MenuInflater, menu: Menu, data: Manga) {
|
||||
@@ -121,6 +128,25 @@ class LocalListFragment : MangaListFragment(), ActivityResultCallback<Uri?> {
|
||||
).show()
|
||||
}
|
||||
|
||||
private fun onImportProgressChanged(progress: Progress?) {
|
||||
if (progress == null) {
|
||||
importSnackbar?.dismiss()
|
||||
importSnackbar = null
|
||||
return
|
||||
}
|
||||
val summaryText = getString(
|
||||
R.string.importing_progress,
|
||||
progress.value + 1,
|
||||
progress.total,
|
||||
)
|
||||
importSnackbar?.setText(summaryText) ?: run {
|
||||
val snackbar =
|
||||
Snackbar.make(binding.recyclerView, summaryText, Snackbar.LENGTH_INDEFINITE)
|
||||
importSnackbar = snackbar
|
||||
snackbar.show()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
fun newInstance() = LocalListFragment()
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
package org.koitharu.kotatsu.local.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException
|
||||
import org.koitharu.kotatsu.core.model.Manga
|
||||
import org.koitharu.kotatsu.core.os.ShortcutsRepository
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
@@ -19,7 +18,7 @@ import org.koitharu.kotatsu.list.ui.model.*
|
||||
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
|
||||
import org.koitharu.kotatsu.utils.SingleLiveEvent
|
||||
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
|
||||
import org.koitharu.kotatsu.utils.ext.resolveName
|
||||
import org.koitharu.kotatsu.utils.progress.Progress
|
||||
import java.io.IOException
|
||||
|
||||
class LocalListViewModel(
|
||||
@@ -30,9 +29,11 @@ class LocalListViewModel(
|
||||
) : MangaListViewModel(settings) {
|
||||
|
||||
val onMangaRemoved = SingleLiveEvent<Manga>()
|
||||
val importProgress = MutableLiveData<Progress?>(null)
|
||||
private val listError = MutableStateFlow<Throwable?>(null)
|
||||
private val mangaList = MutableStateFlow<List<Manga>?>(null)
|
||||
private val headerModel = ListHeader(null, R.string.local_storage)
|
||||
private var importJob: Job? = null
|
||||
|
||||
override val content = combine(
|
||||
mangaList,
|
||||
@@ -59,37 +60,23 @@ class LocalListViewModel(
|
||||
|
||||
override fun onRefresh() {
|
||||
launchLoadingJob(Dispatchers.Default) {
|
||||
try {
|
||||
listError.value = null
|
||||
mangaList.value = repository.getList2(0)
|
||||
} catch (e: Throwable) {
|
||||
listError.value = e
|
||||
}
|
||||
doRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRetry() = onRefresh()
|
||||
|
||||
fun importFile(context: Context, uri: Uri) {
|
||||
launchLoadingJob {
|
||||
val contentResolver = context.contentResolver
|
||||
withContext(Dispatchers.IO) {
|
||||
val name = contentResolver.resolveName(uri)
|
||||
?: throw IOException("Cannot fetch name from uri: $uri")
|
||||
if (!repository.isFileSupported(name)) {
|
||||
throw UnsupportedFileException("Unsupported file on $uri")
|
||||
}
|
||||
val dest = repository.getOutputDir()
|
||||
?: throw IOException("External files dir unavailable")
|
||||
runInterruptible {
|
||||
contentResolver.openInputStream(uri)?.use { source ->
|
||||
dest.outputStream().use { output ->
|
||||
source.copyTo(output)
|
||||
}
|
||||
}
|
||||
} ?: throw IOException("Cannot open input stream: $uri")
|
||||
fun importFiles(uris: List<Uri>) {
|
||||
val previousJob = importJob
|
||||
importJob = launchJob(Dispatchers.Default) {
|
||||
previousJob?.join()
|
||||
importProgress.postValue(Progress(0, uris.size))
|
||||
for ((i, uri) in uris.withIndex()) {
|
||||
repository.import(uri)
|
||||
importProgress.postValue(Progress(i + 1, uris.size))
|
||||
doRefresh()
|
||||
}
|
||||
onRefresh()
|
||||
importProgress.postValue(null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,4 +94,13 @@ class LocalListViewModel(
|
||||
onMangaRemoved.call(manga)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun doRefresh() {
|
||||
try {
|
||||
listError.value = null
|
||||
mangaList.value = repository.getList2(0)
|
||||
} catch (e: Throwable) {
|
||||
listError.value = e
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,10 +15,6 @@ import java.io.File
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipFile
|
||||
|
||||
@Suppress("NOTHING_TO_INLINE")
|
||||
@Deprecated("Useless", ReplaceWith("File(this, name)", "java.io.File"))
|
||||
inline fun File.sub(name: String) = File(this, name)
|
||||
|
||||
fun File.subdir(name: String) = File(this, name).also {
|
||||
if (!it.exists()) it.mkdirs()
|
||||
}
|
||||
@@ -37,14 +33,6 @@ fun File.computeSize(): Long = listFiles()?.sumOf { x ->
|
||||
}
|
||||
} ?: 0L
|
||||
|
||||
inline fun File.findParent(predicate: (File) -> Boolean): File? {
|
||||
var current = this
|
||||
while (!predicate(current)) {
|
||||
current = current.parentFile ?: return null
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
fun File.getStorageName(context: Context): String = runCatching {
|
||||
val manager = context.getSystemService(Context.STORAGE_SERVICE) as StorageManager
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
|
||||
@@ -249,4 +249,5 @@
|
||||
<string name="available_sources">Available sources</string>
|
||||
<string name="dynamic_theme">Dynamic theme</string>
|
||||
<string name="dynamic_theme_summary">Applies a theme created on the color scheme of your wallpaper</string>
|
||||
<string name="importing_progress">Importing manga: %1$d of %2$d</string>
|
||||
</resources>
|
||||
Reference in New Issue
Block a user