From 0e4575356af7e22d3ba307925560edea35b5bed5 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Sat, 18 Mar 2023 20:45:42 +0200 Subject: [PATCH] Rewrite manga importer #31 --- .../base/ui/widgets/TwoLinesItemView.kt | 104 ++++++++++ .../data/importer/SingleMangaImporter.kt | 103 ++++++++++ .../local/data/output/LocalMangaOutput.kt | 1 + .../local/domain/importer/DirMangaImporter.kt | 143 -------------- .../local/domain/importer/MangaImporter.kt | 41 ---- .../local/domain/importer/ZipMangaImporter.kt | 39 ---- .../kotatsu/local/ui/ImportDialogFragment.kt | 10 +- .../kotatsu/local/ui/ImportService.kt | 184 ------------------ .../koitharu/kotatsu/local/ui/ImportWorker.kt | 149 ++++++++++++++ .../kotatsu/local/ui/LocalListViewModel.kt | 2 +- .../java/org/koitharu/kotatsu/utils/ext/IO.kt | 7 +- .../kotatsu/utils/progress/Progress.kt | 3 +- app/src/main/res/layout/dialog_import.xml | 23 ++- .../main/res/layout/view_two_lines_item.xml | 38 ++++ app/src/main/res/values/attrs.xml | 17 ++ app/src/main/res/values/strings.xml | 4 +- app/src/main/res/values/styles.xml | 13 ++ 17 files changed, 457 insertions(+), 424 deletions(-) create mode 100644 app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/TwoLinesItemView.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/local/data/importer/SingleMangaImporter.kt delete mode 100644 app/src/main/java/org/koitharu/kotatsu/local/domain/importer/DirMangaImporter.kt delete mode 100644 app/src/main/java/org/koitharu/kotatsu/local/domain/importer/MangaImporter.kt delete mode 100644 app/src/main/java/org/koitharu/kotatsu/local/domain/importer/ZipMangaImporter.kt delete mode 100644 app/src/main/java/org/koitharu/kotatsu/local/ui/ImportService.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/local/ui/ImportWorker.kt create mode 100644 app/src/main/res/layout/view_two_lines_item.xml diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/TwoLinesItemView.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/TwoLinesItemView.kt new file mode 100644 index 000000000..f7f8d44e1 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/TwoLinesItemView.kt @@ -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 { 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) + } +} diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/importer/SingleMangaImporter.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/importer/SingleMangaImporter.kt new file mode 100644 index 000000000..82a05f6b1 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/local/data/importer/SingleMangaImporter.kt @@ -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?): LocalManga { + return if (isDirectory(uri)) { + importDirectory(uri, progressState) + } else { + importFile(uri, progressState) + } + } + + private suspend fun importFile(uri: Uri, progressState: MutableStateFlow?): 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?): 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 + } +} diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/output/LocalMangaOutput.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/output/LocalMangaOutput.kt index 272687b5e..85c9d5da3 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/data/output/LocalMangaOutput.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/data/output/LocalMangaOutput.kt @@ -22,6 +22,7 @@ abstract class LocalMangaOutput( abstract suspend fun cleanup() + // TODO remove abstract fun sortChaptersByName() companion object { diff --git a/app/src/main/java/org/koitharu/kotatsu/local/domain/importer/DirMangaImporter.kt b/app/src/main/java/org/koitharu/kotatsu/local/domain/importer/DirMangaImporter.kt deleted file mode 100644 index ff92955a2..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/local/domain/importer/DirMangaImporter.kt +++ /dev/null @@ -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() - - @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, - ) - } - } - } -} diff --git a/app/src/main/java/org/koitharu/kotatsu/local/domain/importer/MangaImporter.kt b/app/src/main/java/org/koitharu/kotatsu/local/domain/importer/MangaImporter.kt deleted file mode 100644 index bb036c549..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/local/domain/importer/MangaImporter.kt +++ /dev/null @@ -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 - } - } -} diff --git a/app/src/main/java/org/koitharu/kotatsu/local/domain/importer/ZipMangaImporter.kt b/app/src/main/java/org/koitharu/kotatsu/local/domain/importer/ZipMangaImporter.kt deleted file mode 100644 index 58fa3e77f..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/local/domain/importer/ZipMangaImporter.kt +++ /dev/null @@ -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() - } - } -} diff --git a/app/src/main/java/org/koitharu/kotatsu/local/ui/ImportDialogFragment.kt b/app/src/main/java/org/koitharu/kotatsu/local/ui/ImportDialogFragment.kt index 5cd6cf85b..ddc5a4cf6 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/ui/ImportDialogFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/ui/ImportDialogFragment.kt @@ -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(), View.OnClickListener { - private val viewModel by activityViewModels() private val importFileCall = registerForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { startImport(it) } @@ -48,7 +47,12 @@ class ImportDialogFragment : AlertDialogFragment(), View.On } private fun startImport(uris: Collection) { - 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() } diff --git a/app/src/main/java/org/koitharu/kotatsu/local/ui/ImportService.kt b/app/src/main/java/org/koitharu/kotatsu/local/ui/ImportService.kt deleted file mode 100644 index 9be2af27e..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/local/ui/ImportService.kt +++ /dev/null @@ -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(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) { - 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() - } - } -} diff --git a/app/src/main/java/org/koitharu/kotatsu/local/ui/ImportWorker.kt b/app/src/main/java/org/koitharu/kotatsu/local/ui/ImportWorker.kt new file mode 100644 index 000000000..b6d527b0d --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/local/ui/ImportWorker.kt @@ -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): 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) { + val constraints = Constraints.Builder() + .setRequiresStorageNotLow(true) + .build() + val requests = uris.map { uri -> + OneTimeWorkRequestBuilder() + .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) + } + } +} diff --git a/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt index 62613ffb5..0cbab571a 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt @@ -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() diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/IO.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/IO.kt index eb0e0de12..1b05eddc3 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/IO.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/IO.kt @@ -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? = 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 } diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/progress/Progress.kt b/app/src/main/java/org/koitharu/kotatsu/utils/progress/Progress.kt index 5723cae17..8956c4021 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/progress/Progress.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/progress/Progress.kt @@ -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() -} \ No newline at end of file +} diff --git a/app/src/main/res/layout/dialog_import.xml b/app/src/main/res/layout/dialog_import.xml index 2513088ca..0541b5581 100644 --- a/app/src/main/res/layout/dialog_import.xml +++ b/app/src/main/res/layout/dialog_import.xml @@ -1,32 +1,35 @@ - + app:icon="@drawable/ic_file_zip" + app:subtitle="@string/comics_archive_import_description" + app:title="@string/comics_archive" /> - + app:icon="@drawable/ic_folder_file" + app:subtitle="@string/folder_with_images_import_description" + app:title="@string/folder_with_images" /> diff --git a/app/src/main/res/layout/view_two_lines_item.xml b/app/src/main/res/layout/view_two_lines_item.xml new file mode 100644 index 000000000..664bbdbb6 --- /dev/null +++ b/app/src/main/res/layout/view_two_lines_item.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml index a6b7770b3..32b5338bb 100644 --- a/app/src/main/res/values/attrs.xml +++ b/app/src/main/res/values/attrs.xml @@ -37,6 +37,23 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c1670872c..88f0a4dd8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -278,7 +278,7 @@ Download slowdown Helps avoid blocking your IP address Saved manga processing - Chapters will be removed in the background. It can take some time + Chapters will be removed in the background Canceled Account already exists Back @@ -428,4 +428,6 @@ Tap and hold on an item to reorder them UserAgent header Please restart the application to apply these changes + You can select one or more .cbz or .zip files, each file will be recognized as a separate manga. + You can select a directory with archives or images. Each archive (or subdirectory) will be recognized as a chapter. diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 5c9713e11..48d1517d9 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -154,6 +154,19 @@ 2dp + +