Merge branch 'release/5' into devel
commit
f32ff00b68
@ -1,5 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<bool name="leak_canary_add_launcher_icon" tools:node="replace">false</bool>
|
||||
<bool name="is_sync_enabled">true</bool>
|
||||
</resources>
|
||||
|
||||
@ -0,0 +1,6 @@
|
||||
package org.koitharu.kotatsu.base.ui.list
|
||||
|
||||
interface OnTipCloseListener<T> {
|
||||
|
||||
fun onCloseTip(tip: T)
|
||||
}
|
||||
@ -0,0 +1,13 @@
|
||||
package org.koitharu.kotatsu.base.ui.util
|
||||
|
||||
import android.text.Editable
|
||||
import android.text.TextWatcher
|
||||
|
||||
interface DefaultTextWatcher : TextWatcher {
|
||||
|
||||
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
|
||||
|
||||
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit
|
||||
|
||||
override fun afterTextChanged(s: Editable?) = Unit
|
||||
}
|
||||
@ -0,0 +1,30 @@
|
||||
package org.koitharu.kotatsu.base.ui.util
|
||||
|
||||
import android.view.View
|
||||
import androidx.lifecycle.Observer
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.base.domain.reverseAsync
|
||||
import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner
|
||||
import org.koitharu.kotatsu.utils.ext.findActivity
|
||||
|
||||
class ReversibleActionObserver(
|
||||
private val snackbarHost: View,
|
||||
) : Observer<ReversibleAction?> {
|
||||
|
||||
override fun onChanged(value: ReversibleAction?) {
|
||||
if (value == null) {
|
||||
return
|
||||
}
|
||||
val handle = value.handle
|
||||
val length = if (handle == null) Snackbar.LENGTH_SHORT else Snackbar.LENGTH_LONG
|
||||
val snackbar = Snackbar.make(snackbarHost, value.stringResId, length)
|
||||
if (handle != null) {
|
||||
snackbar.setAction(R.string.undo) { handle.reverseAsync() }
|
||||
}
|
||||
(snackbarHost.context.findActivity() as? BottomNavOwner)?.let {
|
||||
snackbar.anchorView = it.bottomNav
|
||||
}
|
||||
snackbar.show()
|
||||
}
|
||||
}
|
||||
@ -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,66 @@
|
||||
package org.koitharu.kotatsu.core.exceptions.resolve
|
||||
|
||||
import android.content.DialogInterface
|
||||
import android.view.View
|
||||
import androidx.core.util.Consumer
|
||||
import androidx.fragment.app.Fragment
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.ui.ErrorDetailsDialog
|
||||
import org.koitharu.kotatsu.parsers.exception.ParseException
|
||||
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
|
||||
|
||||
class DialogErrorObserver(
|
||||
host: View,
|
||||
fragment: Fragment?,
|
||||
resolver: ExceptionResolver?,
|
||||
private val onResolved: Consumer<Boolean>?,
|
||||
) : ErrorObserver(host, fragment, resolver, onResolved) {
|
||||
|
||||
constructor(
|
||||
host: View,
|
||||
fragment: Fragment?,
|
||||
) : this(host, fragment, null, null)
|
||||
|
||||
override fun onChanged(value: Throwable?) {
|
||||
if (value == null) {
|
||||
return
|
||||
}
|
||||
val listener = DialogListener(value)
|
||||
val dialogBuilder = MaterialAlertDialogBuilder(activity ?: host.context)
|
||||
.setMessage(value.getDisplayMessage(host.context.resources))
|
||||
.setNegativeButton(R.string.close, listener)
|
||||
.setOnCancelListener(listener)
|
||||
if (canResolve(value)) {
|
||||
dialogBuilder.setPositiveButton(ExceptionResolver.getResolveStringId(value), listener)
|
||||
} else if (value is ParseException) {
|
||||
val fm = fragmentManager
|
||||
if (fm != null) {
|
||||
dialogBuilder.setPositiveButton(R.string.details) { _, _ ->
|
||||
ErrorDetailsDialog.show(fm, value, value.url)
|
||||
}
|
||||
}
|
||||
}
|
||||
val dialog = dialogBuilder.create()
|
||||
if (activity != null) {
|
||||
dialog.setOwnerActivity(activity)
|
||||
}
|
||||
dialog.show()
|
||||
}
|
||||
|
||||
private inner class DialogListener(
|
||||
private val error: Throwable,
|
||||
) : DialogInterface.OnClickListener, DialogInterface.OnCancelListener {
|
||||
|
||||
override fun onClick(dialog: DialogInterface?, which: Int) {
|
||||
when (which) {
|
||||
DialogInterface.BUTTON_NEGATIVE -> onResolved?.accept(false)
|
||||
DialogInterface.BUTTON_POSITIVE -> resolve(error)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCancel(dialog: DialogInterface?) {
|
||||
onResolved?.accept(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,44 @@
|
||||
package org.koitharu.kotatsu.core.exceptions.resolve
|
||||
|
||||
import android.view.View
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.util.Consumer
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.lifecycle.LifecycleCoroutineScope
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.Observer
|
||||
import androidx.lifecycle.coroutineScope
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koitharu.kotatsu.utils.ext.findActivity
|
||||
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
|
||||
|
||||
abstract class ErrorObserver(
|
||||
protected val host: View,
|
||||
protected val fragment: Fragment?,
|
||||
private val resolver: ExceptionResolver?,
|
||||
private val onResolved: Consumer<Boolean>?,
|
||||
) : Observer<Throwable?> {
|
||||
|
||||
protected val activity = host.context.findActivity()
|
||||
|
||||
private val lifecycleScope: LifecycleCoroutineScope
|
||||
get() = checkNotNull(fragment?.viewLifecycleScope ?: (activity as? LifecycleOwner)?.lifecycle?.coroutineScope)
|
||||
|
||||
protected val fragmentManager: FragmentManager?
|
||||
get() = fragment?.childFragmentManager ?: (activity as? AppCompatActivity)?.supportFragmentManager
|
||||
|
||||
protected fun canResolve(error: Throwable): Boolean {
|
||||
return resolver != null && ExceptionResolver.canResolve(error)
|
||||
}
|
||||
|
||||
protected fun resolve(error: Throwable) {
|
||||
lifecycleScope.launch {
|
||||
val isResolved = resolver?.resolve(error) ?: false
|
||||
if (isActive) {
|
||||
onResolved?.accept(isResolved)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,47 @@
|
||||
package org.koitharu.kotatsu.core.exceptions.resolve
|
||||
|
||||
import android.view.View
|
||||
import androidx.core.util.Consumer
|
||||
import androidx.fragment.app.Fragment
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.ui.ErrorDetailsDialog
|
||||
import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner
|
||||
import org.koitharu.kotatsu.parsers.exception.ParseException
|
||||
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
|
||||
|
||||
class SnackbarErrorObserver(
|
||||
host: View,
|
||||
fragment: Fragment?,
|
||||
resolver: ExceptionResolver?,
|
||||
onResolved: Consumer<Boolean>?,
|
||||
) : ErrorObserver(host, fragment, resolver, onResolved) {
|
||||
|
||||
constructor(
|
||||
host: View,
|
||||
fragment: Fragment?,
|
||||
) : this(host, fragment, null, null)
|
||||
|
||||
override fun onChanged(value: Throwable?) {
|
||||
if (value == null) {
|
||||
return
|
||||
}
|
||||
val snackbar = Snackbar.make(host, value.getDisplayMessage(host.context.resources), Snackbar.LENGTH_SHORT)
|
||||
if (activity is BottomNavOwner) {
|
||||
snackbar.anchorView = activity.bottomNav
|
||||
}
|
||||
if (canResolve(value)) {
|
||||
snackbar.setAction(ExceptionResolver.getResolveStringId(value)) {
|
||||
resolve(value)
|
||||
}
|
||||
} else if (value is ParseException) {
|
||||
val fm = fragmentManager
|
||||
if (fm != null) {
|
||||
snackbar.setAction(R.string.details) {
|
||||
ErrorDetailsDialog.show(fm, value, value.url)
|
||||
}
|
||||
}
|
||||
}
|
||||
snackbar.show()
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,88 @@
|
||||
package org.koitharu.kotatsu.core.network
|
||||
|
||||
import dagger.Lazy
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.internal.canParseAsIpAddress
|
||||
import okhttp3.internal.closeQuietly
|
||||
import okhttp3.internal.publicsuffix.PublicSuffixDatabase
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class MirrorSwitchInterceptor @Inject constructor(
|
||||
private val mangaRepositoryFactoryLazy: Lazy<MangaRepository.Factory>,
|
||||
) : Interceptor {
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
return try {
|
||||
val response = chain.proceed(request)
|
||||
if (response.isFailed) {
|
||||
val responseCopy = response.newBuilder().build()
|
||||
response.close()
|
||||
trySwitchMirror(request, chain) ?: responseCopy
|
||||
} else {
|
||||
response
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
trySwitchMirror(request, chain) ?: throw e
|
||||
}
|
||||
}
|
||||
|
||||
private fun trySwitchMirror(request: Request, chain: Interceptor.Chain): Response? {
|
||||
val source = request.tag(MangaSource::class.java) ?: return null
|
||||
val repository = mangaRepositoryFactoryLazy.get().create(source) as? RemoteMangaRepository ?: return null
|
||||
val mirrors = repository.getAvailableMirrors()
|
||||
if (mirrors.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
return tryMirrors(repository, mirrors, chain, request)
|
||||
}
|
||||
|
||||
private fun tryMirrors(
|
||||
repository: RemoteMangaRepository,
|
||||
mirrors: List<String>,
|
||||
chain: Interceptor.Chain,
|
||||
request: Request,
|
||||
): Response? {
|
||||
val url = request.url
|
||||
val currentDomain = url.topPrivateDomain()
|
||||
if (currentDomain !in mirrors) {
|
||||
return null
|
||||
}
|
||||
val urlBuilder = url.newBuilder()
|
||||
for (mirror in mirrors) {
|
||||
if (mirror == currentDomain) {
|
||||
continue
|
||||
}
|
||||
val newHost = hostOf(url.host, mirror) ?: continue
|
||||
val newRequest = request.newBuilder()
|
||||
.url(urlBuilder.host(newHost).build())
|
||||
.build()
|
||||
val response = chain.proceed(newRequest)
|
||||
if (response.isFailed) {
|
||||
response.closeQuietly()
|
||||
} else {
|
||||
repository.domain = mirror
|
||||
return response
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private val Response.isFailed: Boolean
|
||||
get() = code in 400..599
|
||||
|
||||
private fun hostOf(host: String, newDomain: String): String? {
|
||||
if (newDomain.canParseAsIpAddress()) {
|
||||
return newDomain
|
||||
}
|
||||
val domain = PublicSuffixDatabase.get().getEffectiveTldPlusOne(host) ?: return null
|
||||
return host.removeSuffix(domain) + newDomain
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,26 @@
|
||||
package org.koitharu.kotatsu.core.parser
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.ColorRes
|
||||
import dagger.Reusable
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
import javax.inject.Inject
|
||||
|
||||
@Reusable
|
||||
class MangaTagHighlighter @Inject constructor(
|
||||
@ApplicationContext context: Context,
|
||||
) {
|
||||
|
||||
private val dict = context.resources.getStringArray(R.array.genres_warnlist).toSet()
|
||||
|
||||
@ColorRes
|
||||
fun getTint(tag: MangaTag): Int {
|
||||
return if (tag.title.lowercase() in dict) {
|
||||
R.color.warning
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,27 +0,0 @@
|
||||
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() }
|
||||
}
|
||||
@ -0,0 +1,29 @@
|
||||
package org.koitharu.kotatsu.local.data
|
||||
|
||||
import java.io.File
|
||||
import java.io.FileFilter
|
||||
import java.io.FilenameFilter
|
||||
import java.util.Locale
|
||||
import java.util.zip.ZipEntry
|
||||
|
||||
class ImageFileFilter : FilenameFilter, FileFilter {
|
||||
|
||||
override fun accept(dir: File, name: String): Boolean {
|
||||
val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT)
|
||||
return isExtensionValid(ext)
|
||||
}
|
||||
|
||||
override fun accept(pathname: File?): Boolean {
|
||||
val ext = pathname?.extension?.lowercase(Locale.ROOT) ?: return false
|
||||
return isExtensionValid(ext)
|
||||
}
|
||||
|
||||
fun accept(entry: ZipEntry): Boolean {
|
||||
val ext = entry.name.substringAfterLast('.', "").lowercase(Locale.ROOT)
|
||||
return isExtensionValid(ext)
|
||||
}
|
||||
|
||||
private fun isExtensionValid(ext: String): Boolean {
|
||||
return ext == "png" || ext == "jpg" || ext == "jpeg" || ext == "webp"
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,144 @@
|
||||
package org.koitharu.kotatsu.local.data.input
|
||||
|
||||
import androidx.core.net.toFile
|
||||
import androidx.core.net.toUri
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import org.koitharu.kotatsu.local.data.CbzFilter
|
||||
import org.koitharu.kotatsu.local.data.ImageFileFilter
|
||||
import org.koitharu.kotatsu.local.data.LocalManga
|
||||
import org.koitharu.kotatsu.local.data.MangaIndex
|
||||
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.MangaPage
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.util.toCamelCase
|
||||
import org.koitharu.kotatsu.utils.AlphanumComparator
|
||||
import org.koitharu.kotatsu.utils.ext.listFilesRecursive
|
||||
import org.koitharu.kotatsu.utils.ext.longHashCode
|
||||
import org.koitharu.kotatsu.utils.ext.toListSorted
|
||||
import java.io.File
|
||||
import java.util.zip.ZipFile
|
||||
|
||||
/**
|
||||
* Manga {Folder}
|
||||
* |--- index.json (optional)
|
||||
* |--- Chapter 1.cbz
|
||||
* |--- Page 1.png
|
||||
* :
|
||||
* L--- Page x.png
|
||||
* |--- Chapter 2.cbz
|
||||
* :
|
||||
* L--- Chapter x.cbz
|
||||
*/
|
||||
class LocalMangaDirInput(root: File) : LocalMangaInput(root) {
|
||||
|
||||
override suspend fun getManga(): LocalManga = runInterruptible(Dispatchers.IO) {
|
||||
val index = MangaIndex.read(File(root, LocalMangaOutput.ENTRY_NAME_INDEX))
|
||||
val mangaUri = root.toUri().toString()
|
||||
val chapterFiles = getChaptersFiles()
|
||||
val info = index?.getMangaInfo()
|
||||
val manga = info?.copy2(
|
||||
source = MangaSource.LOCAL,
|
||||
url = mangaUri,
|
||||
coverUrl = fileUri(
|
||||
root,
|
||||
index.getCoverEntry() ?: findFirstImageEntry().orEmpty(),
|
||||
),
|
||||
chapters = info.chapters?.mapIndexed { i, c ->
|
||||
c.copy(url = chapterFiles[i].toUri().toString(), source = MangaSource.LOCAL)
|
||||
},
|
||||
) ?: Manga(
|
||||
id = root.absolutePath.longHashCode(),
|
||||
title = root.name.toHumanReadable(),
|
||||
url = mangaUri,
|
||||
publicUrl = mangaUri,
|
||||
source = MangaSource.LOCAL,
|
||||
coverUrl = findFirstImageEntry().orEmpty(),
|
||||
chapters = chapterFiles.mapIndexed { i, f ->
|
||||
MangaChapter(
|
||||
id = "$i${f.name}".longHashCode(),
|
||||
name = f.nameWithoutExtension.toHumanReadable(),
|
||||
number = i + 1,
|
||||
source = MangaSource.LOCAL,
|
||||
uploadDate = f.lastModified(),
|
||||
url = f.toUri().toString(),
|
||||
scanlator = null,
|
||||
branch = null,
|
||||
)
|
||||
},
|
||||
altTitle = null,
|
||||
rating = -1f,
|
||||
isNsfw = false,
|
||||
tags = setOf(),
|
||||
state = null,
|
||||
author = null,
|
||||
largeCoverUrl = null,
|
||||
description = null,
|
||||
)
|
||||
LocalManga(root, manga)
|
||||
}
|
||||
|
||||
override suspend fun getMangaInfo(): Manga? = runInterruptible(Dispatchers.IO) {
|
||||
val index = MangaIndex.read(File(root, LocalMangaOutput.ENTRY_NAME_INDEX))
|
||||
index?.getMangaInfo()
|
||||
}
|
||||
|
||||
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> = runInterruptible(Dispatchers.IO) {
|
||||
val file = chapter.url.toUri().toFile()
|
||||
if (file.isDirectory) {
|
||||
file.listFilesRecursive(ImageFileFilter())
|
||||
.toListSorted(compareBy(AlphanumComparator()) { x -> x.name })
|
||||
.map {
|
||||
val pageUri = it.toUri().toString()
|
||||
MangaPage(
|
||||
id = pageUri.longHashCode(),
|
||||
url = pageUri,
|
||||
preview = null,
|
||||
source = MangaSource.LOCAL,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
ZipFile(file).use { zip ->
|
||||
zip.entries()
|
||||
.asSequence()
|
||||
.filter { x -> !x.isDirectory }
|
||||
.map { it.name }
|
||||
.toListSorted(AlphanumComparator())
|
||||
.map {
|
||||
val pageUri = zipUri(file, it)
|
||||
MangaPage(
|
||||
id = pageUri.longHashCode(),
|
||||
url = pageUri,
|
||||
preview = null,
|
||||
source = MangaSource.LOCAL,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.toHumanReadable() = replace("_", " ").toCamelCase()
|
||||
|
||||
private fun getChaptersFiles(): List<File> = root.listFilesRecursive(CbzFilter())
|
||||
.toListSorted(compareBy(AlphanumComparator()) { x -> x.name })
|
||||
|
||||
private fun findFirstImageEntry(): String? {
|
||||
val filter = ImageFileFilter()
|
||||
root.listFilesRecursive(filter).firstOrNull()?.let {
|
||||
return it.toUri().toString()
|
||||
}
|
||||
val cbz = root.listFilesRecursive(CbzFilter()).firstOrNull() ?: return null
|
||||
return ZipFile(cbz).use { zip ->
|
||||
val filter = ImageFileFilter()
|
||||
zip.entries().asSequence()
|
||||
.firstOrNull { x -> !x.isDirectory && filter.accept(x) }
|
||||
?.let { entry -> zipUri(cbz, entry.name) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun fileUri(base: File, name: String): String {
|
||||
return File(base, name).toUri().toString()
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,75 @@
|
||||
package org.koitharu.kotatsu.local.data.input
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.core.net.toFile
|
||||
import org.koitharu.kotatsu.local.data.LocalManga
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import java.io.File
|
||||
|
||||
sealed class LocalMangaInput(
|
||||
protected val root: File,
|
||||
) {
|
||||
|
||||
abstract suspend fun getManga(): LocalManga
|
||||
|
||||
abstract suspend fun getMangaInfo(): Manga?
|
||||
|
||||
abstract suspend fun getPages(chapter: MangaChapter): List<MangaPage>
|
||||
|
||||
companion object {
|
||||
|
||||
fun of(manga: Manga): LocalMangaInput = of(Uri.parse(manga.url).toFile())
|
||||
|
||||
fun of(chapter: MangaChapter): LocalMangaInput = of(Uri.parse(chapter.url).toFile())
|
||||
|
||||
fun of(file: File): LocalMangaInput = when {
|
||||
file.isDirectory -> LocalMangaDirInput(file)
|
||||
else -> LocalMangaZipInput(file)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
protected fun zipUri(file: File, entryName: String) = "cbz://${file.path}#$entryName"
|
||||
|
||||
@JvmStatic
|
||||
protected fun Manga.copy2(
|
||||
url: String = this.url,
|
||||
coverUrl: String = this.coverUrl,
|
||||
chapters: List<MangaChapter>? = this.chapters,
|
||||
source: MangaSource = this.source,
|
||||
) = Manga(
|
||||
id = id,
|
||||
title = title,
|
||||
altTitle = altTitle,
|
||||
url = url,
|
||||
publicUrl = publicUrl,
|
||||
rating = rating,
|
||||
isNsfw = isNsfw,
|
||||
coverUrl = coverUrl,
|
||||
tags = tags,
|
||||
state = state,
|
||||
author = author,
|
||||
largeCoverUrl = largeCoverUrl,
|
||||
description = description,
|
||||
chapters = chapters,
|
||||
source = source,
|
||||
)
|
||||
|
||||
@JvmStatic
|
||||
protected fun MangaChapter.copy(
|
||||
url: String = this.url,
|
||||
source: MangaSource = this.source,
|
||||
) = MangaChapter(
|
||||
id = id,
|
||||
name = name,
|
||||
number = number,
|
||||
url = url,
|
||||
scanlator = scanlator,
|
||||
uploadDate = uploadDate,
|
||||
branch = branch,
|
||||
source = source,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,151 @@
|
||||
package org.koitharu.kotatsu.local.data.input
|
||||
|
||||
import android.net.Uri
|
||||
import android.webkit.MimeTypeMap
|
||||
import androidx.collection.ArraySet
|
||||
import androidx.core.net.toFile
|
||||
import androidx.core.net.toUri
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import org.koitharu.kotatsu.local.data.LocalManga
|
||||
import org.koitharu.kotatsu.local.data.MangaIndex
|
||||
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.MangaPage
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.util.toCamelCase
|
||||
import org.koitharu.kotatsu.utils.AlphanumComparator
|
||||
import org.koitharu.kotatsu.utils.ext.longHashCode
|
||||
import org.koitharu.kotatsu.utils.ext.readText
|
||||
import org.koitharu.kotatsu.utils.ext.toListSorted
|
||||
import java.io.File
|
||||
import java.util.Enumeration
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipFile
|
||||
|
||||
/**
|
||||
* Manga archive {.cbz or .zip file}
|
||||
* |--- index.json (optional)
|
||||
* |--- Page 1.png
|
||||
* |--- Page 2.png
|
||||
* :
|
||||
* L--- Page x.png
|
||||
*/
|
||||
class LocalMangaZipInput(root: File) : LocalMangaInput(root) {
|
||||
|
||||
override suspend fun getManga(): LocalManga {
|
||||
val manga = runInterruptible(Dispatchers.IO) {
|
||||
ZipFile(root).use { zip ->
|
||||
val fileUri = root.toUri().toString()
|
||||
val entry = zip.getEntry(LocalMangaOutput.ENTRY_NAME_INDEX)
|
||||
val index = entry?.let(zip::readText)?.let(::MangaIndex)
|
||||
val info = index?.getMangaInfo()
|
||||
if (info != null) {
|
||||
return@use info.copy2(
|
||||
source = MangaSource.LOCAL,
|
||||
url = fileUri,
|
||||
coverUrl = zipUri(
|
||||
root,
|
||||
entryName = index.getCoverEntry()
|
||||
?: findFirstImageEntry(zip.entries())?.name.orEmpty(),
|
||||
),
|
||||
chapters = info.chapters?.map { c ->
|
||||
c.copy(url = fileUri, source = MangaSource.LOCAL)
|
||||
},
|
||||
)
|
||||
}
|
||||
// fallback
|
||||
val title = root.nameWithoutExtension.replace("_", " ").toCamelCase()
|
||||
val chapters = ArraySet<String>()
|
||||
for (x in zip.entries()) {
|
||||
if (!x.isDirectory) {
|
||||
chapters += x.name.substringBeforeLast(File.separatorChar, "")
|
||||
}
|
||||
}
|
||||
val uriBuilder = root.toUri().buildUpon()
|
||||
Manga(
|
||||
id = root.absolutePath.longHashCode(),
|
||||
title = title,
|
||||
url = fileUri,
|
||||
publicUrl = fileUri,
|
||||
source = MangaSource.LOCAL,
|
||||
coverUrl = zipUri(root, findFirstImageEntry(zip.entries())?.name.orEmpty()),
|
||||
chapters = chapters.sortedWith(AlphanumComparator()).mapIndexed { i, s ->
|
||||
MangaChapter(
|
||||
id = "$i$s".longHashCode(),
|
||||
name = s.ifEmpty { title },
|
||||
number = i + 1,
|
||||
source = MangaSource.LOCAL,
|
||||
uploadDate = 0L,
|
||||
url = uriBuilder.fragment(s).build().toString(),
|
||||
scanlator = null,
|
||||
branch = null,
|
||||
)
|
||||
},
|
||||
altTitle = null,
|
||||
rating = -1f,
|
||||
isNsfw = false,
|
||||
tags = setOf(),
|
||||
state = null,
|
||||
author = null,
|
||||
largeCoverUrl = null,
|
||||
description = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
return LocalManga(root, manga)
|
||||
}
|
||||
|
||||
override suspend fun getMangaInfo(): Manga? = runInterruptible(Dispatchers.IO) {
|
||||
ZipFile(root).use { zip ->
|
||||
val entry = zip.getEntry(LocalMangaOutput.ENTRY_NAME_INDEX)
|
||||
val index = entry?.let(zip::readText)?.let(::MangaIndex)
|
||||
index?.getMangaInfo()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
||||
return runInterruptible(Dispatchers.IO) {
|
||||
val uri = Uri.parse(chapter.url)
|
||||
val file = uri.toFile()
|
||||
val zip = ZipFile(file)
|
||||
val index = zip.getEntry(LocalMangaOutput.ENTRY_NAME_INDEX)?.let(zip::readText)?.let(::MangaIndex)
|
||||
var entries = zip.entries().asSequence()
|
||||
entries = if (index != null) {
|
||||
val pattern = index.getChapterNamesPattern(chapter)
|
||||
entries.filter { x -> !x.isDirectory && x.name.substringBefore('.').matches(pattern) }
|
||||
} else {
|
||||
val parent = uri.fragment.orEmpty()
|
||||
entries.filter { x ->
|
||||
!x.isDirectory && x.name.substringBeforeLast(
|
||||
File.separatorChar,
|
||||
"",
|
||||
) == parent
|
||||
}
|
||||
}
|
||||
entries
|
||||
.toListSorted(compareBy(AlphanumComparator()) { x -> x.name })
|
||||
.map { x ->
|
||||
val entryUri = zipUri(file, x.name)
|
||||
MangaPage(
|
||||
id = entryUri.longHashCode(),
|
||||
url = entryUri,
|
||||
preview = null,
|
||||
source = MangaSource.LOCAL,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun findFirstImageEntry(entries: Enumeration<out ZipEntry>): ZipEntry? {
|
||||
val list = entries.toList()
|
||||
.filterNot { it.isDirectory }
|
||||
.sortedWith(compareBy(AlphanumComparator()) { x -> x.name })
|
||||
val map = MimeTypeMap.getSingleton()
|
||||
return list.firstOrNull {
|
||||
map.getMimeTypeFromExtension(it.name.substringAfterLast('.'))
|
||||
?.startsWith("image/") == true
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,118 @@
|
||||
package org.koitharu.kotatsu.local.data.output
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import org.koitharu.kotatsu.core.zip.ZipOutput
|
||||
import org.koitharu.kotatsu.local.data.MangaIndex
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
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.takeIfReadable
|
||||
import java.io.File
|
||||
|
||||
class LocalMangaDirOutput(
|
||||
rootFile: File,
|
||||
manga: Manga,
|
||||
) : LocalMangaOutput(rootFile) {
|
||||
|
||||
private val chaptersOutput = HashMap<MangaChapter, ZipOutput>()
|
||||
private val index = MangaIndex(File(rootFile, ENTRY_NAME_INDEX).takeIfReadable()?.readText())
|
||||
|
||||
init {
|
||||
index.setMangaInfo(manga, append = true)
|
||||
}
|
||||
|
||||
override suspend fun mergeWithExisting() = Unit
|
||||
|
||||
override suspend fun addCover(file: File, ext: String) {
|
||||
val name = buildString {
|
||||
append("cover")
|
||||
if (ext.isNotEmpty() && ext.length <= 4) {
|
||||
append('.')
|
||||
append(ext)
|
||||
}
|
||||
}
|
||||
runInterruptible(Dispatchers.IO) {
|
||||
file.copyTo(File(rootFile, name), overwrite = true)
|
||||
}
|
||||
index.setCoverEntry(name)
|
||||
flushIndex()
|
||||
}
|
||||
|
||||
override suspend fun addPage(chapter: MangaChapter, file: File, pageNumber: Int, ext: String) {
|
||||
val output = chaptersOutput.getOrPut(chapter) {
|
||||
ZipOutput(File(rootFile, chapterFileName(chapter) + SUFFIX_TMP))
|
||||
}
|
||||
val name = buildString {
|
||||
append(FILENAME_PATTERN.format(chapter.branch.hashCode(), chapter.number, pageNumber))
|
||||
if (ext.isNotEmpty() && ext.length <= 4) {
|
||||
append('.')
|
||||
append(ext)
|
||||
}
|
||||
}
|
||||
runInterruptible(Dispatchers.IO) {
|
||||
output.put(name, file)
|
||||
}
|
||||
index.addChapter(chapter)
|
||||
}
|
||||
|
||||
override suspend fun flushChapter(chapter: MangaChapter) {
|
||||
val output = chaptersOutput.remove(chapter) ?: return
|
||||
output.flushAndFinish()
|
||||
flushIndex()
|
||||
}
|
||||
|
||||
override suspend fun finish() {
|
||||
flushIndex()
|
||||
for (output in chaptersOutput.values) {
|
||||
output.flushAndFinish()
|
||||
}
|
||||
chaptersOutput.clear()
|
||||
}
|
||||
|
||||
override suspend fun cleanup() {
|
||||
for (output in chaptersOutput.values) {
|
||||
output.file.deleteAwait()
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
for (output in chaptersOutput.values) {
|
||||
output.close()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun deleteChapter(chapterId: Long) {
|
||||
val chapter = checkNotNull(index.getMangaInfo()?.chapters) {
|
||||
"No chapters found"
|
||||
}.first { it.id == chapterId }
|
||||
val chapterDir = File(rootFile, chapterFileName(chapter))
|
||||
chapterDir.deleteAwait()
|
||||
index.removeChapter(chapterId)
|
||||
}
|
||||
|
||||
fun setIndex(newIndex: MangaIndex) {
|
||||
index.setFrom(newIndex)
|
||||
}
|
||||
|
||||
private suspend fun ZipOutput.flushAndFinish() = runInterruptible(Dispatchers.IO) {
|
||||
finish()
|
||||
close()
|
||||
val resFile = File(file.absolutePath.removeSuffix(SUFFIX_TMP))
|
||||
file.renameTo(resFile)
|
||||
}
|
||||
|
||||
private fun chapterFileName(chapter: MangaChapter): String {
|
||||
return "${chapter.number}_${chapter.name.toFileNameSafe()}".take(18) + ".cbz"
|
||||
}
|
||||
|
||||
private suspend fun flushIndex() = runInterruptible(Dispatchers.IO) {
|
||||
File(rootFile, ENTRY_NAME_INDEX).writeText(index.toString())
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private const val FILENAME_PATTERN = "%08d_%03d%03d"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,56 @@
|
||||
package org.koitharu.kotatsu.local.data.output
|
||||
|
||||
import okio.Closeable
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
import org.koitharu.kotatsu.parsers.util.toFileNameSafe
|
||||
import java.io.File
|
||||
|
||||
sealed class LocalMangaOutput(
|
||||
val rootFile: File,
|
||||
) : Closeable {
|
||||
|
||||
abstract suspend fun mergeWithExisting()
|
||||
|
||||
abstract suspend fun addCover(file: File, ext: String)
|
||||
|
||||
abstract suspend fun addPage(chapter: MangaChapter, file: File, pageNumber: Int, ext: String)
|
||||
|
||||
abstract suspend fun flushChapter(chapter: MangaChapter)
|
||||
|
||||
abstract suspend fun finish()
|
||||
|
||||
abstract suspend fun cleanup()
|
||||
|
||||
companion object {
|
||||
|
||||
const val ENTRY_NAME_INDEX = "index.json"
|
||||
const val SUFFIX_TMP = ".tmp"
|
||||
|
||||
fun getOrCreate(root: File, manga: Manga): LocalMangaOutput {
|
||||
return checkNotNull(getImpl(root, manga, onlyIfExists = false))
|
||||
}
|
||||
|
||||
fun get(root: File, manga: Manga): LocalMangaOutput? {
|
||||
return getImpl(root, manga, onlyIfExists = true)
|
||||
}
|
||||
|
||||
private fun getImpl(root: File, manga: Manga, onlyIfExists: Boolean): LocalMangaOutput? {
|
||||
val name = manga.title.toFileNameSafe()
|
||||
val file = File(root, name)
|
||||
return if (file.exists()) {
|
||||
if (file.isDirectory) {
|
||||
LocalMangaDirOutput(file, manga)
|
||||
} else {
|
||||
LocalMangaZipOutput(file, manga)
|
||||
}
|
||||
} else {
|
||||
if (onlyIfExists) {
|
||||
null
|
||||
} else {
|
||||
LocalMangaDirOutput(file, manga)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,58 @@
|
||||
package org.koitharu.kotatsu.local.data.output
|
||||
|
||||
import androidx.core.net.toFile
|
||||
import androidx.core.net.toUri
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import org.koitharu.kotatsu.local.data.MangaIndex
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
|
||||
class LocalMangaUtil(
|
||||
private val manga: Manga,
|
||||
) {
|
||||
|
||||
init {
|
||||
require(manga.source == MangaSource.LOCAL) {
|
||||
"Expected LOCAL source but ${manga.source} found"
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun deleteChapters(ids: Set<Long>) {
|
||||
newOutput().use { output ->
|
||||
when (output) {
|
||||
is LocalMangaZipOutput -> runInterruptible(Dispatchers.IO) {
|
||||
LocalMangaZipOutput.filterChapters(output, ids)
|
||||
}
|
||||
|
||||
is LocalMangaDirOutput -> {
|
||||
for (id in ids) {
|
||||
output.deleteChapter(id)
|
||||
}
|
||||
output.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun writeIndex(index: MangaIndex) {
|
||||
newOutput().use { output ->
|
||||
when (output) {
|
||||
is LocalMangaDirOutput -> {
|
||||
TODO()
|
||||
}
|
||||
|
||||
is LocalMangaZipOutput -> TODO()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun newOutput(): LocalMangaOutput = runInterruptible(Dispatchers.IO) {
|
||||
val file = manga.url.toUri().toFile()
|
||||
if (file.isDirectory) {
|
||||
LocalMangaDirOutput(file, manga)
|
||||
} else {
|
||||
LocalMangaZipOutput(file, manga)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
package org.koitharu.kotatsu.local.data
|
||||
package org.koitharu.kotatsu.local.data.util
|
||||
|
||||
import okhttp3.internal.closeQuietly
|
||||
import okio.Closeable
|
||||
@ -0,0 +1,48 @@
|
||||
package org.koitharu.kotatsu.local.data.util
|
||||
|
||||
import android.os.Build
|
||||
import android.os.FileObserver
|
||||
import androidx.annotation.RequiresApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.ProducerScope
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.channels.trySendBlocking
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import java.io.File
|
||||
|
||||
fun File.observe() = callbackFlow {
|
||||
val observer = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
FlowFileObserverQ(this, this@observe)
|
||||
} else {
|
||||
FlowFileObserver(this, this@observe)
|
||||
}
|
||||
observer.startWatching()
|
||||
awaitClose { observer.stopWatching() }
|
||||
}.flowOn(Dispatchers.IO)
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
private class FlowFileObserverQ(
|
||||
private val producerScope: ProducerScope<File>,
|
||||
private val file: File,
|
||||
) : FileObserver(file, CREATE or DELETE or CLOSE_WRITE) {
|
||||
|
||||
override fun onEvent(event: Int, path: String?) {
|
||||
producerScope.trySendBlocking(
|
||||
if (path == null) file else file.resolve(path),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private 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),
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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.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.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,
|
||||
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.finish()
|
||||
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().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,43 +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 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,40 +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.LocalStorageManager
|
||||
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
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,
|
||||
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.copyToSuspending(output)
|
||||
}
|
||||
} ?: throw IOException("Cannot open input stream: $uri")
|
||||
localMangaRepository.getFromFile(dest)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue