From 0077dc2f1c1f9151bb9fba48ba9fb1551a0a07d0 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Wed, 10 Aug 2022 14:27:51 +0300 Subject: [PATCH] Option to import manga from directories #31 --- app/src/main/AndroidManifest.xml | 1 + .../kotatsu/base/ui/CoroutineIntentService.kt | 2 +- .../base/ui/widgets/CheckableButtonGroup.kt | 1 - .../download/ui/service/DownloadService.kt | 2 +- .../kotatsu/library/ui/LibraryMenuProvider.kt | 5 + .../koitharu/kotatsu/local/data/CbzFilter.kt | 11 +- .../kotatsu/local/data/FlowFileObserver.kt | 27 +++ .../kotatsu/local/data/LocalStorageManager.kt | 12 ++ .../koitharu/kotatsu/local/data/MangaIndex.kt | 30 ++- .../kotatsu/local/domain/CbzMangaOutput.kt | 12 +- .../local/domain/LocalMangaRepository.kt | 25 --- .../local/domain/importer/DirMangaImporter.kt | 140 ++++++++++++++ .../local/domain/importer/MangaImporter.kt | 43 +++++ .../local/domain/importer/ZipMangaImporter.kt | 39 ++++ .../kotatsu/local/ui/ImportDialogFragment.kt | 60 ++++++ .../kotatsu/local/ui/ImportService.kt | 173 ++++++++++++++++++ .../local/ui/LocalChaptersRemoveService.kt | 13 ++ .../kotatsu/local/ui/LocalListFragment.kt | 60 +----- .../kotatsu/local/ui/LocalListViewModel.kt | 22 +-- .../kotatsu/utils/ext/CollectionExt.kt | 4 +- .../kotatsu/utils/ext/PrimitiveExt.kt | 6 +- .../drawable-anydpi-v24/ic_stat_book_plus.xml | 4 +- .../res/drawable-anydpi-v24/ic_stat_done.xml | 15 ++ .../main/res/drawable-hdpi/ic_stat_done.png | Bin 0 -> 240 bytes .../main/res/drawable-mdpi/ic_stat_done.png | Bin 0 -> 208 bytes .../main/res/drawable-xhdpi/ic_stat_done.png | Bin 0 -> 285 bytes .../main/res/drawable-xxhdpi/ic_stat_done.png | Bin 0 -> 487 bytes app/src/main/res/drawable/ic_file_zip.xml | 14 ++ app/src/main/res/drawable/ic_folder_file.xml | 12 ++ app/src/main/res/layout/dialog_import.xml | 32 ++++ app/src/main/res/menu/opt_library.xml | 5 + app/src/main/res/values/constants.xml | 2 +- app/src/main/res/values/strings.xml | 6 + 33 files changed, 657 insertions(+), 121 deletions(-) create mode 100644 app/src/main/java/org/koitharu/kotatsu/local/data/FlowFileObserver.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/local/domain/importer/DirMangaImporter.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/local/domain/importer/MangaImporter.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/local/domain/importer/ZipMangaImporter.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/local/ui/ImportDialogFragment.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/local/ui/ImportService.kt create mode 100644 app/src/main/res/drawable-anydpi-v24/ic_stat_done.xml create mode 100644 app/src/main/res/drawable-hdpi/ic_stat_done.png create mode 100644 app/src/main/res/drawable-mdpi/ic_stat_done.png create mode 100644 app/src/main/res/drawable-xhdpi/ic_stat_done.png create mode 100644 app/src/main/res/drawable-xxhdpi/ic_stat_done.png create mode 100644 app/src/main/res/drawable/ic_file_zip.xml create mode 100644 app/src/main/res/drawable/ic_folder_file.xml create mode 100644 app/src/main/res/layout/dialog_import.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2e2325290..e064f604c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -137,6 +137,7 @@ android:name="org.koitharu.kotatsu.download.ui.service.DownloadService" android:foregroundServiceType="dataSync" /> + diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/CoroutineIntentService.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/CoroutineIntentService.kt index 10f06f68d..0e63b3950 100644 --- a/app/src/main/java/org/koitharu/kotatsu/base/ui/CoroutineIntentService.kt +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/CoroutineIntentService.kt @@ -15,7 +15,7 @@ abstract class CoroutineIntentService : BaseService() { private val mutex = Mutex() protected open val dispatcher: CoroutineDispatcher = Dispatchers.Default - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + final override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { super.onStartCommand(intent, flags, startId) launchCoroutine(intent, startId) return Service.START_REDELIVER_INTENT diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/CheckableButtonGroup.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/CheckableButtonGroup.kt index 09931d540..f159a56cd 100644 --- a/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/CheckableButtonGroup.kt +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/CheckableButtonGroup.kt @@ -12,7 +12,6 @@ import com.google.android.material.R as materialR import com.google.android.material.button.MaterialButton import com.google.android.material.shape.ShapeAppearanceModel -@Deprecated("") class CheckableButtonGroup @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt index 39c9e0cca..4b18d30be 100644 --- a/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt @@ -196,7 +196,7 @@ class DownloadService : BaseService() { private const val ACTION_DOWNLOAD_CANCEL = "${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_CANCEL" private const val ACTION_DOWNLOAD_RESUME = "${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_RESUME" - private const val EXTRA_MANGA = "manga" + const val EXTRA_MANGA = "manga" private const val EXTRA_CHAPTERS_IDS = "chapters_ids" private const val EXTRA_CANCEL_ID = "cancel_id" diff --git a/app/src/main/java/org/koitharu/kotatsu/library/ui/LibraryMenuProvider.kt b/app/src/main/java/org/koitharu/kotatsu/library/ui/LibraryMenuProvider.kt index c7947ebf7..f00570421 100644 --- a/app/src/main/java/org/koitharu/kotatsu/library/ui/LibraryMenuProvider.kt +++ b/app/src/main/java/org/koitharu/kotatsu/library/ui/LibraryMenuProvider.kt @@ -14,6 +14,7 @@ import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.ui.dialog.RememberSelectionDialogListener import org.koitharu.kotatsu.library.ui.config.categories.LibraryCategoriesConfigSheet import org.koitharu.kotatsu.library.ui.config.size.LibrarySizeBottomSheet +import org.koitharu.kotatsu.local.ui.ImportDialogFragment import org.koitharu.kotatsu.utils.ext.startOfDay class LibraryMenuProvider( @@ -36,6 +37,10 @@ class LibraryMenuProvider( LibrarySizeBottomSheet.show(fragmentManager) true } + R.id.action_import -> { + ImportDialogFragment.show(fragmentManager) + true + } R.id.action_categories -> { LibraryCategoriesConfigSheet.show(fragmentManager) true diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/CbzFilter.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/CbzFilter.kt index 8b8fca986..5fafe31a1 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/data/CbzFilter.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/data/CbzFilter.kt @@ -10,8 +10,11 @@ class CbzFilter : FilenameFilter { return isFileSupported(name) } - fun isFileSupported(name: String): Boolean { - val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT) - return ext == "cbz" || ext == "zip" + companion object { + + fun isFileSupported(name: String): Boolean { + val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT) + return ext == "cbz" || ext == "zip" + } } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/FlowFileObserver.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/FlowFileObserver.kt new file mode 100644 index 000000000..a5bec126f --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/local/data/FlowFileObserver.kt @@ -0,0 +1,27 @@ +package org.koitharu.kotatsu.local.data + +import android.os.FileObserver +import java.io.File +import kotlinx.coroutines.channels.ProducerScope +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.channels.trySendBlocking +import kotlinx.coroutines.flow.callbackFlow + +@Suppress("DEPRECATION") +class FlowFileObserver( + private val producerScope: ProducerScope, + private val file: File, +) : FileObserver(file.absolutePath, CREATE or DELETE or CLOSE_WRITE) { + + override fun onEvent(event: Int, path: String?) { + producerScope.trySendBlocking( + if (path == null) file else file.resolve(path), + ) + } +} + +fun File.observe() = callbackFlow { + val observer = FlowFileObserver(this, this@observe) + observer.startWatching() + awaitClose { observer.stopWatching() } +} 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 81493561b..5c138869c 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 @@ -9,6 +9,10 @@ import java.io.File import javax.inject.Inject import javax.inject.Singleton import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.asFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flatMapMerge import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.withContext import okhttp3.Cache @@ -75,6 +79,14 @@ class LocalStorageManager @Inject constructor( fun getStorageDisplayName(file: File) = file.getStorageName(context) + fun observe(files: List): Flow { + if (files.isEmpty()) { + return emptyFlow() + } + return files.asFlow() + .flatMapMerge(files.size) { it.observe() } + } + @WorkerThread private fun getConfiguredStorageDirs(): MutableSet { val set = getAvailableStorageDirs() diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/MangaIndex.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/MangaIndex.kt index 3a585be9c..ee570e826 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/data/MangaIndex.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/data/MangaIndex.kt @@ -9,6 +9,7 @@ import org.koitharu.kotatsu.parsers.util.json.getLongOrDefault import org.koitharu.kotatsu.parsers.util.json.getStringOrNull import org.koitharu.kotatsu.parsers.util.json.mapJSONToSet import org.koitharu.kotatsu.parsers.util.toTitleCase +import org.koitharu.kotatsu.utils.AlphanumComparator class MangaIndex(source: String?) { @@ -37,7 +38,7 @@ class MangaIndex(source: String?) { jo.put("title", tag.title) a.put(jo) } - } + }, ) if (!append || !json.has("chapters")) { json.put("chapters", JSONObject()) @@ -68,7 +69,7 @@ class MangaIndex(source: String?) { MangaTag( title = x.getString("title").toTitleCase(), key = x.getString("key"), - source = source + source = source, ) }, chapters = getChapters(json.getJSONObject("chapters"), source), @@ -103,9 +104,28 @@ class MangaIndex(source: String?) { fun getChapterNamesPattern(chapter: MangaChapter) = Regex( json.getJSONObject("chapters") .getJSONObject(chapter.id.toString()) - .getString("entries") + .getString("entries"), ) + fun sortChaptersByName() { + val jo = json.getJSONObject("chapters") + val list = ArrayList(jo.length()) + jo.keys().forEach { id -> + val item = jo.getJSONObject(id) + item.put("id", id) + list.add(item) + } + val comparator = AlphanumComparator() + list.sortWith(compareBy(comparator) { it.getString("name") }) + val newJo = JSONObject() + list.forEachIndexed { i, obj -> + obj.put("number", i + 1) + val id = obj.remove("id") as String + newJo.put(id, obj) + } + json.put("chapters", newJo) + } + private fun getChapters(json: JSONObject, source: MangaSource): List { val chapters = ArrayList(json.length()) for (k in json.keys()) { @@ -120,7 +140,7 @@ class MangaIndex(source: String?) { scanlator = v.getStringOrNull("scanlator"), branch = v.getStringOrNull("branch"), source = source, - ) + ), ) } return chapters.sortedBy { it.number } @@ -131,4 +151,4 @@ class MangaIndex(source: String?) { } else { json.toString() } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/koitharu/kotatsu/local/domain/CbzMangaOutput.kt b/app/src/main/java/org/koitharu/kotatsu/local/domain/CbzMangaOutput.kt index 53e2d474a..d643475d7 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/domain/CbzMangaOutput.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/domain/CbzMangaOutput.kt @@ -1,6 +1,8 @@ package org.koitharu.kotatsu.local.domain import androidx.annotation.WorkerThread +import java.io.File +import java.util.zip.ZipFile import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runInterruptible import okio.Closeable @@ -11,8 +13,6 @@ import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.util.toFileNameSafe import org.koitharu.kotatsu.utils.ext.deleteAwait import org.koitharu.kotatsu.utils.ext.readText -import java.io.File -import java.util.zip.ZipFile class CbzMangaOutput( val file: File, @@ -80,6 +80,10 @@ class CbzMangaOutput( output.close() } + fun sortChaptersByName() { + index.sortChaptersByName() + } + @WorkerThread private fun mergeWith(other: File) { var otherIndex: MangaIndex? = null @@ -89,7 +93,7 @@ class CbzMangaOutput( otherIndex = MangaIndex( zip.getInputStream(entry).use { it.reader().readText() - } + }, ) } else { output.copyEntryFrom(zip, entry) @@ -150,4 +154,4 @@ class CbzMangaOutput( } } } -} \ No newline at end of file +} 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 b37b1a5b8..22c88481c 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,7 +8,6 @@ import androidx.collection.ArraySet import androidx.core.net.toFile import androidx.core.net.toUri import java.io.File -import java.io.IOException import java.util.* import java.util.zip.ZipEntry import java.util.zip.ZipFile @@ -16,7 +15,6 @@ import javax.inject.Inject import javax.inject.Singleton import kotlin.coroutines.CoroutineContext import kotlinx.coroutines.* -import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.local.data.CbzFilter import org.koitharu.kotatsu.local.data.LocalStorageManager @@ -29,7 +27,6 @@ import org.koitharu.kotatsu.utils.CompositeMutex 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.resolveName private const val MAX_PARALLELISM = 4 @@ -250,28 +247,6 @@ class LocalMangaRepository @Inject constructor(private val storageManager: Local 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 (!filenameFilter.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") - } - } - suspend fun getOutputDir(): File? { return storageManager.getDefaultWriteableDir() } diff --git a/app/src/main/java/org/koitharu/kotatsu/local/domain/importer/DirMangaImporter.kt b/app/src/main/java/org/koitharu/kotatsu/local/domain/importer/DirMangaImporter.kt new file mode 100644 index 000000000..3d9085cbb --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/local/domain/importer/DirMangaImporter.kt @@ -0,0 +1,140 @@ +package org.koitharu.kotatsu.local.domain.importer + +import android.content.Context +import android.net.Uri +import android.webkit.MimeTypeMap +import androidx.documentfile.provider.DocumentFile +import java.io.File +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.withContext +import org.koitharu.kotatsu.local.data.LocalStorageManager +import org.koitharu.kotatsu.local.domain.CbzMangaOutput +import org.koitharu.kotatsu.local.domain.LocalMangaRepository +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaChapter +import org.koitharu.kotatsu.parsers.model.MangaSource +import org.koitharu.kotatsu.parsers.model.RATING_UNKNOWN +import org.koitharu.kotatsu.utils.ext.deleteAwait +import org.koitharu.kotatsu.utils.ext.longOf + +// TODO: Add support for chapters in cbz +// https://github.com/KotatsuApp/Kotatsu/issues/31 +class DirMangaImporter( + private val context: Context, + storageManager: LocalStorageManager, + private val localMangaRepository: LocalMangaRepository, +) : MangaImporter(storageManager) { + + private val contentResolver = context.contentResolver + + override suspend fun import(uri: Uri): Manga { + val root = requireNotNull(DocumentFile.fromTreeUri(context, uri)) { + "Provided uri $uri is not a tree" + } + val manga = Manga(root) + val output = CbzMangaOutput.get(getOutputDir(), manga) + try { + val dest = output.use { + addPages( + output = it, + root = root, + path = "", + state = State(uri.hashCode(), 0, false), + ) + it.sortChaptersByName() + it.mergeWithExisting() + it.finalize() + it.file + } + return localMangaRepository.getFromFile(dest) + } finally { + withContext(NonCancellable) { + output.cleanup() + File(getOutputDir(), "page.tmp").deleteAwait() + } + } + } + + private suspend fun addPages(output: CbzMangaOutput, root: DocumentFile, path: String, state: State) { + var number = 0 + for (file in root.listFiles()) { + when { + file.isDirectory -> { + addPages(output, file, path + "/" + file.name, state) + } + file.isFile -> { + val tempFile = file.asTempFile() + if (!state.hasCover) { + output.addCover(tempFile, file.extension) + state.hasCover = true + } + output.addPage( + chapter = state.getChapter(path), + file = tempFile, + pageNumber = number, + ext = file.extension, + ) + number++ + } + } + } + } + + private suspend fun DocumentFile.asTempFile(): File { + val file = File(getOutputDir(), "page.tmp") + checkNotNull(contentResolver.openInputStream(uri)) { + "Cannot open input stream for $uri" + }.use { input -> + file.outputStream().use { output -> + input.copyTo(output) + } + } + return file + } + + private fun Manga(file: DocumentFile) = Manga( + id = longOf(file.uri.hashCode(), 0), + title = checkNotNull(file.name), + altTitle = null, + url = file.uri.path.orEmpty(), + publicUrl = file.uri.toString(), + rating = RATING_UNKNOWN, + isNsfw = false, + coverUrl = "", + tags = emptySet(), + state = null, + author = null, + source = MangaSource.LOCAL, + ) + + private val DocumentFile.extension: String + get() = type?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } + ?: name?.substringAfterLast('.')?.takeIf { it.length in 2..4 } + ?: error("Cannot obtain extension of $uri") + + private class State( + private val rootId: Int, + private var counter: Int, + var hasCover: Boolean, + ) { + + private val chapters = HashMap() + + @Synchronized + fun getChapter(path: String): MangaChapter { + return chapters.getOrPut(path) { + counter++ + MangaChapter( + id = longOf(rootId, counter), + name = path.replace('/', ' ').trim(), + number = counter, + url = path.ifEmpty { "Default chapter" }, + scanlator = null, + uploadDate = 0L, + branch = null, + source = MangaSource.LOCAL, + ) + } + } + } +} diff --git a/app/src/main/java/org/koitharu/kotatsu/local/domain/importer/MangaImporter.kt b/app/src/main/java/org/koitharu/kotatsu/local/domain/importer/MangaImporter.kt new file mode 100644 index 000000000..dc281b920 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/local/domain/importer/MangaImporter.kt @@ -0,0 +1,43 @@ +package org.koitharu.kotatsu.local.domain.importer + +import android.content.Context +import android.net.Uri +import androidx.documentfile.provider.DocumentFile +import dagger.hilt.android.qualifiers.ApplicationContext +import java.io.File +import java.io.IOException +import javax.inject.Inject +import org.koitharu.kotatsu.local.data.LocalStorageManager +import org.koitharu.kotatsu.local.domain.LocalMangaRepository +import org.koitharu.kotatsu.parsers.model.Manga + +abstract class MangaImporter( + protected val storageManager: LocalStorageManager, +) { + + abstract suspend fun import(uri: Uri): Manga + + suspend fun getOutputDir(): File { + return storageManager.getDefaultWriteableDir() ?: throw IOException("External files dir unavailable") + } + + class Factory @Inject constructor( + @ApplicationContext private val context: Context, + private val storageManager: LocalStorageManager, + private val localMangaRepository: LocalMangaRepository, + ) { + + fun create(uri: Uri): MangaImporter { + return when { + isDir(uri) -> DirMangaImporter(context, storageManager, localMangaRepository) + else -> ZipMangaImporter(storageManager, localMangaRepository) + } + } + + private fun isDir(uri: Uri): Boolean { + return runCatching { + DocumentFile.fromTreeUri(context, uri) + }.isSuccess + } + } +} diff --git a/app/src/main/java/org/koitharu/kotatsu/local/domain/importer/ZipMangaImporter.kt b/app/src/main/java/org/koitharu/kotatsu/local/domain/importer/ZipMangaImporter.kt new file mode 100644 index 000000000..a60d8e39e --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/local/domain/importer/ZipMangaImporter.kt @@ -0,0 +1,39 @@ +package org.koitharu.kotatsu.local.domain.importer + +import android.net.Uri +import java.io.File +import java.io.IOException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runInterruptible +import kotlinx.coroutines.withContext +import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException +import org.koitharu.kotatsu.local.data.CbzFilter +import org.koitharu.kotatsu.local.data.LocalStorageManager +import org.koitharu.kotatsu.local.domain.LocalMangaRepository +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.utils.ext.resolveName + +class ZipMangaImporter( + storageManager: LocalStorageManager, + private val localMangaRepository: LocalMangaRepository, +) : MangaImporter(storageManager) { + + override suspend fun import(uri: Uri): Manga { + val contentResolver = storageManager.contentResolver + return withContext(Dispatchers.IO) { + val name = contentResolver.resolveName(uri) ?: throw IOException("Cannot fetch name from uri: $uri") + if (!CbzFilter.isFileSupported(name)) { + throw UnsupportedFileException("Unsupported file on $uri") + } + val dest = File(getOutputDir(), name) + runInterruptible { + contentResolver.openInputStream(uri)?.use { source -> + dest.outputStream().use { output -> + source.copyTo(output) + } + } + } ?: throw IOException("Cannot open input stream: $uri") + localMangaRepository.getFromFile(dest) + } + } +} diff --git a/app/src/main/java/org/koitharu/kotatsu/local/ui/ImportDialogFragment.kt b/app/src/main/java/org/koitharu/kotatsu/local/ui/ImportDialogFragment.kt new file mode 100644 index 000000000..000e57c14 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/local/ui/ImportDialogFragment.kt @@ -0,0 +1,60 @@ +package org.koitharu.kotatsu.local.ui + +import android.net.Uri +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.activity.result.contract.ActivityResultContracts +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.activityViewModels +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.ui.AlertDialogFragment +import org.koitharu.kotatsu.databinding.DialogImportBinding + +class ImportDialogFragment : AlertDialogFragment(), View.OnClickListener { + + private val viewModel by activityViewModels() + private val importFileCall = registerForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { + startImport(it) + } + private val importDirCall = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { + startImport(listOfNotNull(it)) + } + + override fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): DialogImportBinding { + return DialogImportBinding.inflate(inflater, container, false) + } + + override fun onBuildDialog(builder: MaterialAlertDialogBuilder) { + builder.setTitle(R.string._import) + .setNegativeButton(android.R.string.cancel, null) + .setCancelable(true) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.buttonDir.setOnClickListener(this) + binding.buttonFile.setOnClickListener(this) + } + + override fun onClick(v: View) { + when (v.id) { + R.id.button_file -> importFileCall.launch(arrayOf("*/*")) + R.id.button_dir -> importDirCall.launch(null) + } + } + + private fun startImport(uris: Collection) { + ImportService.start(requireContext(), uris) + dismiss() + } + + companion object { + + private const val TAG = "ImportDialogFragment" + + fun show(fm: FragmentManager) = ImportDialogFragment().show(fm, TAG) + } +} diff --git a/app/src/main/java/org/koitharu/kotatsu/local/ui/ImportService.kt b/app/src/main/java/org/koitharu/kotatsu/local/ui/ImportService.kt new file mode 100644 index 000000000..408bae352 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/local/ui/ImportService.kt @@ -0,0 +1,173 @@ +package org.koitharu.kotatsu.local.ui + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.widget.Toast +import androidx.core.app.NotificationCompat +import androidx.core.app.ServiceCompat +import androidx.core.content.ContextCompat +import coil.ImageLoader +import coil.request.ImageRequest +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CancellationException +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.ui.CoroutineIntentService +import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga +import org.koitharu.kotatsu.details.ui.DetailsActivity +import org.koitharu.kotatsu.download.ui.service.DownloadService +import org.koitharu.kotatsu.local.domain.importer.MangaImporter +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.utils.PendingIntentCompat +import org.koitharu.kotatsu.utils.ext.* +import javax.inject.Inject + +@AndroidEntryPoint +class ImportService : CoroutineIntentService() { + + @Inject + lateinit var importerFactory: MangaImporter.Factory + + @Inject + lateinit var coil: ImageLoader + + private lateinit var notificationManager: NotificationManager + + override fun onCreate() { + super.onCreate() + isRunning = true + notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + } + + override fun onDestroy() { + isRunning = false + super.onDestroy() + } + + override suspend fun processIntent(intent: Intent?) { + val uris = intent?.getParcelableArrayListExtra(EXTRA_URIS) + if (uris.isNullOrEmpty()) { + return + } + startForeground() + for (uri in uris) { + try { + val manga = importImpl(uri) + showNotification(uri, manga, null) + sendBroadcast(manga) + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + e.printStackTraceDebug() + showNotification(uri, null, e) + } + } + ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) + } + + private suspend fun importImpl(uri: Uri): Manga { + val importer = importerFactory.create(uri) + return importer.import(uri) + } + + private fun sendBroadcast(manga: Manga) { + sendBroadcast( + Intent(DownloadService.ACTION_DOWNLOAD_COMPLETE) + .putExtra(DownloadService.EXTRA_MANGA, ParcelableManga(manga, withChapters = false)), + ) + } + + private suspend fun showNotification(uri: Uri, manga: Manga?, error: Throwable?) { + val notification = NotificationCompat.Builder(this, CHANNEL_ID) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setDefaults(0) + .setColor(ContextCompat.getColor(this, R.color.blue_primary_dark)) + .setSilent(true) + if (manga != null) { + notification.setLargeIcon( + coil.execute( + ImageRequest.Builder(applicationContext).data(manga.coverUrl).referer(manga.publicUrl).build(), + ).toBitmapOrNull(), + ) + notification.setSubText(manga.title) + val intent = DetailsActivity.newIntent(applicationContext, manga) + notification.setContentIntent( + PendingIntent.getActivity( + applicationContext, + manga.id.toInt(), + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE, + ), + ).setAutoCancel(true) + .setVisibility( + if (manga.isNsfw) { + NotificationCompat.VISIBILITY_SECRET + } else NotificationCompat.VISIBILITY_PUBLIC, + ) + } + if (error != null) { + notification.setContentTitle(getString(R.string.error_occurred)) + .setContentText(error.getDisplayMessage(resources)) + .setSmallIcon(android.R.drawable.stat_notify_error) + } else { + notification.setContentTitle(getString(R.string.import_completed)) + .setContentText(getString(R.string.import_completed_hint)) + .setSmallIcon(R.drawable.ic_stat_done) + NotificationCompat.BigTextStyle(notification) + .bigText(getString(R.string.import_completed_hint)) + } + + notificationManager.notify(uri.hashCode(), notification.build()) + } + + private fun startForeground() { + val title = getString(R.string.importing_manga) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val channel = NotificationChannel(CHANNEL_ID, title, NotificationManager.IMPORTANCE_LOW) + channel.setShowBadge(false) + channel.enableVibration(false) + channel.setSound(null, null) + channel.enableLights(false) + manager.createNotificationChannel(channel) + } + + val notification = NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle(title) + .setPriority(NotificationCompat.PRIORITY_MIN) + .setDefaults(0) + .setColor(ContextCompat.getColor(this, R.color.blue_primary_dark)) + .setSilent(true) + .setProgress(0, 0, true) + .setSmallIcon(android.R.drawable.stat_sys_download) + .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) + .setOngoing(true) + .build() + startForeground(NOTIFICATION_ID, notification) + } + + companion object { + + var isRunning: Boolean = false + private set + + private const val CHANNEL_ID = "importing" + private const val NOTIFICATION_ID = 22 + + private const val EXTRA_URIS = "uris" + + fun start(context: Context, uris: Collection) { + if (uris.isEmpty()) { + return + } + val intent = Intent(context, ImportService::class.java) + intent.putParcelableArrayListExtra(EXTRA_URIS, uris.asArrayList()) + ContextCompat.startForegroundService(context, intent) + Toast.makeText(context, R.string.import_will_start_soon, Toast.LENGTH_LONG).show() + } + } +} diff --git a/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalChaptersRemoveService.kt b/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalChaptersRemoveService.kt index a95b7bebc..0a97bb99d 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalChaptersRemoveService.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalChaptersRemoveService.kt @@ -23,6 +23,16 @@ class LocalChaptersRemoveService : CoroutineIntentService() { @Inject lateinit var localMangaRepository: LocalMangaRepository + override fun onCreate() { + super.onCreate() + isRunning = true + } + + override fun onDestroy() { + isRunning = false + super.onDestroy() + } + override suspend fun processIntent(intent: Intent?) { val manga = intent?.getParcelableExtra(EXTRA_MANGA)?.manga ?: return val chaptersIds = intent.getLongArrayExtra(EXTRA_CHAPTERS_IDS)?.toSet() ?: return @@ -64,6 +74,9 @@ class LocalChaptersRemoveService : CoroutineIntentService() { companion object { + var isRunning: Boolean = false + private set + private const val CHANNEL_ID = "local_processing" private const val NOTIFICATION_ID = 21 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 aadfe7ad3..9038f2d9f 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 @@ -1,13 +1,13 @@ package org.koitharu.kotatsu.local.ui -import android.content.* -import android.net.Uri +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter import android.os.Bundle import android.view.Menu import android.view.MenuItem import android.view.View -import androidx.activity.result.ActivityResultCallback -import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.view.ActionMode import androidx.core.net.toFile import androidx.core.net.toUri @@ -20,17 +20,10 @@ import org.koitharu.kotatsu.download.ui.service.DownloadService import org.koitharu.kotatsu.list.ui.MangaListFragment import org.koitharu.kotatsu.utils.ShareHelper import org.koitharu.kotatsu.utils.ext.addMenuProvider -import org.koitharu.kotatsu.utils.ext.printStackTraceDebug -import org.koitharu.kotatsu.utils.progress.Progress -class LocalListFragment : MangaListFragment(), ActivityResultCallback> { +class LocalListFragment : MangaListFragment() { override val viewModel by viewModels() - private val importCall = registerForActivityResult( - 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) { @@ -51,12 +44,6 @@ class LocalListFragment : MangaListFragment(), ActivityResultCallback) { - if (result.isEmpty()) return - viewModel.importFiles(result) - } + override fun onScrolledToEnd() = Unit override fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean { mode.menuInflater.inflate(R.menu.mode_local, menu) @@ -121,25 +94,6 @@ class LocalListFragment : MangaListFragment(), ActivityResultCallback() - val importProgress = MutableLiveData(null) private val listError = MutableStateFlow(null) private val mangaList = MutableStateFlow?>(null) - private var importJob: Job? = null override val content = combine( mangaList, @@ -75,20 +69,6 @@ class LocalListViewModel @Inject constructor( override fun onRetry() = onRefresh() - 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() - } - importProgress.postValue(null) - } - } - fun delete(ids: Set) { launchLoadingJob { withContext(Dispatchers.Default) { @@ -118,7 +98,7 @@ class LocalListViewModel @Inject constructor( } private fun cleanup() { - if (!DownloadService.isRunning) { + if (!DownloadService.isRunning && !ImportService.isRunning && !LocalChaptersRemoveService.isRunning) { viewModelScope.launch { runCatching { repository.cleanup() diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/CollectionExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/CollectionExt.kt index 611bbd442..6f70d2175 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/CollectionExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/CollectionExt.kt @@ -25,7 +25,7 @@ inline fun Set(size: Int, init: (index: Int) -> T): Set = when (size) { else -> MutableSet(size, init) } -fun List.asArrayList(): ArrayList = if (this is ArrayList<*>) { +fun Collection.asArrayList(): ArrayList = if (this is ArrayList<*>) { this as ArrayList } else { ArrayList(this) @@ -38,4 +38,4 @@ fun Map.findKeyByValue(value: V): K? { } } return null -} \ No newline at end of file +} diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/PrimitiveExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/PrimitiveExt.kt index a8359f5a1..dee4a06d7 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/PrimitiveExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/PrimitiveExt.kt @@ -1,3 +1,7 @@ package org.koitharu.kotatsu.utils.ext -inline fun Int.ifZero(defaultValue: () -> Int): Int = if (this == 0) defaultValue() else this \ No newline at end of file +inline fun Int.ifZero(defaultValue: () -> Int): Int = if (this == 0) defaultValue() else this + +fun longOf(a: Int, b: Int): Long { + return a.toLong() shl 32 or (b.toLong() and 0xffffffffL) +} diff --git a/app/src/main/res/drawable-anydpi-v24/ic_stat_book_plus.xml b/app/src/main/res/drawable-anydpi-v24/ic_stat_book_plus.xml index 3d3c49f04..b06600927 100644 --- a/app/src/main/res/drawable-anydpi-v24/ic_stat_book_plus.xml +++ b/app/src/main/res/drawable-anydpi-v24/ic_stat_book_plus.xml @@ -6,5 +6,5 @@ android:viewportHeight="24"> - \ No newline at end of file + android:pathData="M4 20H18V22H4C2.9 22 2 21.1 2 20V6H4V20M22 4V16C22 17.1 21.1 18 20 18H8C6.9 18 6 17.1 6 16V4C6 2.9 6.9 2 8 2H20C21.1 2 22 2.9 22 4M20 4H8V16H20V4M18 6H13V13L15.5 11.5L18 13V6Z" /> + diff --git a/app/src/main/res/drawable-anydpi-v24/ic_stat_done.xml b/app/src/main/res/drawable-anydpi-v24/ic_stat_done.xml new file mode 100644 index 000000000..603163b23 --- /dev/null +++ b/app/src/main/res/drawable-anydpi-v24/ic_stat_done.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable-hdpi/ic_stat_done.png b/app/src/main/res/drawable-hdpi/ic_stat_done.png new file mode 100644 index 0000000000000000000000000000000000000000..315f0e22d98db90f507f52f42c2da1f3974d7d2b GIT binary patch literal 240 zcmV{}wjti2DjXP8DFti21^JItnMQP0e! z&K91XxzZMEJ-p{i39v^vAATnp|s@$t9eq2gXs q8^+rXD(*GOaA_ButKi<0Zs`MFgDcW-*R84m000005AWEP)jwOAqB{`aCI8Z?8<)te5` zfcQsSw{$~v@hZ;{gLv_j>vnHAjQ7HLFO2uX_)j3<)W0Imy2qb-Kk3f)T;kRA5QDSg zcku!d^3dMl5IE->uMjgm1op>>SBN?s0{ddcE5vOMfzM;cD@2ZlSZnlnB~ggm6Ej{( z93n?z$190MY`%D9hR7AKFvLA&JkdXEU&?}C7f;1@O_Rg+ZVrm=nkI+s-5eC#HLbp1 j&}0bDwc0KsBKn#a8=M>DT{i7(00000NkvXXu0mjftHOM% literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xxhdpi/ic_stat_done.png b/app/src/main/res/drawable-xxhdpi/ic_stat_done.png new file mode 100644 index 0000000000000000000000000000000000000000..357b330e35eaf576b67122c4cd3d2f4282006889 GIT binary patch literal 487 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY1|&n@Zgyv2U|i?v;uum9_x9>uJ(fU;V;^1H zowT_)Tm6;`E{zV@$uzgm%k!m}%eLU1%<|%n%7F?(Q$1eT%scx|I{p5en=>~m&$N90 zQ#Ie%IKAkc{p7oT?k-@kB#q(OUsK71Ig#gdpHDJ={61Z`BU#Vl^1q!ry&cKB=4k$} z36n_RTl{!N@_sexj(W>yy_=t1Pi5>9Wg&KXIxsGKEoD+@` zNax>Q=%asWS^YMyDczgjTsP%1-|?0t&ndVm$KXoPt*ag)U@^mRbl|HT@=UK?(JV(o~QQ=ba zBNu}+jOiES#RUa_*8gF*dLFa2=G>p1S%;_JU;OtSkipC<#m{9TCz4_F!aWKSs(kj3 XRx*E-`dw%Rj6eoYS3j3^P6j>D@ literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable/ic_file_zip.xml b/app/src/main/res/drawable/ic_file_zip.xml new file mode 100644 index 000000000..817e9a11f --- /dev/null +++ b/app/src/main/res/drawable/ic_file_zip.xml @@ -0,0 +1,14 @@ + + + + diff --git a/app/src/main/res/drawable/ic_folder_file.xml b/app/src/main/res/drawable/ic_folder_file.xml new file mode 100644 index 000000000..e36512cdc --- /dev/null +++ b/app/src/main/res/drawable/ic_folder_file.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/layout/dialog_import.xml b/app/src/main/res/layout/dialog_import.xml new file mode 100644 index 000000000..2513088ca --- /dev/null +++ b/app/src/main/res/layout/dialog_import.xml @@ -0,0 +1,32 @@ + + + + + + + + diff --git a/app/src/main/res/menu/opt_library.xml b/app/src/main/res/menu/opt_library.xml index dd0b59de8..3cefebcb3 100644 --- a/app/src/main/res/menu/opt_library.xml +++ b/app/src/main/res/menu/opt_library.xml @@ -3,6 +3,11 @@ xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto"> + https://hosted.weblate.org/engage/kotatsu kotatsu@waifu.club org.kotatsu.sync - http://95.216.215.49:8055 + http://86.57.183.214:8081 -1 1 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0f08a45c7..c7516b8e6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -368,4 +368,10 @@ %ss Ch. %1$d/%2$d Pg. %3$d/%4$d Show information bar in reader + Comics archive + Folder with images + Importing manga + Import completed + You can delete the original file from storage to save space + Import will start soon