Option to import manga from directories #31
This commit is contained in:
@@ -137,6 +137,7 @@
|
|||||||
android:name="org.koitharu.kotatsu.download.ui.service.DownloadService"
|
android:name="org.koitharu.kotatsu.download.ui.service.DownloadService"
|
||||||
android:foregroundServiceType="dataSync" />
|
android:foregroundServiceType="dataSync" />
|
||||||
<service android:name="org.koitharu.kotatsu.local.ui.LocalChaptersRemoveService" />
|
<service android:name="org.koitharu.kotatsu.local.ui.LocalChaptersRemoveService" />
|
||||||
|
<service android:name="org.koitharu.kotatsu.local.ui.ImportService" />
|
||||||
<service
|
<service
|
||||||
android:name="org.koitharu.kotatsu.widget.shelf.ShelfWidgetService"
|
android:name="org.koitharu.kotatsu.widget.shelf.ShelfWidgetService"
|
||||||
android:permission="android.permission.BIND_REMOTEVIEWS" />
|
android:permission="android.permission.BIND_REMOTEVIEWS" />
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ abstract class CoroutineIntentService : BaseService() {
|
|||||||
private val mutex = Mutex()
|
private val mutex = Mutex()
|
||||||
protected open val dispatcher: CoroutineDispatcher = Dispatchers.Default
|
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)
|
super.onStartCommand(intent, flags, startId)
|
||||||
launchCoroutine(intent, startId)
|
launchCoroutine(intent, startId)
|
||||||
return Service.START_REDELIVER_INTENT
|
return Service.START_REDELIVER_INTENT
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import com.google.android.material.R as materialR
|
|||||||
import com.google.android.material.button.MaterialButton
|
import com.google.android.material.button.MaterialButton
|
||||||
import com.google.android.material.shape.ShapeAppearanceModel
|
import com.google.android.material.shape.ShapeAppearanceModel
|
||||||
|
|
||||||
@Deprecated("")
|
|
||||||
class CheckableButtonGroup @JvmOverloads constructor(
|
class CheckableButtonGroup @JvmOverloads constructor(
|
||||||
context: Context,
|
context: Context,
|
||||||
attrs: AttributeSet? = null,
|
attrs: AttributeSet? = null,
|
||||||
|
|||||||
@@ -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_CANCEL = "${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_CANCEL"
|
||||||
private const val ACTION_DOWNLOAD_RESUME = "${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_RESUME"
|
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_CHAPTERS_IDS = "chapters_ids"
|
||||||
private const val EXTRA_CANCEL_ID = "cancel_id"
|
private const val EXTRA_CANCEL_ID = "cancel_id"
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import org.koitharu.kotatsu.R
|
|||||||
import org.koitharu.kotatsu.base.ui.dialog.RememberSelectionDialogListener
|
import org.koitharu.kotatsu.base.ui.dialog.RememberSelectionDialogListener
|
||||||
import org.koitharu.kotatsu.library.ui.config.categories.LibraryCategoriesConfigSheet
|
import org.koitharu.kotatsu.library.ui.config.categories.LibraryCategoriesConfigSheet
|
||||||
import org.koitharu.kotatsu.library.ui.config.size.LibrarySizeBottomSheet
|
import org.koitharu.kotatsu.library.ui.config.size.LibrarySizeBottomSheet
|
||||||
|
import org.koitharu.kotatsu.local.ui.ImportDialogFragment
|
||||||
import org.koitharu.kotatsu.utils.ext.startOfDay
|
import org.koitharu.kotatsu.utils.ext.startOfDay
|
||||||
|
|
||||||
class LibraryMenuProvider(
|
class LibraryMenuProvider(
|
||||||
@@ -36,6 +37,10 @@ class LibraryMenuProvider(
|
|||||||
LibrarySizeBottomSheet.show(fragmentManager)
|
LibrarySizeBottomSheet.show(fragmentManager)
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
R.id.action_import -> {
|
||||||
|
ImportDialogFragment.show(fragmentManager)
|
||||||
|
true
|
||||||
|
}
|
||||||
R.id.action_categories -> {
|
R.id.action_categories -> {
|
||||||
LibraryCategoriesConfigSheet.show(fragmentManager)
|
LibraryCategoriesConfigSheet.show(fragmentManager)
|
||||||
true
|
true
|
||||||
|
|||||||
@@ -10,8 +10,11 @@ class CbzFilter : FilenameFilter {
|
|||||||
return isFileSupported(name)
|
return isFileSupported(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun isFileSupported(name: String): Boolean {
|
companion object {
|
||||||
val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT)
|
|
||||||
return ext == "cbz" || ext == "zip"
|
fun isFileSupported(name: String): Boolean {
|
||||||
|
val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT)
|
||||||
|
return ext == "cbz" || ext == "zip"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<File>,
|
||||||
|
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() }
|
||||||
|
}
|
||||||
@@ -9,6 +9,10 @@ import java.io.File
|
|||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
import kotlinx.coroutines.Dispatchers
|
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.runInterruptible
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import okhttp3.Cache
|
import okhttp3.Cache
|
||||||
@@ -75,6 +79,14 @@ class LocalStorageManager @Inject constructor(
|
|||||||
|
|
||||||
fun getStorageDisplayName(file: File) = file.getStorageName(context)
|
fun getStorageDisplayName(file: File) = file.getStorageName(context)
|
||||||
|
|
||||||
|
fun observe(files: List<File>): Flow<File> {
|
||||||
|
if (files.isEmpty()) {
|
||||||
|
return emptyFlow()
|
||||||
|
}
|
||||||
|
return files.asFlow()
|
||||||
|
.flatMapMerge(files.size) { it.observe() }
|
||||||
|
}
|
||||||
|
|
||||||
@WorkerThread
|
@WorkerThread
|
||||||
private fun getConfiguredStorageDirs(): MutableSet<File> {
|
private fun getConfiguredStorageDirs(): MutableSet<File> {
|
||||||
val set = getAvailableStorageDirs()
|
val set = getAvailableStorageDirs()
|
||||||
|
|||||||
@@ -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.getStringOrNull
|
||||||
import org.koitharu.kotatsu.parsers.util.json.mapJSONToSet
|
import org.koitharu.kotatsu.parsers.util.json.mapJSONToSet
|
||||||
import org.koitharu.kotatsu.parsers.util.toTitleCase
|
import org.koitharu.kotatsu.parsers.util.toTitleCase
|
||||||
|
import org.koitharu.kotatsu.utils.AlphanumComparator
|
||||||
|
|
||||||
class MangaIndex(source: String?) {
|
class MangaIndex(source: String?) {
|
||||||
|
|
||||||
@@ -37,7 +38,7 @@ class MangaIndex(source: String?) {
|
|||||||
jo.put("title", tag.title)
|
jo.put("title", tag.title)
|
||||||
a.put(jo)
|
a.put(jo)
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
if (!append || !json.has("chapters")) {
|
if (!append || !json.has("chapters")) {
|
||||||
json.put("chapters", JSONObject())
|
json.put("chapters", JSONObject())
|
||||||
@@ -68,7 +69,7 @@ class MangaIndex(source: String?) {
|
|||||||
MangaTag(
|
MangaTag(
|
||||||
title = x.getString("title").toTitleCase(),
|
title = x.getString("title").toTitleCase(),
|
||||||
key = x.getString("key"),
|
key = x.getString("key"),
|
||||||
source = source
|
source = source,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
chapters = getChapters(json.getJSONObject("chapters"), source),
|
chapters = getChapters(json.getJSONObject("chapters"), source),
|
||||||
@@ -103,9 +104,28 @@ class MangaIndex(source: String?) {
|
|||||||
fun getChapterNamesPattern(chapter: MangaChapter) = Regex(
|
fun getChapterNamesPattern(chapter: MangaChapter) = Regex(
|
||||||
json.getJSONObject("chapters")
|
json.getJSONObject("chapters")
|
||||||
.getJSONObject(chapter.id.toString())
|
.getJSONObject(chapter.id.toString())
|
||||||
.getString("entries")
|
.getString("entries"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
fun sortChaptersByName() {
|
||||||
|
val jo = json.getJSONObject("chapters")
|
||||||
|
val list = ArrayList<JSONObject>(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<MangaChapter> {
|
private fun getChapters(json: JSONObject, source: MangaSource): List<MangaChapter> {
|
||||||
val chapters = ArrayList<MangaChapter>(json.length())
|
val chapters = ArrayList<MangaChapter>(json.length())
|
||||||
for (k in json.keys()) {
|
for (k in json.keys()) {
|
||||||
@@ -120,7 +140,7 @@ class MangaIndex(source: String?) {
|
|||||||
scanlator = v.getStringOrNull("scanlator"),
|
scanlator = v.getStringOrNull("scanlator"),
|
||||||
branch = v.getStringOrNull("branch"),
|
branch = v.getStringOrNull("branch"),
|
||||||
source = source,
|
source = source,
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return chapters.sortedBy { it.number }
|
return chapters.sortedBy { it.number }
|
||||||
@@ -131,4 +151,4 @@ class MangaIndex(source: String?) {
|
|||||||
} else {
|
} else {
|
||||||
json.toString()
|
json.toString()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package org.koitharu.kotatsu.local.domain
|
package org.koitharu.kotatsu.local.domain
|
||||||
|
|
||||||
import androidx.annotation.WorkerThread
|
import androidx.annotation.WorkerThread
|
||||||
|
import java.io.File
|
||||||
|
import java.util.zip.ZipFile
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.runInterruptible
|
import kotlinx.coroutines.runInterruptible
|
||||||
import okio.Closeable
|
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.parsers.util.toFileNameSafe
|
||||||
import org.koitharu.kotatsu.utils.ext.deleteAwait
|
import org.koitharu.kotatsu.utils.ext.deleteAwait
|
||||||
import org.koitharu.kotatsu.utils.ext.readText
|
import org.koitharu.kotatsu.utils.ext.readText
|
||||||
import java.io.File
|
|
||||||
import java.util.zip.ZipFile
|
|
||||||
|
|
||||||
class CbzMangaOutput(
|
class CbzMangaOutput(
|
||||||
val file: File,
|
val file: File,
|
||||||
@@ -80,6 +80,10 @@ class CbzMangaOutput(
|
|||||||
output.close()
|
output.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun sortChaptersByName() {
|
||||||
|
index.sortChaptersByName()
|
||||||
|
}
|
||||||
|
|
||||||
@WorkerThread
|
@WorkerThread
|
||||||
private fun mergeWith(other: File) {
|
private fun mergeWith(other: File) {
|
||||||
var otherIndex: MangaIndex? = null
|
var otherIndex: MangaIndex? = null
|
||||||
@@ -89,7 +93,7 @@ class CbzMangaOutput(
|
|||||||
otherIndex = MangaIndex(
|
otherIndex = MangaIndex(
|
||||||
zip.getInputStream(entry).use {
|
zip.getInputStream(entry).use {
|
||||||
it.reader().readText()
|
it.reader().readText()
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
output.copyEntryFrom(zip, entry)
|
output.copyEntryFrom(zip, entry)
|
||||||
@@ -150,4 +154,4 @@ class CbzMangaOutput(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import androidx.collection.ArraySet
|
|||||||
import androidx.core.net.toFile
|
import androidx.core.net.toFile
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
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
|
||||||
@@ -16,7 +15,6 @@ import javax.inject.Inject
|
|||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
import kotlin.coroutines.CoroutineContext
|
import kotlin.coroutines.CoroutineContext
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException
|
|
||||||
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
|
||||||
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
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.deleteAwait
|
||||||
import org.koitharu.kotatsu.utils.ext.longHashCode
|
import org.koitharu.kotatsu.utils.ext.longHashCode
|
||||||
import org.koitharu.kotatsu.utils.ext.readText
|
import org.koitharu.kotatsu.utils.ext.readText
|
||||||
import org.koitharu.kotatsu.utils.ext.resolveName
|
|
||||||
|
|
||||||
private const val MAX_PARALLELISM = 4
|
private const val MAX_PARALLELISM = 4
|
||||||
|
|
||||||
@@ -250,28 +247,6 @@ class LocalMangaRepository @Inject constructor(private val storageManager: Local
|
|||||||
|
|
||||||
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 (!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? {
|
suspend fun getOutputDir(): File? {
|
||||||
return storageManager.getDefaultWriteableDir()
|
return storageManager.getDefaultWriteableDir()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<String, MangaChapter>()
|
||||||
|
|
||||||
|
@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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<DialogImportBinding>(), View.OnClickListener {
|
||||||
|
|
||||||
|
private val viewModel by activityViewModels<LocalListViewModel>()
|
||||||
|
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<Uri>) {
|
||||||
|
ImportService.start(requireContext(), uris)
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
private const val TAG = "ImportDialogFragment"
|
||||||
|
|
||||||
|
fun show(fm: FragmentManager) = ImportDialogFragment().show(fm, TAG)
|
||||||
|
}
|
||||||
|
}
|
||||||
173
app/src/main/java/org/koitharu/kotatsu/local/ui/ImportService.kt
Normal file
173
app/src/main/java/org/koitharu/kotatsu/local/ui/ImportService.kt
Normal file
@@ -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<Uri>(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<Uri>) {
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,16 @@ class LocalChaptersRemoveService : CoroutineIntentService() {
|
|||||||
@Inject
|
@Inject
|
||||||
lateinit var localMangaRepository: LocalMangaRepository
|
lateinit var localMangaRepository: LocalMangaRepository
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
isRunning = true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
isRunning = false
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun processIntent(intent: Intent?) {
|
override suspend fun processIntent(intent: Intent?) {
|
||||||
val manga = intent?.getParcelableExtra<ParcelableManga>(EXTRA_MANGA)?.manga ?: return
|
val manga = intent?.getParcelableExtra<ParcelableManga>(EXTRA_MANGA)?.manga ?: return
|
||||||
val chaptersIds = intent.getLongArrayExtra(EXTRA_CHAPTERS_IDS)?.toSet() ?: return
|
val chaptersIds = intent.getLongArrayExtra(EXTRA_CHAPTERS_IDS)?.toSet() ?: return
|
||||||
@@ -64,6 +74,9 @@ class LocalChaptersRemoveService : CoroutineIntentService() {
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
|
var isRunning: Boolean = false
|
||||||
|
private set
|
||||||
|
|
||||||
private const val CHANNEL_ID = "local_processing"
|
private const val CHANNEL_ID = "local_processing"
|
||||||
private const val NOTIFICATION_ID = 21
|
private const val NOTIFICATION_ID = 21
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
package org.koitharu.kotatsu.local.ui
|
package org.koitharu.kotatsu.local.ui
|
||||||
|
|
||||||
import android.content.*
|
import android.content.BroadcastReceiver
|
||||||
import android.net.Uri
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.Menu
|
import android.view.Menu
|
||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import androidx.activity.result.ActivityResultCallback
|
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.appcompat.view.ActionMode
|
import androidx.appcompat.view.ActionMode
|
||||||
import androidx.core.net.toFile
|
import androidx.core.net.toFile
|
||||||
import androidx.core.net.toUri
|
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.list.ui.MangaListFragment
|
||||||
import org.koitharu.kotatsu.utils.ShareHelper
|
import org.koitharu.kotatsu.utils.ShareHelper
|
||||||
import org.koitharu.kotatsu.utils.ext.addMenuProvider
|
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<List<@JvmSuppressWildcards Uri>> {
|
class LocalListFragment : MangaListFragment() {
|
||||||
|
|
||||||
override val viewModel by viewModels<LocalListViewModel>()
|
override val viewModel by viewModels<LocalListViewModel>()
|
||||||
private val importCall = registerForActivityResult(
|
|
||||||
ActivityResultContracts.OpenMultipleDocuments(),
|
|
||||||
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) {
|
||||||
@@ -51,12 +44,6 @@ class LocalListFragment : MangaListFragment(), ActivityResultCallback<List<@JvmS
|
|||||||
super.onViewCreated(view, savedInstanceState)
|
super.onViewCreated(view, savedInstanceState)
|
||||||
addMenuProvider(LocalListMenuProvider(this::onEmptyActionClick))
|
addMenuProvider(LocalListMenuProvider(this::onEmptyActionClick))
|
||||||
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() {
|
||||||
@@ -64,25 +51,11 @@ class LocalListFragment : MangaListFragment(), ActivityResultCallback<List<@JvmS
|
|||||||
super.onDetach()
|
super.onDetach()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onScrolledToEnd() = Unit
|
|
||||||
|
|
||||||
override fun onEmptyActionClick() {
|
override fun onEmptyActionClick() {
|
||||||
try {
|
ImportDialogFragment.show(childFragmentManager)
|
||||||
importCall.launch(arrayOf("*/*"))
|
|
||||||
} catch (e: ActivityNotFoundException) {
|
|
||||||
e.printStackTraceDebug()
|
|
||||||
Snackbar.make(
|
|
||||||
binding.recyclerView,
|
|
||||||
R.string.operation_not_supported,
|
|
||||||
Snackbar.LENGTH_SHORT,
|
|
||||||
).show()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onActivityResult(result: List<@JvmSuppressWildcards Uri>) {
|
override fun onScrolledToEnd() = Unit
|
||||||
if (result.isEmpty()) return
|
|
||||||
viewModel.importFiles(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
|
override fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
|
||||||
mode.menuInflater.inflate(R.menu.mode_local, menu)
|
mode.menuInflater.inflate(R.menu.mode_local, menu)
|
||||||
@@ -121,25 +94,6 @@ class LocalListFragment : MangaListFragment(), ActivityResultCallback<List<@JvmS
|
|||||||
Snackbar.make(binding.recyclerView, R.string.removal_completed, Snackbar.LENGTH_SHORT).show()
|
Snackbar.make(binding.recyclerView, R.string.removal_completed, Snackbar.LENGTH_SHORT).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,13 +1,10 @@
|
|||||||
package org.koitharu.kotatsu.local.ui
|
package org.koitharu.kotatsu.local.ui
|
||||||
|
|
||||||
import android.net.Uri
|
|
||||||
import androidx.lifecycle.MutableLiveData
|
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
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.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
@@ -27,7 +24,6 @@ import org.koitharu.kotatsu.parsers.model.Manga
|
|||||||
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.printStackTraceDebug
|
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
|
||||||
import org.koitharu.kotatsu.utils.progress.Progress
|
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class LocalListViewModel @Inject constructor(
|
class LocalListViewModel @Inject constructor(
|
||||||
@@ -37,10 +33,8 @@ class LocalListViewModel @Inject constructor(
|
|||||||
) : MangaListViewModel(settings) {
|
) : MangaListViewModel(settings) {
|
||||||
|
|
||||||
val onMangaRemoved = SingleLiveEvent<Unit>()
|
val onMangaRemoved = SingleLiveEvent<Unit>()
|
||||||
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 var importJob: Job? = null
|
|
||||||
|
|
||||||
override val content = combine(
|
override val content = combine(
|
||||||
mangaList,
|
mangaList,
|
||||||
@@ -75,20 +69,6 @@ class LocalListViewModel @Inject constructor(
|
|||||||
|
|
||||||
override fun onRetry() = onRefresh()
|
override fun onRetry() = onRefresh()
|
||||||
|
|
||||||
fun importFiles(uris: List<Uri>) {
|
|
||||||
val previousJob = importJob
|
|
||||||
importJob = launchJob(Dispatchers.Default) {
|
|
||||||
previousJob?.join()
|
|
||||||
importProgress.postValue(Progress(0, uris.size))
|
|
||||||
for ((i, uri) in uris.withIndex()) {
|
|
||||||
repository.import(uri)
|
|
||||||
importProgress.postValue(Progress(i + 1, uris.size))
|
|
||||||
doRefresh()
|
|
||||||
}
|
|
||||||
importProgress.postValue(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun delete(ids: Set<Long>) {
|
fun delete(ids: Set<Long>) {
|
||||||
launchLoadingJob {
|
launchLoadingJob {
|
||||||
withContext(Dispatchers.Default) {
|
withContext(Dispatchers.Default) {
|
||||||
@@ -118,7 +98,7 @@ class LocalListViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun cleanup() {
|
private fun cleanup() {
|
||||||
if (!DownloadService.isRunning) {
|
if (!DownloadService.isRunning && !ImportService.isRunning && !LocalChaptersRemoveService.isRunning) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
runCatching {
|
runCatching {
|
||||||
repository.cleanup()
|
repository.cleanup()
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ inline fun <T> Set(size: Int, init: (index: Int) -> T): Set<T> = when (size) {
|
|||||||
else -> MutableSet(size, init)
|
else -> MutableSet(size, init)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun <T> List<T>.asArrayList(): ArrayList<T> = if (this is ArrayList<*>) {
|
fun <T> Collection<T>.asArrayList(): ArrayList<T> = if (this is ArrayList<*>) {
|
||||||
this as ArrayList<T>
|
this as ArrayList<T>
|
||||||
} else {
|
} else {
|
||||||
ArrayList(this)
|
ArrayList(this)
|
||||||
@@ -38,4 +38,4 @@ fun <K, V> Map<K, V>.findKeyByValue(value: V): K? {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
package org.koitharu.kotatsu.utils.ext
|
package org.koitharu.kotatsu.utils.ext
|
||||||
|
|
||||||
inline fun Int.ifZero(defaultValue: () -> Int): Int = if (this == 0) defaultValue() else this
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,5 +6,5 @@
|
|||||||
android:viewportHeight="24">
|
android:viewportHeight="24">
|
||||||
<path
|
<path
|
||||||
android:fillColor="#FFFFFF"
|
android:fillColor="#FFFFFF"
|
||||||
android:pathData="M4,6H2V20A2,2 0 0,0 4,22H18V20H4V6M20,2H8A2,2 0 0,0 6,4V16A2,2 0 0,0 8,18H20A2,2 0 0,0 22,16V4A2,2 0 0,0 20,2M20,12L17.5,10.5L15,12V4H20V12Z" />
|
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" />
|
||||||
</vector>
|
</vector>
|
||||||
|
|||||||
15
app/src/main/res/drawable-anydpi-v24/ic_stat_done.xml
Normal file
15
app/src/main/res/drawable-anydpi-v24/ic_stat_done.xml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="#FFFFFF">
|
||||||
|
<group android:scaleX="0.92"
|
||||||
|
android:scaleY="0.92"
|
||||||
|
android:translateX="0.96"
|
||||||
|
android:translateY="0.96">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M21,5L9,17L3.5,11.5L4.91,10.09L9,14.17L19.59,3.59L21,5M3,21V19H21V21H3Z"/>
|
||||||
|
</group>
|
||||||
|
</vector>
|
||||||
BIN
app/src/main/res/drawable-hdpi/ic_stat_done.png
Normal file
BIN
app/src/main/res/drawable-hdpi/ic_stat_done.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 240 B |
BIN
app/src/main/res/drawable-mdpi/ic_stat_done.png
Normal file
BIN
app/src/main/res/drawable-mdpi/ic_stat_done.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 208 B |
BIN
app/src/main/res/drawable-xhdpi/ic_stat_done.png
Normal file
BIN
app/src/main/res/drawable-xhdpi/ic_stat_done.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 285 B |
BIN
app/src/main/res/drawable-xxhdpi/ic_stat_done.png
Normal file
BIN
app/src/main/res/drawable-xxhdpi/ic_stat_done.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 487 B |
14
app/src/main/res/drawable/ic_file_zip.xml
Normal file
14
app/src/main/res/drawable/ic_file_zip.xml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<vector
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:tint="?attr/colorControlNormal"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M14,2H6A2,2 0,0 0,4 4v16a2,2 0,0 0,2 2h12a2,2 0,0 0,2 -2V8L14,2m4,18H6V4h7v5h5z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="m16,16v-2h-2v2h2m-4,-2h2V12h-2v2m4,6v-2h-2v2h2m-4,-2h2V16h-2v2" />
|
||||||
|
</vector>
|
||||||
12
app/src/main/res/drawable/ic_folder_file.xml
Normal file
12
app/src/main/res/drawable/ic_folder_file.xml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:tint="?attr/colorControlNormal"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#000"
|
||||||
|
android:pathData="M4 18H11V20H4C2.9 20 2 19.11 2 18V6C2 4.89 2.89 4 4 4H10L12 6H20C21.1 6 22 6.89 22 8V10.17L20.41 8.59L20 8.17V8H4V18M23 14V21C23 22.11 22.11 23 21 23H15C13.9 23 13 22.11 13 21V12C13 10.9 13.9 10 15 10H19L23 14M21 15H18V12H15V21H21V15Z" />
|
||||||
|
</vector>
|
||||||
32
app/src/main/res/layout/dialog_import.xml
Normal file
32
app/src/main/res/layout/dialog_import.xml
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:paddingHorizontal="@dimen/grid_spacing_outer"
|
||||||
|
android:paddingTop="@dimen/margin_normal">
|
||||||
|
|
||||||
|
<org.koitharu.kotatsu.base.ui.widgets.ListItemTextView
|
||||||
|
android:id="@+id/button_file"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="?android:listPreferredItemHeightSmall"
|
||||||
|
android:drawableStart="@drawable/ic_file_zip"
|
||||||
|
android:drawablePadding="?android:listPreferredItemPaddingStart"
|
||||||
|
android:paddingStart="?android:listPreferredItemPaddingStart"
|
||||||
|
android:paddingEnd="?android:listPreferredItemPaddingEnd"
|
||||||
|
android:text="@string/comics_archive"
|
||||||
|
android:textAppearance="?attr/textAppearanceButton" />
|
||||||
|
|
||||||
|
<org.koitharu.kotatsu.base.ui.widgets.ListItemTextView
|
||||||
|
android:id="@+id/button_dir"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="?android:listPreferredItemHeightSmall"
|
||||||
|
android:drawableStart="@drawable/ic_folder_file"
|
||||||
|
android:drawablePadding="?android:listPreferredItemPaddingStart"
|
||||||
|
android:paddingStart="?android:listPreferredItemPaddingStart"
|
||||||
|
android:paddingEnd="?android:listPreferredItemPaddingEnd"
|
||||||
|
android:text="@string/folder_with_images"
|
||||||
|
android:textAppearance="?attr/textAppearanceButton" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
@@ -3,6 +3,11 @@
|
|||||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||||
|
|
||||||
|
<item
|
||||||
|
android:id="@+id/action_import"
|
||||||
|
android:orderInCategory="50"
|
||||||
|
android:title="@string/_import"
|
||||||
|
app:showAsAction="never" />
|
||||||
|
|
||||||
<item
|
<item
|
||||||
android:id="@+id/action_categories"
|
android:id="@+id/action_categories"
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<string name="url_weblate" translatable="false">https://hosted.weblate.org/engage/kotatsu</string>
|
<string name="url_weblate" translatable="false">https://hosted.weblate.org/engage/kotatsu</string>
|
||||||
<string name="email_error_report" translatable="false">kotatsu@waifu.club</string>
|
<string name="email_error_report" translatable="false">kotatsu@waifu.club</string>
|
||||||
<string name="account_type_sync" translatable="false">org.kotatsu.sync</string>
|
<string name="account_type_sync" translatable="false">org.kotatsu.sync</string>
|
||||||
<string name="url_sync_server" translatable="false">http://95.216.215.49:8055</string>
|
<string name="url_sync_server" translatable="false">http://86.57.183.214:8081</string>
|
||||||
<string-array name="values_theme" translatable="false">
|
<string-array name="values_theme" translatable="false">
|
||||||
<item>-1</item>
|
<item>-1</item>
|
||||||
<item>1</item>
|
<item>1</item>
|
||||||
|
|||||||
@@ -368,4 +368,10 @@
|
|||||||
<string name="seconds_pattern">%ss</string>
|
<string name="seconds_pattern">%ss</string>
|
||||||
<string name="reader_info_pattern">Ch. %1$d/%2$d Pg. %3$d/%4$d</string>
|
<string name="reader_info_pattern">Ch. %1$d/%2$d Pg. %3$d/%4$d</string>
|
||||||
<string name="reader_info_bar">Show information bar in reader</string>
|
<string name="reader_info_bar">Show information bar in reader</string>
|
||||||
|
<string name="comics_archive">Comics archive</string>
|
||||||
|
<string name="folder_with_images">Folder with images</string>
|
||||||
|
<string name="importing_manga">Importing manga</string>
|
||||||
|
<string name="import_completed">Import completed</string>
|
||||||
|
<string name="import_completed_hint">You can delete the original file from storage to save space</string>
|
||||||
|
<string name="import_will_start_soon">Import will start soon</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
Reference in New Issue
Block a user