Rewrite manga importer #31
parent
4744a0a162
commit
0e4575356a
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,32 +1,35 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
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"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:paddingHorizontal="@dimen/grid_spacing_outer"
|
android:paddingHorizontal="@dimen/grid_spacing_outer"
|
||||||
android:paddingTop="@dimen/margin_normal">
|
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:id="@+id/button_file"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="?android:listPreferredItemHeightSmall"
|
android:layout_height="wrap_content"
|
||||||
android:drawableStart="@drawable/ic_file_zip"
|
|
||||||
android:drawablePadding="?android:listPreferredItemPaddingStart"
|
android:drawablePadding="?android:listPreferredItemPaddingStart"
|
||||||
|
android:minHeight="?android:listPreferredItemHeightSmall"
|
||||||
android:paddingStart="?android:listPreferredItemPaddingStart"
|
android:paddingStart="?android:listPreferredItemPaddingStart"
|
||||||
android:paddingEnd="?android:listPreferredItemPaddingEnd"
|
android:paddingEnd="?android:listPreferredItemPaddingEnd"
|
||||||
android:text="@string/comics_archive"
|
app:icon="@drawable/ic_file_zip"
|
||||||
android:textAppearance="?attr/textAppearanceButton" />
|
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:id="@+id/button_dir"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="?android:listPreferredItemHeightSmall"
|
android:layout_height="wrap_content"
|
||||||
android:drawableStart="@drawable/ic_folder_file"
|
|
||||||
android:drawablePadding="?android:listPreferredItemPaddingStart"
|
android:drawablePadding="?android:listPreferredItemPaddingStart"
|
||||||
|
android:minHeight="?android:listPreferredItemHeightSmall"
|
||||||
android:paddingStart="?android:listPreferredItemPaddingStart"
|
android:paddingStart="?android:listPreferredItemPaddingStart"
|
||||||
android:paddingEnd="?android:listPreferredItemPaddingEnd"
|
android:paddingEnd="?android:listPreferredItemPaddingEnd"
|
||||||
android:text="@string/folder_with_images"
|
app:icon="@drawable/ic_folder_file"
|
||||||
android:textAppearance="?attr/textAppearanceButton" />
|
app:subtitle="@string/folder_with_images_import_description"
|
||||||
|
app:title="@string/folder_with_images" />
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|||||||
@ -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>
|
||||||
Loading…
Reference in New Issue