Support batch manga import

This commit is contained in:
Koitharu
2022-02-26 13:56:21 +02:00
parent 70db9ba94a
commit ed4c470bdc
6 changed files with 88 additions and 51 deletions

View File

@@ -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()

View File

@@ -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"

View File

@@ -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()

View File

@@ -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
}
}
}

View File

@@ -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) {

View File

@@ -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>