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