Support batch manga import
This commit is contained in:
@@ -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()
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user