Option to import manga from directories #31

This commit is contained in:
Koitharu
2022-08-10 14:27:51 +03:00
parent 59a50e163f
commit 0077dc2f1c
33 changed files with 657 additions and 121 deletions

View File

@@ -137,6 +137,7 @@
android:name="org.koitharu.kotatsu.download.ui.service.DownloadService"
android:foregroundServiceType="dataSync" />
<service android:name="org.koitharu.kotatsu.local.ui.LocalChaptersRemoveService" />
<service android:name="org.koitharu.kotatsu.local.ui.ImportService" />
<service
android:name="org.koitharu.kotatsu.widget.shelf.ShelfWidgetService"
android:permission="android.permission.BIND_REMOTEVIEWS" />

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"
}
}
}
}

View File

@@ -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() }
}

View File

@@ -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<File>): Flow<File> {
if (files.isEmpty()) {
return emptyFlow()
}
return files.asFlow()
.flatMapMerge(files.size) { it.observe() }
}
@WorkerThread
private fun getConfiguredStorageDirs(): MutableSet<File> {
val set = getAvailableStorageDirs()

View File

@@ -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<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> {
val chapters = ArrayList<MangaChapter>(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()
}
}
}

View File

@@ -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(
}
}
}
}
}

View File

@@ -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<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? {
return storageManager.getDefaultWriteableDir()
}

View File

@@ -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,
)
}
}
}
}

View File

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

View File

@@ -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)
}
}
}

View File

@@ -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)
}
}

View 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()
}
}
}

View File

@@ -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<ParcelableManga>(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

View File

@@ -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<List<@JvmSuppressWildcards Uri>> {
class LocalListFragment : MangaListFragment() {
override val viewModel by viewModels<LocalListViewModel>()
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<List<@JvmS
super.onViewCreated(view, savedInstanceState)
addMenuProvider(LocalListMenuProvider(this::onEmptyActionClick))
viewModel.onMangaRemoved.observe(viewLifecycleOwner) { onItemRemoved() }
viewModel.importProgress.observe(viewLifecycleOwner, ::onImportProgressChanged)
}
override fun onDestroyView() {
importSnackbar = null
super.onDestroyView()
}
override fun onDetach() {
@@ -64,25 +51,11 @@ class LocalListFragment : MangaListFragment(), ActivityResultCallback<List<@JvmS
super.onDetach()
}
override fun onScrolledToEnd() = Unit
override fun onEmptyActionClick() {
try {
importCall.launch(arrayOf("*/*"))
} catch (e: ActivityNotFoundException) {
e.printStackTraceDebug()
Snackbar.make(
binding.recyclerView,
R.string.operation_not_supported,
Snackbar.LENGTH_SHORT,
).show()
}
ImportDialogFragment.show(childFragmentManager)
}
override fun onActivityResult(result: List<@JvmSuppressWildcards Uri>) {
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<List<@JvmS
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 {
fun newInstance() = LocalListFragment()

View File

@@ -1,13 +1,10 @@
package org.koitharu.kotatsu.local.ui
import android.net.Uri
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import java.io.IOException
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
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.ext.asLiveDataDistinct
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.progress.Progress
@HiltViewModel
class LocalListViewModel @Inject constructor(
@@ -37,10 +33,8 @@ class LocalListViewModel @Inject constructor(
) : MangaListViewModel(settings) {
val onMangaRemoved = SingleLiveEvent<Unit>()
val importProgress = MutableLiveData<Progress?>(null)
private val listError = MutableStateFlow<Throwable?>(null)
private val mangaList = MutableStateFlow<List<Manga>?>(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<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>) {
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()

View File

@@ -25,7 +25,7 @@ inline fun <T> Set(size: Int, init: (index: Int) -> T): Set<T> = when (size) {
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>
} else {
ArrayList(this)
@@ -38,4 +38,4 @@ fun <K, V> Map<K, V>.findKeyByValue(value: V): K? {
}
}
return null
}
}

View File

@@ -1,3 +1,7 @@
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)
}

View File

@@ -6,5 +6,5 @@
android:viewportHeight="24">
<path
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" />
</vector>
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>

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 285 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 487 B

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

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

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

View File

@@ -3,6 +3,11 @@
xmlns:android="http://schemas.android.com/apk/res/android"
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
android:id="@+id/action_categories"

View File

@@ -8,7 +8,7 @@
<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="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">
<item>-1</item>
<item>1</item>

View File

@@ -368,4 +368,10 @@
<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_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>