Rewrite manga importer #31
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
package org.koitharu.kotatsu.base.ui.widgets
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.res.ColorStateList
|
||||
import android.content.res.TypedArray
|
||||
import android.graphics.Color
|
||||
import android.graphics.drawable.InsetDrawable
|
||||
import android.graphics.drawable.RippleDrawable
|
||||
import android.graphics.drawable.ShapeDrawable
|
||||
import android.graphics.drawable.shapes.RoundRectShape
|
||||
import android.util.AttributeSet
|
||||
import android.view.LayoutInflater
|
||||
import android.widget.LinearLayout
|
||||
import androidx.annotation.AttrRes
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.withStyledAttributes
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import androidx.core.widget.ImageViewCompat
|
||||
import androidx.core.widget.TextViewCompat
|
||||
import com.google.android.material.ripple.RippleUtils
|
||||
import com.google.android.material.shape.MaterialShapeDrawable
|
||||
import com.google.android.material.shape.ShapeAppearanceModel
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.databinding.ViewTwoLinesItemBinding
|
||||
import org.koitharu.kotatsu.utils.ext.resolveDp
|
||||
|
||||
@SuppressLint("RestrictedApi")
|
||||
class TwoLinesItemView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
@AttrRes defStyleAttr: Int = 0,
|
||||
) : LinearLayout(context, attrs, defStyleAttr) {
|
||||
|
||||
private val binding = ViewTwoLinesItemBinding.inflate(LayoutInflater.from(context), this)
|
||||
|
||||
init {
|
||||
var textColors: ColorStateList? = null
|
||||
context.withStyledAttributes(
|
||||
set = attrs,
|
||||
attrs = R.styleable.TwoLinesItemView,
|
||||
defStyleAttr = defStyleAttr,
|
||||
defStyleRes = R.style.Widget_Kotatsu_TwoLinesItemView,
|
||||
) {
|
||||
val itemRippleColor = getRippleColor(context)
|
||||
val shape = createShapeDrawable(this)
|
||||
val roundCorners = FloatArray(8) { resources.resolveDp(16f) }
|
||||
background = RippleDrawable(
|
||||
RippleUtils.sanitizeRippleDrawableColor(itemRippleColor),
|
||||
shape,
|
||||
ShapeDrawable(RoundRectShape(roundCorners, null, null)),
|
||||
)
|
||||
val drawablePadding = getDimensionPixelOffset(R.styleable.TwoLinesItemView_android_drawablePadding, 0)
|
||||
binding.layoutText.updateLayoutParams<MarginLayoutParams> { marginStart = drawablePadding }
|
||||
setIconResource(getResourceId(R.styleable.TwoLinesItemView_icon, 0))
|
||||
binding.title.text = getText(R.styleable.TwoLinesItemView_title)
|
||||
binding.subtitle.text = getText(R.styleable.TwoLinesItemView_subtitle)
|
||||
textColors = getColorStateList(R.styleable.TwoLinesItemView_android_textColor)
|
||||
val textAppearanceFallback = androidx.appcompat.R.style.TextAppearance_AppCompat
|
||||
TextViewCompat.setTextAppearance(
|
||||
binding.title,
|
||||
getResourceId(R.styleable.TwoLinesItemView_titleTextAppearance, textAppearanceFallback),
|
||||
)
|
||||
TextViewCompat.setTextAppearance(
|
||||
binding.subtitle,
|
||||
getResourceId(R.styleable.TwoLinesItemView_subtitleTextAppearance, textAppearanceFallback),
|
||||
)
|
||||
}
|
||||
if (textColors == null) {
|
||||
textColors = binding.title.textColors
|
||||
}
|
||||
binding.title.setTextColor(textColors)
|
||||
binding.subtitle.setTextColor(textColors)
|
||||
ImageViewCompat.setImageTintList(binding.icon, textColors)
|
||||
}
|
||||
|
||||
fun setIconResource(@DrawableRes resId: Int) {
|
||||
val icon = if (resId != 0) ContextCompat.getDrawable(context, resId) else null
|
||||
binding.icon.setImageDrawable(icon)
|
||||
}
|
||||
|
||||
private fun createShapeDrawable(ta: TypedArray): InsetDrawable {
|
||||
val shapeAppearance = ShapeAppearanceModel.builder(
|
||||
context,
|
||||
ta.getResourceId(R.styleable.TwoLinesItemView_shapeAppearance, 0),
|
||||
ta.getResourceId(R.styleable.TwoLinesItemView_shapeAppearanceOverlay, 0),
|
||||
).build()
|
||||
val shapeDrawable = MaterialShapeDrawable(shapeAppearance)
|
||||
shapeDrawable.fillColor = ta.getColorStateList(R.styleable.TwoLinesItemView_backgroundFillColor)
|
||||
return InsetDrawable(
|
||||
shapeDrawable,
|
||||
ta.getDimensionPixelOffset(R.styleable.TwoLinesItemView_android_insetLeft, 0),
|
||||
ta.getDimensionPixelOffset(R.styleable.TwoLinesItemView_android_insetTop, 0),
|
||||
ta.getDimensionPixelOffset(R.styleable.TwoLinesItemView_android_insetRight, 0),
|
||||
ta.getDimensionPixelOffset(R.styleable.TwoLinesItemView_android_insetBottom, 0),
|
||||
)
|
||||
}
|
||||
|
||||
private fun getRippleColor(context: Context): ColorStateList {
|
||||
return ContextCompat.getColorStateList(context, R.color.selector_overlay)
|
||||
?: ColorStateList.valueOf(Color.TRANSPARENT)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package org.koitharu.kotatsu.local.data.importer
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import dagger.Reusable
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException
|
||||
import org.koitharu.kotatsu.local.data.CbzFilter
|
||||
import org.koitharu.kotatsu.local.data.LocalManga
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
||||
import org.koitharu.kotatsu.local.data.input.LocalMangaInput
|
||||
import org.koitharu.kotatsu.utils.ext.copyToSuspending
|
||||
import org.koitharu.kotatsu.utils.ext.resolveName
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
|
||||
@Reusable
|
||||
class SingleMangaImporter @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val storageManager: LocalStorageManager,
|
||||
) {
|
||||
|
||||
private val contentResolver = context.contentResolver
|
||||
|
||||
suspend fun import(uri: Uri, progressState: MutableStateFlow<Float>?): LocalManga {
|
||||
return if (isDirectory(uri)) {
|
||||
importDirectory(uri, progressState)
|
||||
} else {
|
||||
importFile(uri, progressState)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun importFile(uri: Uri, progressState: MutableStateFlow<Float>?): LocalManga {
|
||||
val contentResolver = storageManager.contentResolver
|
||||
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.copyToSuspending(output, progressState = progressState)
|
||||
}
|
||||
} ?: throw IOException("Cannot open input stream: $uri")
|
||||
return LocalMangaInput.of(dest).getManga()
|
||||
}
|
||||
|
||||
private suspend fun importDirectory(uri: Uri, progressState: MutableStateFlow<Float>?): LocalManga {
|
||||
val root = requireNotNull(DocumentFile.fromTreeUri(context, uri)) {
|
||||
"Provided uri $uri is not a tree"
|
||||
}
|
||||
val dest = File(getOutputDir(), root.requireName())
|
||||
dest.mkdir()
|
||||
for (docFile in root.listFiles()) {
|
||||
docFile.copyTo(dest)
|
||||
}
|
||||
return LocalMangaInput.of(dest).getManga()
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO: progress
|
||||
*/
|
||||
private suspend fun DocumentFile.copyTo(destDir: File) {
|
||||
if (isDirectory) {
|
||||
val subDir = File(destDir, requireName())
|
||||
subDir.mkdir()
|
||||
for (docFile in listFiles()) {
|
||||
docFile.copyTo(subDir)
|
||||
}
|
||||
} else {
|
||||
inputStream().use { input ->
|
||||
File(destDir, requireName()).outputStream().use { output ->
|
||||
input.copyToSuspending(output)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getOutputDir(): File {
|
||||
return storageManager.getDefaultWriteableDir() ?: throw IOException("External files dir unavailable")
|
||||
}
|
||||
|
||||
private suspend fun DocumentFile.inputStream() = runInterruptible(Dispatchers.IO) {
|
||||
contentResolver.openInputStream(uri) ?: throw IOException("Cannot open input stream: $uri")
|
||||
}
|
||||
|
||||
private fun DocumentFile.requireName(): String {
|
||||
return name ?: throw IOException("Cannot fetch name from uri: $uri")
|
||||
}
|
||||
|
||||
private fun isDirectory(uri: Uri): Boolean {
|
||||
return runCatching {
|
||||
DocumentFile.fromTreeUri(context, uri)
|
||||
}.isSuccess
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ abstract class LocalMangaOutput(
|
||||
|
||||
abstract suspend fun cleanup()
|
||||
|
||||
// TODO remove
|
||||
abstract fun sortChaptersByName()
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
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 kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koitharu.kotatsu.local.data.LocalManga
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
||||
import org.koitharu.kotatsu.local.data.input.LocalMangaInput
|
||||
import org.koitharu.kotatsu.local.data.output.LocalMangaOutput
|
||||
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.AlphanumComparator
|
||||
import org.koitharu.kotatsu.utils.ext.copyToSuspending
|
||||
import org.koitharu.kotatsu.utils.ext.deleteAwait
|
||||
import org.koitharu.kotatsu.utils.ext.longOf
|
||||
import java.io.File
|
||||
|
||||
// TODO: Add support for chapters in cbz
|
||||
// https://github.com/KotatsuApp/Kotatsu/issues/31
|
||||
class DirMangaImporter(
|
||||
private val context: Context,
|
||||
storageManager: LocalStorageManager,
|
||||
) : MangaImporter(storageManager) {
|
||||
|
||||
private val contentResolver = context.contentResolver
|
||||
|
||||
override suspend fun import(uri: Uri): LocalManga {
|
||||
val root = requireNotNull(DocumentFile.fromTreeUri(context, uri)) {
|
||||
"Provided uri $uri is not a tree"
|
||||
}
|
||||
val manga = Manga(root)
|
||||
val output = LocalMangaOutput.getOrCreate(getOutputDir(), manga)
|
||||
try {
|
||||
val dest = output.use {
|
||||
addPages(
|
||||
output = it,
|
||||
root = root,
|
||||
path = "",
|
||||
state = State(uri.hashCode(), 0, false),
|
||||
)
|
||||
it.sortChaptersByName()
|
||||
it.mergeWithExisting()
|
||||
it.finish()
|
||||
it.rootFile
|
||||
}
|
||||
return LocalMangaInput.of(dest).getManga()
|
||||
} finally {
|
||||
withContext(NonCancellable) {
|
||||
output.cleanup()
|
||||
File(getOutputDir(), "page.tmp").deleteAwait()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun addPages(output: LocalMangaOutput, root: DocumentFile, path: String, state: State) {
|
||||
var number = 0
|
||||
for (file in root.listFiles().sortedWith(compareBy(AlphanumComparator()) { it.name.orEmpty() })) {
|
||||
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.copyToSuspending(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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
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 org.koitharu.kotatsu.local.data.LocalManga
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
|
||||
abstract class MangaImporter(
|
||||
protected val storageManager: LocalStorageManager,
|
||||
) {
|
||||
|
||||
abstract suspend fun import(uri: Uri): LocalManga
|
||||
|
||||
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,
|
||||
) {
|
||||
|
||||
fun create(uri: Uri): MangaImporter {
|
||||
return when {
|
||||
isDir(uri) -> DirMangaImporter(context, storageManager)
|
||||
else -> ZipMangaImporter(storageManager)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isDir(uri: Uri): Boolean {
|
||||
return runCatching {
|
||||
DocumentFile.fromTreeUri(context, uri)
|
||||
}.isSuccess
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
package org.koitharu.kotatsu.local.domain.importer
|
||||
|
||||
import android.net.Uri
|
||||
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.LocalManga
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
||||
import org.koitharu.kotatsu.local.data.input.LocalMangaInput
|
||||
import org.koitharu.kotatsu.utils.ext.copyToSuspending
|
||||
import org.koitharu.kotatsu.utils.ext.resolveName
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
|
||||
class ZipMangaImporter(
|
||||
storageManager: LocalStorageManager,
|
||||
) : MangaImporter(storageManager) {
|
||||
|
||||
override suspend fun import(uri: Uri): LocalManga {
|
||||
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.copyToSuspending(output)
|
||||
}
|
||||
} ?: throw IOException("Cannot open input stream: $uri")
|
||||
LocalMangaInput.of(dest).getManga()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,9 @@ import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Toast
|
||||
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
|
||||
@@ -15,7 +15,6 @@ 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)
|
||||
}
|
||||
@@ -48,7 +47,12 @@ class ImportDialogFragment : AlertDialogFragment<DialogImportBinding>(), View.On
|
||||
}
|
||||
|
||||
private fun startImport(uris: Collection<Uri>) {
|
||||
ImportService.start(requireContext(), uris)
|
||||
if (uris.isEmpty()) {
|
||||
return
|
||||
}
|
||||
val ctx = requireContext()
|
||||
ImportWorker.start(ctx, uris)
|
||||
Toast.makeText(ctx, R.string.import_will_start_soon, Toast.LENGTH_LONG).show()
|
||||
dismiss()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,184 +0,0 @@
|
||||
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.asArrayList
|
||||
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
|
||||
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.utils.ext.report
|
||||
import org.koitharu.kotatsu.utils.ext.toBitmapOrNull
|
||||
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(startId: Int, 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)
|
||||
}
|
||||
|
||||
override fun onError(startId: Int, error: Throwable) {
|
||||
error.report()
|
||||
}
|
||||
|
||||
private suspend fun importImpl(uri: Uri): Manga {
|
||||
val importer = importerFactory.create(uri)
|
||||
return importer.import(uri).manga
|
||||
}
|
||||
|
||||
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)
|
||||
.tag(manga.source)
|
||||
.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()
|
||||
}
|
||||
}
|
||||
}
|
||||
149
app/src/main/java/org/koitharu/kotatsu/local/ui/ImportWorker.kt
Normal file
149
app/src/main/java/org/koitharu/kotatsu/local/ui/ImportWorker.kt
Normal file
@@ -0,0 +1,149 @@
|
||||
package org.koitharu.kotatsu.local.ui
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.hilt.work.HiltWorker
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.Data
|
||||
import androidx.work.ForegroundInfo
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.OutOfQuotaPolicy
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import coil.ImageLoader
|
||||
import coil.request.ImageRequest
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.details.ui.DetailsActivity
|
||||
import org.koitharu.kotatsu.local.data.importer.SingleMangaImporter
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.koitharu.kotatsu.utils.PendingIntentCompat
|
||||
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
|
||||
import org.koitharu.kotatsu.utils.ext.toBitmapOrNull
|
||||
import org.koitharu.kotatsu.utils.ext.toUriOrNull
|
||||
|
||||
@HiltWorker
|
||||
class ImportWorker @AssistedInject constructor(
|
||||
@Assisted appContext: Context,
|
||||
@Assisted params: WorkerParameters,
|
||||
private val importer: SingleMangaImporter,
|
||||
private val coil: ImageLoader
|
||||
) : CoroutineWorker(appContext, params) {
|
||||
|
||||
private val notificationManager by lazy {
|
||||
applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
}
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
val uri = inputData.getString(DATA_URI)?.toUriOrNull() ?: return Result.failure()
|
||||
setForeground(getForegroundInfo())
|
||||
val result = runCatchingCancellable {
|
||||
importer.import(uri, null).manga
|
||||
}
|
||||
val notification = buildNotification(result)
|
||||
notificationManager.notify(uri.hashCode(), notification)
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
override suspend fun getForegroundInfo(): ForegroundInfo {
|
||||
val title = applicationContext.getString(R.string.importing_manga)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(CHANNEL_ID, title, NotificationManager.IMPORTANCE_LOW)
|
||||
channel.setShowBadge(false)
|
||||
channel.enableVibration(false)
|
||||
channel.setSound(null, null)
|
||||
channel.enableLights(false)
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
||||
.setContentTitle(title)
|
||||
.setPriority(NotificationCompat.PRIORITY_MIN)
|
||||
.setDefaults(0)
|
||||
.setColor(ContextCompat.getColor(applicationContext, 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()
|
||||
|
||||
return ForegroundInfo(FOREGROUND_NOTIFICATION_ID, notification)
|
||||
}
|
||||
|
||||
private suspend fun buildNotification(result: kotlin.Result<Manga>): Notification {
|
||||
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
.setDefaults(0)
|
||||
.setColor(ContextCompat.getColor(applicationContext, R.color.blue_primary_dark))
|
||||
.setSilent(true)
|
||||
result.onSuccess { manga ->
|
||||
notification.setLargeIcon(
|
||||
coil.execute(
|
||||
ImageRequest.Builder(applicationContext)
|
||||
.data(manga.coverUrl)
|
||||
.tag(manga.source)
|
||||
.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,
|
||||
)
|
||||
notification.setContentTitle(applicationContext.getString(R.string.import_completed))
|
||||
.setContentText(applicationContext.getString(R.string.import_completed_hint))
|
||||
.setSmallIcon(R.drawable.ic_stat_done)
|
||||
NotificationCompat.BigTextStyle(notification)
|
||||
.bigText(applicationContext.getString(R.string.import_completed_hint))
|
||||
}.onFailure { error ->
|
||||
notification.setContentTitle(applicationContext.getString(R.string.error_occurred))
|
||||
.setContentText(error.getDisplayMessage(applicationContext.resources))
|
||||
.setSmallIcon(android.R.drawable.stat_notify_error)
|
||||
}
|
||||
return notification.build()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
const val DATA_URI = "uri"
|
||||
|
||||
private const val TAG = "import"
|
||||
private const val CHANNEL_ID = "importing"
|
||||
private const val FOREGROUND_NOTIFICATION_ID = 37
|
||||
|
||||
fun start(context: Context, uris: Iterable<Uri>) {
|
||||
val constraints = Constraints.Builder()
|
||||
.setRequiresStorageNotLow(true)
|
||||
.build()
|
||||
val requests = uris.map { uri ->
|
||||
OneTimeWorkRequestBuilder<ImportWorker>()
|
||||
.setConstraints(constraints)
|
||||
.addTag(TAG)
|
||||
.setInputData(Data.Builder().putString(DATA_URI, uri.toString()).build())
|
||||
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
|
||||
.build()
|
||||
}
|
||||
WorkManager.getInstance(context)
|
||||
.enqueue(requests)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -141,7 +141,7 @@ class LocalListViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
private fun cleanup() {
|
||||
if (!DownloadService.isRunning && !ImportService.isRunning && !LocalChaptersRemoveService.isRunning) {
|
||||
if (!DownloadService.isRunning && !LocalChaptersRemoveService.isRunning) {
|
||||
viewModelScope.launch {
|
||||
runCatchingCancellable {
|
||||
repository.cleanup()
|
||||
|
||||
@@ -13,9 +13,11 @@ import java.io.OutputStream
|
||||
|
||||
suspend fun InputStream.copyToSuspending(
|
||||
out: OutputStream,
|
||||
bufferSize: Int = DEFAULT_BUFFER_SIZE
|
||||
bufferSize: Int = DEFAULT_BUFFER_SIZE,
|
||||
progressState: MutableStateFlow<Float>? = null,
|
||||
): Long = withContext(Dispatchers.IO) {
|
||||
val job = currentCoroutineContext()[Job]
|
||||
val total = available()
|
||||
var bytesCopied: Long = 0
|
||||
val buffer = ByteArray(bufferSize)
|
||||
var bytes = read(buffer)
|
||||
@@ -25,6 +27,9 @@ suspend fun InputStream.copyToSuspending(
|
||||
job?.ensureActive()
|
||||
bytes = read(buffer)
|
||||
job?.ensureActive()
|
||||
if (progressState != null && total > 0) {
|
||||
progressState.value = bytesCopied / total.toFloat()
|
||||
}
|
||||
}
|
||||
bytesCopied
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.utils.progress
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Deprecated("Should be replaced with Float")
|
||||
@Parcelize
|
||||
data class Progress(
|
||||
val value: Int,
|
||||
@@ -21,4 +22,4 @@ data class Progress(
|
||||
get() = total <= 0
|
||||
|
||||
private fun part() = if (isIndeterminate) -1.0 else value / total.toDouble()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,32 +1,35 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
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
|
||||
<org.koitharu.kotatsu.base.ui.widgets.TwoLinesItemView
|
||||
android:id="@+id/button_file"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?android:listPreferredItemHeightSmall"
|
||||
android:drawableStart="@drawable/ic_file_zip"
|
||||
android:layout_height="wrap_content"
|
||||
android:drawablePadding="?android:listPreferredItemPaddingStart"
|
||||
android:minHeight="?android:listPreferredItemHeightSmall"
|
||||
android:paddingStart="?android:listPreferredItemPaddingStart"
|
||||
android:paddingEnd="?android:listPreferredItemPaddingEnd"
|
||||
android:text="@string/comics_archive"
|
||||
android:textAppearance="?attr/textAppearanceButton" />
|
||||
app:icon="@drawable/ic_file_zip"
|
||||
app:subtitle="@string/comics_archive_import_description"
|
||||
app:title="@string/comics_archive" />
|
||||
|
||||
<org.koitharu.kotatsu.base.ui.widgets.ListItemTextView
|
||||
<org.koitharu.kotatsu.base.ui.widgets.TwoLinesItemView
|
||||
android:id="@+id/button_dir"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?android:listPreferredItemHeightSmall"
|
||||
android:drawableStart="@drawable/ic_folder_file"
|
||||
android:layout_height="wrap_content"
|
||||
android:drawablePadding="?android:listPreferredItemPaddingStart"
|
||||
android:minHeight="?android:listPreferredItemHeightSmall"
|
||||
android:paddingStart="?android:listPreferredItemPaddingStart"
|
||||
android:paddingEnd="?android:listPreferredItemPaddingEnd"
|
||||
android:text="@string/folder_with_images"
|
||||
android:textAppearance="?attr/textAppearanceButton" />
|
||||
app:icon="@drawable/ic_folder_file"
|
||||
app:subtitle="@string/folder_with_images_import_description"
|
||||
app:title="@string/folder_with_images" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
38
app/src/main/res/layout/view_two_lines_item.xml
Normal file
38
app/src/main/res/layout/view_two_lines_item.xml
Normal file
@@ -0,0 +1,38 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<merge
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
tools:layout_height="wrap_content"
|
||||
tools:orientation="horizontal"
|
||||
tools:parentTag="android.widget.LinearLayout">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/icon"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center_vertical"
|
||||
tools:src="@drawable/ic_folder_file" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/layout_text"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center_vertical"
|
||||
android:orientation="vertical"
|
||||
android:paddingVertical="6dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
tools:text="@tools:sample/cities" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/subtitle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="2dp"
|
||||
tools:text="@tools:sample/lorem[12]" />
|
||||
|
||||
</LinearLayout>
|
||||
</merge>
|
||||
@@ -37,6 +37,23 @@
|
||||
<attr name="android:insetRight" />
|
||||
</declare-styleable>
|
||||
|
||||
<declare-styleable name="TwoLinesItemView">
|
||||
<attr name="shapeAppearance" />
|
||||
<attr name="shapeAppearanceOverlay" />
|
||||
<attr name="backgroundFillColor" />
|
||||
<attr name="android:textColor" />
|
||||
<attr name="android:drawablePadding" />
|
||||
<attr name="android:insetTop" />
|
||||
<attr name="android:insetBottom" />
|
||||
<attr name="android:insetLeft" />
|
||||
<attr name="android:insetRight" />
|
||||
<attr name="title" />
|
||||
<attr name="subtitle" />
|
||||
<attr name="icon" />
|
||||
<attr name="titleTextAppearance" />
|
||||
<attr name="subtitleTextAppearance" />
|
||||
</declare-styleable>
|
||||
|
||||
<declare-styleable name="ProgressDrawable">
|
||||
<attr name="strokeWidth" />
|
||||
<attr name="android:strokeColor" />
|
||||
|
||||
@@ -278,7 +278,7 @@
|
||||
<string name="download_slowdown">Download slowdown</string>
|
||||
<string name="download_slowdown_summary">Helps avoid blocking your IP address</string>
|
||||
<string name="local_manga_processing">Saved manga processing</string>
|
||||
<string name="chapters_will_removed_background">Chapters will be removed in the background. It can take some time</string>
|
||||
<string name="chapters_will_removed_background">Chapters will be removed in the background</string>
|
||||
<string name="canceled">Canceled</string>
|
||||
<string name="account_already_exists">Account already exists</string>
|
||||
<string name="back">Back</string>
|
||||
@@ -428,4 +428,6 @@
|
||||
<string name="sources_reorder_tip">Tap and hold on an item to reorder them</string>
|
||||
<string name="user_agent">UserAgent header</string>
|
||||
<string name="settings_apply_restart_required">Please restart the application to apply these changes</string>
|
||||
<string name="comics_archive_import_description">You can select one or more .cbz or .zip files, each file will be recognized as a separate manga.</string>
|
||||
<string name="folder_with_images_import_description">You can select a directory with archives or images. Each archive (or subdirectory) will be recognized as a chapter.</string>
|
||||
</resources>
|
||||
|
||||
@@ -154,6 +154,19 @@
|
||||
<item name="android:insetBottom">2dp</item>
|
||||
</style>
|
||||
|
||||
<style name="Widget.Kotatsu.TwoLinesItemView" parent="">
|
||||
<item name="backgroundFillColor">@color/list_item_background_color</item>
|
||||
<item name="shapeAppearance">?attr/shapeAppearanceCornerLarge</item>
|
||||
<item name="android:insetRight">6dp</item>
|
||||
<item name="android:insetLeft">6dp</item>
|
||||
<item name="android:insetTop">2dp</item>
|
||||
<item name="android:insetBottom">2dp</item>
|
||||
<item name="android:orientation">horizontal</item>
|
||||
<item name="android:textColor">@color/list_item_text_color</item>
|
||||
<item name="titleTextAppearance">?attr/textAppearanceButton</item>
|
||||
<item name="subtitleTextAppearance">?attr/textAppearanceBodySmall</item>
|
||||
</style>
|
||||
|
||||
<style name="Widget.Kotatsu.ExploreButton" parent="Widget.Material3.Button.TonalButton.Icon">
|
||||
<item name="android:minHeight">58dp</item>
|
||||
<item name="android:textColor">?attr/colorOnSurface</item>
|
||||
|
||||
Reference in New Issue
Block a user