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 package org.koitharu.kotatsu.local.data
import android.content.ContentResolver
import android.content.Context import android.content.Context
import android.os.StatFs import android.os.StatFs
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
@@ -21,6 +22,9 @@ class LocalStorageManager(
private val settings: AppSettings, private val settings: AppSettings,
) { ) {
val contentResolver: ContentResolver
get() = context.contentResolver
fun createHttpCache(): Cache { fun createHttpCache(): Cache {
val directory = File(context.externalCacheDir ?: context.cacheDir, "http") val directory = File(context.externalCacheDir ?: context.cacheDir, "http")
directory.mkdirs() directory.mkdirs()

View File

@@ -8,6 +8,8 @@ import androidx.core.net.toFile
import androidx.core.net.toUri import androidx.core.net.toUri
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible 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.model.*
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
@@ -15,11 +17,9 @@ 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.MangaZip
import org.koitharu.kotatsu.utils.AlphanumComparator import org.koitharu.kotatsu.utils.AlphanumComparator
import org.koitharu.kotatsu.utils.ext.deleteAwait import org.koitharu.kotatsu.utils.ext.*
import org.koitharu.kotatsu.utils.ext.longHashCode
import org.koitharu.kotatsu.utils.ext.readText
import org.koitharu.kotatsu.utils.ext.toCamelCase
import java.io.File import java.io.File
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
@@ -197,6 +197,28 @@ class LocalMangaRepository(private val storageManager: LocalStorageManager) : Ma
override suspend fun getTags() = emptySet<MangaTag>() 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 { 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"

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.download.ui.service.DownloadService
import org.koitharu.kotatsu.list.ui.MangaListFragment import org.koitharu.kotatsu.list.ui.MangaListFragment
import org.koitharu.kotatsu.utils.ext.ellipsize 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>() override val viewModel by viewModel<LocalListViewModel>()
private val importCall = registerForActivityResult( private val importCall = registerForActivityResult(
ActivityResultContracts.OpenDocument(), ActivityResultContracts.OpenMultipleDocuments(),
this this
) )
private var importSnackbar: Snackbar? = null
private val downloadReceiver = object : BroadcastReceiver() { private val downloadReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) { override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == DownloadService.ACTION_DOWNLOAD_COMPLETE) { if (intent?.action == DownloadService.ACTION_DOWNLOAD_COMPLETE) {
@@ -45,6 +47,12 @@ class LocalListFragment : MangaListFragment(), ActivityResultCallback<Uri?> {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
viewModel.onMangaRemoved.observe(viewLifecycleOwner, ::onItemRemoved) viewModel.onMangaRemoved.observe(viewLifecycleOwner, ::onItemRemoved)
viewModel.importProgress.observe(viewLifecycleOwner, ::onImportProgressChanged)
}
override fun onDestroyView() {
importSnackbar = null
super.onDestroyView()
} }
override fun onDetach() { override fun onDetach() {
@@ -84,10 +92,9 @@ class LocalListFragment : MangaListFragment(), ActivityResultCallback<Uri?> {
return context?.getString(R.string.local_storage) return context?.getString(R.string.local_storage)
} }
override fun onActivityResult(result: Uri?) { override fun onActivityResult(result: List<@JvmSuppressWildcards Uri>) {
if (result != null) { if (result.isEmpty()) return
viewModel.importFile(context?.applicationContext ?: return, result) viewModel.importFiles(result)
}
} }
override fun onCreatePopupMenu(inflater: MenuInflater, menu: Menu, data: Manga) { override fun onCreatePopupMenu(inflater: MenuInflater, menu: Menu, data: Manga) {
@@ -121,6 +128,25 @@ class LocalListFragment : MangaListFragment(), ActivityResultCallback<Uri?> {
).show() ).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 { companion object {
fun newInstance() = LocalListFragment() fun newInstance() = LocalListFragment()

View File

@@ -1,15 +1,14 @@
package org.koitharu.kotatsu.local.ui package org.koitharu.kotatsu.local.ui
import android.content.Context
import android.net.Uri import android.net.Uri
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
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.runInterruptible
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException
import org.koitharu.kotatsu.core.model.Manga import org.koitharu.kotatsu.core.model.Manga
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
@@ -19,7 +18,7 @@ import org.koitharu.kotatsu.list.ui.model.*
import org.koitharu.kotatsu.local.domain.LocalMangaRepository import org.koitharu.kotatsu.local.domain.LocalMangaRepository
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.ext.resolveName import org.koitharu.kotatsu.utils.progress.Progress
import java.io.IOException import java.io.IOException
class LocalListViewModel( class LocalListViewModel(
@@ -30,9 +29,11 @@ class LocalListViewModel(
) : MangaListViewModel(settings) { ) : MangaListViewModel(settings) {
val onMangaRemoved = SingleLiveEvent<Manga>() val onMangaRemoved = SingleLiveEvent<Manga>()
val importProgress = MutableLiveData<Progress?>(null)
private val listError = MutableStateFlow<Throwable?>(null) private val listError = MutableStateFlow<Throwable?>(null)
private val mangaList = MutableStateFlow<List<Manga>?>(null) private val mangaList = MutableStateFlow<List<Manga>?>(null)
private val headerModel = ListHeader(null, R.string.local_storage) private val headerModel = ListHeader(null, R.string.local_storage)
private var importJob: Job? = null
override val content = combine( override val content = combine(
mangaList, mangaList,
@@ -59,37 +60,23 @@ class LocalListViewModel(
override fun onRefresh() { override fun onRefresh() {
launchLoadingJob(Dispatchers.Default) { launchLoadingJob(Dispatchers.Default) {
try { doRefresh()
listError.value = null
mangaList.value = repository.getList2(0)
} catch (e: Throwable) {
listError.value = e
}
} }
} }
override fun onRetry() = onRefresh() override fun onRetry() = onRefresh()
fun importFile(context: Context, uri: Uri) { fun importFiles(uris: List<Uri>) {
launchLoadingJob { val previousJob = importJob
val contentResolver = context.contentResolver importJob = launchJob(Dispatchers.Default) {
withContext(Dispatchers.IO) { previousJob?.join()
val name = contentResolver.resolveName(uri) importProgress.postValue(Progress(0, uris.size))
?: throw IOException("Cannot fetch name from uri: $uri") for ((i, uri) in uris.withIndex()) {
if (!repository.isFileSupported(name)) { repository.import(uri)
throw UnsupportedFileException("Unsupported file on $uri") importProgress.postValue(Progress(i + 1, uris.size))
} doRefresh()
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")
} }
onRefresh() importProgress.postValue(null)
} }
} }
@@ -107,4 +94,13 @@ class LocalListViewModel(
onMangaRemoved.call(manga) 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.ZipEntry
import java.util.zip.ZipFile 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 { fun File.subdir(name: String) = File(this, name).also {
if (!it.exists()) it.mkdirs() if (!it.exists()) it.mkdirs()
} }
@@ -37,14 +33,6 @@ fun File.computeSize(): Long = listFiles()?.sumOf { x ->
} }
} ?: 0L } ?: 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 { fun File.getStorageName(context: Context): String = runCatching {
val manager = context.getSystemService(Context.STORAGE_SERVICE) as StorageManager val manager = context.getSystemService(Context.STORAGE_SERVICE) as StorageManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {

View File

@@ -249,4 +249,5 @@
<string name="available_sources">Available sources</string> <string name="available_sources">Available sources</string>
<string name="dynamic_theme">Dynamic theme</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="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> </resources>