Rewrite manga importer #31

This commit is contained in:
Koitharu
2023-03-18 20:45:42 +02:00
parent 4744a0a162
commit 0e4575356a
17 changed files with 457 additions and 424 deletions

View File

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

View File

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

View File

@@ -22,6 +22,7 @@ abstract class LocalMangaOutput(
abstract suspend fun cleanup()
// TODO remove
abstract fun sortChaptersByName()
companion object {

View File

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

View File

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

View File

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

View File

@@ -5,9 +5,9 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.activityViewModels
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.AlertDialogFragment
@@ -15,7 +15,6 @@ import org.koitharu.kotatsu.databinding.DialogImportBinding
class ImportDialogFragment : AlertDialogFragment<DialogImportBinding>(), View.OnClickListener {
private val viewModel by activityViewModels<LocalListViewModel>()
private val importFileCall = registerForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) {
startImport(it)
}
@@ -48,7 +47,12 @@ class ImportDialogFragment : AlertDialogFragment<DialogImportBinding>(), View.On
}
private fun startImport(uris: Collection<Uri>) {
ImportService.start(requireContext(), uris)
if (uris.isEmpty()) {
return
}
val ctx = requireContext()
ImportWorker.start(ctx, uris)
Toast.makeText(ctx, R.string.import_will_start_soon, Toast.LENGTH_LONG).show()
dismiss()
}

View File

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

View File

@@ -0,0 +1,149 @@
package org.koitharu.kotatsu.local.ui
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.net.Uri
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import androidx.hilt.work.HiltWorker
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.Data
import androidx.work.ForegroundInfo
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.OutOfQuotaPolicy
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import coil.ImageLoader
import coil.request.ImageRequest
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.local.data.importer.SingleMangaImporter
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.utils.PendingIntentCompat
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.toBitmapOrNull
import org.koitharu.kotatsu.utils.ext.toUriOrNull
@HiltWorker
class ImportWorker @AssistedInject constructor(
@Assisted appContext: Context,
@Assisted params: WorkerParameters,
private val importer: SingleMangaImporter,
private val coil: ImageLoader
) : CoroutineWorker(appContext, params) {
private val notificationManager by lazy {
applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
}
override suspend fun doWork(): Result {
val uri = inputData.getString(DATA_URI)?.toUriOrNull() ?: return Result.failure()
setForeground(getForegroundInfo())
val result = runCatchingCancellable {
importer.import(uri, null).manga
}
val notification = buildNotification(result)
notificationManager.notify(uri.hashCode(), notification)
return Result.success()
}
override suspend fun getForegroundInfo(): ForegroundInfo {
val title = applicationContext.getString(R.string.importing_manga)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(CHANNEL_ID, title, NotificationManager.IMPORTANCE_LOW)
channel.setShowBadge(false)
channel.enableVibration(false)
channel.setSound(null, null)
channel.enableLights(false)
notificationManager.createNotificationChannel(channel)
}
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
.setContentTitle(title)
.setPriority(NotificationCompat.PRIORITY_MIN)
.setDefaults(0)
.setColor(ContextCompat.getColor(applicationContext, R.color.blue_primary_dark))
.setSilent(true)
.setProgress(0, 0, true)
.setSmallIcon(android.R.drawable.stat_sys_download)
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
.setOngoing(true)
.build()
return ForegroundInfo(FOREGROUND_NOTIFICATION_ID, notification)
}
private suspend fun buildNotification(result: kotlin.Result<Manga>): Notification {
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setDefaults(0)
.setColor(ContextCompat.getColor(applicationContext, R.color.blue_primary_dark))
.setSilent(true)
result.onSuccess { manga ->
notification.setLargeIcon(
coil.execute(
ImageRequest.Builder(applicationContext)
.data(manga.coverUrl)
.tag(manga.source)
.build(),
).toBitmapOrNull(),
)
notification.setSubText(manga.title)
val intent = DetailsActivity.newIntent(applicationContext, manga)
notification.setContentIntent(
PendingIntent.getActivity(
applicationContext,
manga.id.toInt(),
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE,
),
).setAutoCancel(true)
.setVisibility(
if (manga.isNsfw) NotificationCompat.VISIBILITY_SECRET else NotificationCompat.VISIBILITY_PUBLIC,
)
notification.setContentTitle(applicationContext.getString(R.string.import_completed))
.setContentText(applicationContext.getString(R.string.import_completed_hint))
.setSmallIcon(R.drawable.ic_stat_done)
NotificationCompat.BigTextStyle(notification)
.bigText(applicationContext.getString(R.string.import_completed_hint))
}.onFailure { error ->
notification.setContentTitle(applicationContext.getString(R.string.error_occurred))
.setContentText(error.getDisplayMessage(applicationContext.resources))
.setSmallIcon(android.R.drawable.stat_notify_error)
}
return notification.build()
}
companion object {
const val DATA_URI = "uri"
private const val TAG = "import"
private const val CHANNEL_ID = "importing"
private const val FOREGROUND_NOTIFICATION_ID = 37
fun start(context: Context, uris: Iterable<Uri>) {
val constraints = Constraints.Builder()
.setRequiresStorageNotLow(true)
.build()
val requests = uris.map { uri ->
OneTimeWorkRequestBuilder<ImportWorker>()
.setConstraints(constraints)
.addTag(TAG)
.setInputData(Data.Builder().putString(DATA_URI, uri.toString()).build())
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
.build()
}
WorkManager.getInstance(context)
.enqueue(requests)
}
}
}

View File

@@ -141,7 +141,7 @@ class LocalListViewModel @Inject constructor(
}
private fun cleanup() {
if (!DownloadService.isRunning && !ImportService.isRunning && !LocalChaptersRemoveService.isRunning) {
if (!DownloadService.isRunning && !LocalChaptersRemoveService.isRunning) {
viewModelScope.launch {
runCatchingCancellable {
repository.cleanup()

View File

@@ -13,9 +13,11 @@ import java.io.OutputStream
suspend fun InputStream.copyToSuspending(
out: OutputStream,
bufferSize: Int = DEFAULT_BUFFER_SIZE
bufferSize: Int = DEFAULT_BUFFER_SIZE,
progressState: MutableStateFlow<Float>? = null,
): Long = withContext(Dispatchers.IO) {
val job = currentCoroutineContext()[Job]
val total = available()
var bytesCopied: Long = 0
val buffer = ByteArray(bufferSize)
var bytes = read(buffer)
@@ -25,6 +27,9 @@ suspend fun InputStream.copyToSuspending(
job?.ensureActive()
bytes = read(buffer)
job?.ensureActive()
if (progressState != null && total > 0) {
progressState.value = bytesCopied / total.toFloat()
}
}
bytesCopied
}

View File

@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.utils.progress
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@Deprecated("Should be replaced with Float")
@Parcelize
data class Progress(
val value: Int,
@@ -21,4 +22,4 @@ data class Progress(
get() = total <= 0
private fun part() = if (isIndeterminate) -1.0 else value / total.toDouble()
}
}

View File

@@ -1,32 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingHorizontal="@dimen/grid_spacing_outer"
android:paddingTop="@dimen/margin_normal">
<org.koitharu.kotatsu.base.ui.widgets.ListItemTextView
<org.koitharu.kotatsu.base.ui.widgets.TwoLinesItemView
android:id="@+id/button_file"
android:layout_width="match_parent"
android:layout_height="?android:listPreferredItemHeightSmall"
android:drawableStart="@drawable/ic_file_zip"
android:layout_height="wrap_content"
android:drawablePadding="?android:listPreferredItemPaddingStart"
android:minHeight="?android:listPreferredItemHeightSmall"
android:paddingStart="?android:listPreferredItemPaddingStart"
android:paddingEnd="?android:listPreferredItemPaddingEnd"
android:text="@string/comics_archive"
android:textAppearance="?attr/textAppearanceButton" />
app:icon="@drawable/ic_file_zip"
app:subtitle="@string/comics_archive_import_description"
app:title="@string/comics_archive" />
<org.koitharu.kotatsu.base.ui.widgets.ListItemTextView
<org.koitharu.kotatsu.base.ui.widgets.TwoLinesItemView
android:id="@+id/button_dir"
android:layout_width="match_parent"
android:layout_height="?android:listPreferredItemHeightSmall"
android:drawableStart="@drawable/ic_folder_file"
android:layout_height="wrap_content"
android:drawablePadding="?android:listPreferredItemPaddingStart"
android:minHeight="?android:listPreferredItemHeightSmall"
android:paddingStart="?android:listPreferredItemPaddingStart"
android:paddingEnd="?android:listPreferredItemPaddingEnd"
android:text="@string/folder_with_images"
android:textAppearance="?attr/textAppearanceButton" />
app:icon="@drawable/ic_folder_file"
app:subtitle="@string/folder_with_images_import_description"
app:title="@string/folder_with_images" />
</LinearLayout>

View File

@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<merge
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
tools:layout_height="wrap_content"
tools:orientation="horizontal"
tools:parentTag="android.widget.LinearLayout">
<ImageView
android:id="@+id/icon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
tools:src="@drawable/ic_folder_file" />
<LinearLayout
android:id="@+id/layout_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:orientation="vertical"
android:paddingVertical="6dp">
<TextView
android:id="@+id/title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
tools:text="@tools:sample/cities" />
<TextView
android:id="@+id/subtitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
tools:text="@tools:sample/lorem[12]" />
</LinearLayout>
</merge>

View File

@@ -37,6 +37,23 @@
<attr name="android:insetRight" />
</declare-styleable>
<declare-styleable name="TwoLinesItemView">
<attr name="shapeAppearance" />
<attr name="shapeAppearanceOverlay" />
<attr name="backgroundFillColor" />
<attr name="android:textColor" />
<attr name="android:drawablePadding" />
<attr name="android:insetTop" />
<attr name="android:insetBottom" />
<attr name="android:insetLeft" />
<attr name="android:insetRight" />
<attr name="title" />
<attr name="subtitle" />
<attr name="icon" />
<attr name="titleTextAppearance" />
<attr name="subtitleTextAppearance" />
</declare-styleable>
<declare-styleable name="ProgressDrawable">
<attr name="strokeWidth" />
<attr name="android:strokeColor" />

View File

@@ -278,7 +278,7 @@
<string name="download_slowdown">Download slowdown</string>
<string name="download_slowdown_summary">Helps avoid blocking your IP address</string>
<string name="local_manga_processing">Saved manga processing</string>
<string name="chapters_will_removed_background">Chapters will be removed in the background. It can take some time</string>
<string name="chapters_will_removed_background">Chapters will be removed in the background</string>
<string name="canceled">Canceled</string>
<string name="account_already_exists">Account already exists</string>
<string name="back">Back</string>
@@ -428,4 +428,6 @@
<string name="sources_reorder_tip">Tap and hold on an item to reorder them</string>
<string name="user_agent">UserAgent header</string>
<string name="settings_apply_restart_required">Please restart the application to apply these changes</string>
<string name="comics_archive_import_description">You can select one or more .cbz or .zip files, each file will be recognized as a separate manga.</string>
<string name="folder_with_images_import_description">You can select a directory with archives or images. Each archive (or subdirectory) will be recognized as a chapter.</string>
</resources>

View File

@@ -154,6 +154,19 @@
<item name="android:insetBottom">2dp</item>
</style>
<style name="Widget.Kotatsu.TwoLinesItemView" parent="">
<item name="backgroundFillColor">@color/list_item_background_color</item>
<item name="shapeAppearance">?attr/shapeAppearanceCornerLarge</item>
<item name="android:insetRight">6dp</item>
<item name="android:insetLeft">6dp</item>
<item name="android:insetTop">2dp</item>
<item name="android:insetBottom">2dp</item>
<item name="android:orientation">horizontal</item>
<item name="android:textColor">@color/list_item_text_color</item>
<item name="titleTextAppearance">?attr/textAppearanceButton</item>
<item name="subtitleTextAppearance">?attr/textAppearanceBodySmall</item>
</style>
<style name="Widget.Kotatsu.ExploreButton" parent="Widget.Material3.Button.TonalButton.Icon">
<item name="android:minHeight">58dp</item>
<item name="android:textColor">?attr/colorOnSurface</item>