From ed4c470bdce988be2f2d8155d58baf4af1af922f Mon Sep 17 00:00:00 2001 From: Koitharu Date: Sat, 26 Feb 2022 13:56:21 +0200 Subject: [PATCH] Support batch manga import --- .../kotatsu/local/data/LocalStorageManager.kt | 4 ++ .../local/domain/LocalMangaRepository.kt | 30 +++++++++-- .../kotatsu/local/ui/LocalListFragment.kt | 38 ++++++++++--- .../kotatsu/local/ui/LocalListViewModel.kt | 54 +++++++++---------- .../org/koitharu/kotatsu/utils/ext/FileExt.kt | 12 ----- app/src/main/res/values/strings.xml | 1 + 6 files changed, 88 insertions(+), 51 deletions(-) diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/LocalStorageManager.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/LocalStorageManager.kt index b6859cea4..813dfba4f 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/data/LocalStorageManager.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/data/LocalStorageManager.kt @@ -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() diff --git a/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt b/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt index 2b861592f..04adc71f8 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt @@ -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() + 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" diff --git a/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListFragment.kt b/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListFragment.kt index 586e96bfc..94ef2fe21 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListFragment.kt @@ -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 { +class LocalListFragment : MangaListFragment(), ActivityResultCallback> { override val viewModel by viewModel() 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 { 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 { 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 { ).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() diff --git a/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt index 442591984..f89f50d35 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt @@ -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() + val importProgress = MutableLiveData(null) private val listError = MutableStateFlow(null) private val mangaList = MutableStateFlow?>(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) { + 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 + } + } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/FileExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/FileExt.kt index adc5f6f0b..db6fd0f4f 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/FileExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/FileExt.kt @@ -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) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f090d4012..1131b5655 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -249,4 +249,5 @@ Available sources Dynamic theme Applies a theme created on the color scheme of your wallpaper + Importing manga: %1$d of %2$d \ No newline at end of file