From a6662ab50146df16ebb36514865f515ad4d93658 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Sun, 15 Sep 2024 13:30:33 +0300 Subject: [PATCH] Batch manga fix functionality --- app/src/main/AndroidManifest.xml | 5 +- .../domain/AlternativesUseCase.kt | 22 +- .../alternatives/domain/AutoFixUseCase.kt | 93 +++++++++ .../alternatives/domain/MigrateUseCase.kt | 4 +- .../kotatsu/alternatives/ui/AutoFixService.kt | 196 ++++++++++++++++++ .../org/koitharu/kotatsu/core/model/Manga.kt | 3 + .../kotatsu/core/model/MangaHistory.kt | 1 + .../kotatsu/core/ui/CoroutineIntentService.kt | 56 ++++- .../ui/list/FavouritesListFragment.kt | 6 - .../kotatsu/history/data/EntityMapping.kt | 1 + .../kotatsu/history/ui/HistoryListFragment.kt | 22 +- .../kotatsu/list/ui/MangaListFragment.kt | 25 +++ .../kotatsu/local/ui/ImportService.kt | 6 +- .../drawable-anydpi-v24/ic_stat_auto_fix.xml | 17 ++ .../res/drawable-hdpi/ic_stat_auto_fix.png | Bin 0 -> 631 bytes .../res/drawable-mdpi/ic_stat_auto_fix.png | Bin 0 -> 404 bytes .../res/drawable-xhdpi/ic_stat_auto_fix.png | Bin 0 -> 851 bytes .../res/drawable-xxhdpi/ic_stat_auto_fix.png | Bin 0 -> 1274 bytes app/src/main/res/drawable/ic_auto_fix.xml | 11 + app/src/main/res/drawable/ic_heart.xml | 2 +- .../main/res/drawable/ic_heart_outline.xml | 2 +- app/src/main/res/drawable/ic_save.xml | 2 +- app/src/main/res/menu/mode_favourites.xml | 6 + app/src/main/res/menu/mode_history.xml | 6 + app/src/main/res/values/strings.xml | 6 + 25 files changed, 454 insertions(+), 38 deletions(-) create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/alternatives/domain/AutoFixUseCase.kt create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/alternatives/ui/AutoFixService.kt create mode 100644 app/src/main/res/drawable-anydpi-v24/ic_stat_auto_fix.xml create mode 100644 app/src/main/res/drawable-hdpi/ic_stat_auto_fix.png create mode 100644 app/src/main/res/drawable-mdpi/ic_stat_auto_fix.png create mode 100644 app/src/main/res/drawable-xhdpi/ic_stat_auto_fix.png create mode 100644 app/src/main/res/drawable-xxhdpi/ic_stat_auto_fix.png create mode 100644 app/src/main/res/drawable/ic_auto_fix.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index cfdc81f88..c0f146385 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -20,7 +20,7 @@ - + @@ -273,6 +273,9 @@ + diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/domain/AlternativesUseCase.kt b/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/domain/AlternativesUseCase.kt index a993eb31c..ed5444bcf 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/domain/AlternativesUseCase.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/domain/AlternativesUseCase.kt @@ -18,14 +18,16 @@ import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import javax.inject.Inject private const val MAX_PARALLELISM = 4 -private const val MATCH_THRESHOLD = 0.2f +private const val MATCH_THRESHOLD_DEFAULT = 0.2f class AlternativesUseCase @Inject constructor( private val sourcesRepository: MangaSourcesRepository, private val mangaRepositoryFactory: MangaRepository.Factory, ) { - suspend operator fun invoke(manga: Manga): Flow { + suspend operator fun invoke(manga: Manga): Flow = invoke(manga, MATCH_THRESHOLD_DEFAULT) + + suspend operator fun invoke(manga: Manga, matchThreshold: Float): Flow { val sources = getSources(manga.source) if (sources.isEmpty()) { return emptyFlow() @@ -44,7 +46,7 @@ class AlternativesUseCase @Inject constructor( } }.getOrDefault(emptyList()) for (item in list) { - if (item.matches(manga)) { + if (item.matches(manga, matchThreshold)) { send(item) } } @@ -65,16 +67,16 @@ class AlternativesUseCase @Inject constructor( return result } - private fun Manga.matches(ref: Manga): Boolean { - return matchesTitles(title, ref.title) || - matchesTitles(title, ref.altTitle) || - matchesTitles(altTitle, ref.title) || - matchesTitles(altTitle, ref.altTitle) + private fun Manga.matches(ref: Manga, threshold: Float): Boolean { + return matchesTitles(title, ref.title, threshold) || + matchesTitles(title, ref.altTitle, threshold) || + matchesTitles(altTitle, ref.title, threshold) || + matchesTitles(altTitle, ref.altTitle, threshold) } - private fun matchesTitles(a: String?, b: String?): Boolean { - return !a.isNullOrEmpty() && !b.isNullOrEmpty() && a.almostEquals(b, MATCH_THRESHOLD) + private fun matchesTitles(a: String?, b: String?, threshold: Float): Boolean { + return !a.isNullOrEmpty() && !b.isNullOrEmpty() && a.almostEquals(b, threshold) } private fun MangaSource.priority(ref: MangaSource): Int { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/domain/AutoFixUseCase.kt b/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/domain/AutoFixUseCase.kt new file mode 100644 index 000000000..ec45258c9 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/domain/AutoFixUseCase.kt @@ -0,0 +1,93 @@ +package org.koitharu.kotatsu.alternatives.domain + +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.lastOrNull +import kotlinx.coroutines.flow.runningFold +import kotlinx.coroutines.flow.transformWhile +import kotlinx.coroutines.flow.withIndex +import kotlinx.coroutines.launch +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import org.koitharu.kotatsu.core.model.chaptersCount +import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga +import org.koitharu.kotatsu.core.parser.MangaDataRepository +import org.koitharu.kotatsu.core.parser.MangaRepository +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.util.runCatchingCancellable +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import kotlin.coroutines.cancellation.CancellationException + +class AutoFixUseCase @Inject constructor( + private val mangaRepositoryFactory: MangaRepository.Factory, + private val alternativesUseCase: AlternativesUseCase, + private val migrateUseCase: MigrateUseCase, + private val mangaDataRepository: MangaDataRepository, +) { + + suspend operator fun invoke(mangaId: Long): Pair { + val seed = checkNotNull(mangaDataRepository.findMangaById(mangaId)) { "Manga $mangaId not found" } + .getDetailsSafe() + if (seed.isHealthy()) { + return seed to null // no fix required + } + val replacement = alternativesUseCase(seed, matchThreshold = 0.02f) + .filter { it.isHealthy() } + .runningFold(null) { best, candidate -> + if (best == null || best < candidate) { + candidate + } else { + best + } + }.selectLastWithTimeout(4, 40, TimeUnit.SECONDS) + migrateUseCase(seed, replacement ?: throw NoAlternativesException(ParcelableManga(seed))) + return seed to replacement + } + + private suspend fun Manga.isHealthy(): Boolean = runCatchingCancellable { + val repo = mangaRepositoryFactory.create(source) + val details = if (this.chapters != null) this else repo.getDetails(this) + val firstChapter = details.chapters?.firstOrNull() ?: return@runCatchingCancellable false + val pageUrl = repo.getPageUrl(repo.getPages(firstChapter).first()) + pageUrl.toHttpUrlOrNull() != null + }.getOrDefault(false) + + private suspend fun Manga.getDetailsSafe() = runCatchingCancellable { + mangaRepositoryFactory.create(source).getDetails(this) + }.getOrDefault(this) + + private operator fun Manga.compareTo(other: Manga) = chaptersCount().compareTo(other.chaptersCount()) + + @Suppress("UNCHECKED_CAST", "OPT_IN_USAGE") + private suspend fun Flow.selectLastWithTimeout( + minCount: Int, + timeout: Long, + timeUnit: TimeUnit + ): T? = channelFlow { + var lastValue: T? = null + launch { + delay(timeUnit.toMillis(timeout)) + close(InternalTimeoutException(lastValue)) + } + withIndex().transformWhile { (index, value) -> + lastValue = value + emit(value) + index < minCount && !isClosedForSend + }.collect { + send(it) + } + }.catch { e -> + if (e is InternalTimeoutException) { + emit(e.value as T?) + } else { + throw e + } + }.lastOrNull() + + class NoAlternativesException(val seed: ParcelableManga) : NoSuchElementException() + + private class InternalTimeoutException(val value: Any?) : CancellationException() +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/domain/MigrateUseCase.kt b/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/domain/MigrateUseCase.kt index 2e9caf19d..df5dd5233 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/domain/MigrateUseCase.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/domain/MigrateUseCase.kt @@ -136,7 +136,7 @@ constructor( return HistoryEntity( mangaId = newManga.id, createdAt = history.createdAt, - updatedAt = System.currentTimeMillis(), + updatedAt = history.updatedAt, chapterId = currentChapter.id, page = history.page, scroll = history.scroll, @@ -173,7 +173,7 @@ constructor( return HistoryEntity( mangaId = newManga.id, createdAt = history.createdAt, - updatedAt = System.currentTimeMillis(), + updatedAt = history.updatedAt, chapterId = newChapterId, page = history.page, scroll = history.scroll, diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/ui/AutoFixService.kt b/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/ui/AutoFixService.kt new file mode 100644 index 000000000..9fa0fdf42 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/ui/AutoFixService.kt @@ -0,0 +1,196 @@ +package org.koitharu.kotatsu.alternatives.ui + +import android.annotation.SuppressLint +import android.app.Notification +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import androidx.core.app.NotificationChannelCompat +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.app.PendingIntentCompat +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.runBlocking +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.alternatives.domain.AutoFixUseCase +import org.koitharu.kotatsu.core.ErrorReporterReceiver +import org.koitharu.kotatsu.core.model.getTitle +import org.koitharu.kotatsu.core.ui.CoroutineIntentService +import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission +import org.koitharu.kotatsu.core.util.ext.getDisplayMessage +import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug +import org.koitharu.kotatsu.core.util.ext.toBitmapOrNull +import org.koitharu.kotatsu.details.ui.DetailsActivity +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.util.runCatchingCancellable +import javax.inject.Inject +import com.google.android.material.R as materialR + +@AndroidEntryPoint +class AutoFixService : CoroutineIntentService() { + + @Inject + lateinit var autoFixUseCase: AutoFixUseCase + + @Inject + lateinit var coil: ImageLoader + + private lateinit var notificationManager: NotificationManagerCompat + + override fun onCreate() { + super.onCreate() + notificationManager = NotificationManagerCompat.from(applicationContext) + } + + override suspend fun processIntent(startId: Int, intent: Intent) { + val ids = requireNotNull(intent.getLongArrayExtra(DATA_IDS)) + startForeground(startId) + try { + for (mangaId in ids) { + val result = runCatchingCancellable { + autoFixUseCase.invoke(mangaId) + } + if (applicationContext.checkNotificationPermission(CHANNEL_ID)) { + val notification = buildNotification(result) + notificationManager.notify(TAG, startId, notification) + } + } + } finally { + ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) + } + } + + override fun onError(startId: Int, error: Throwable) { + if (applicationContext.checkNotificationPermission(CHANNEL_ID)) { + val notification = runBlocking { buildNotification(Result.failure(error)) } + notificationManager.notify(TAG, startId, notification) + } + } + + @SuppressLint("InlinedApi") + private fun startForeground(startId: Int) { + val title = applicationContext.getString(R.string.fixing_manga) + val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_MIN) + .setName(title) + .setShowBadge(false) + .setVibrationEnabled(false) + .setSound(null, null) + .setLightsEnabled(false) + .build() + notificationManager.createNotificationChannel(channel) + + val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID) + .setContentTitle(title) + .setPriority(NotificationCompat.PRIORITY_MIN) + .setDefaults(0) + .setSilent(true) + .setOngoing(true) + .setProgress(0, 0, true) + .setSmallIcon(R.drawable.ic_stat_auto_fix) + .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) + .setCategory(NotificationCompat.CATEGORY_PROGRESS) + .addAction( + materialR.drawable.material_ic_clear_black_24dp, + applicationContext.getString(android.R.string.cancel), + getCancelIntent(startId), + ) + .build() + + ServiceCompat.startForeground( + this, + FOREGROUND_NOTIFICATION_ID, + notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC, + ) + } + + private suspend fun buildNotification(result: Result>): Notification { + val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setDefaults(0) + .setSilent(true) + .setAutoCancel(true) + result.onSuccess { (seed, replacement) -> + if (replacement != null) { + notification.setLargeIcon( + coil.execute( + ImageRequest.Builder(applicationContext) + .data(replacement.coverUrl) + .tag(replacement.source) + .build(), + ).toBitmapOrNull(), + ) + notification.setSubText(replacement.title) + val intent = DetailsActivity.newIntent(applicationContext, replacement) + notification.setContentIntent( + PendingIntentCompat.getActivity( + applicationContext, + replacement.id.toInt(), + intent, + PendingIntent.FLAG_UPDATE_CURRENT, + false, + ), + ).setVisibility( + if (replacement.isNsfw) NotificationCompat.VISIBILITY_SECRET else NotificationCompat.VISIBILITY_PUBLIC, + ) + notification + .setContentTitle(applicationContext.getString(R.string.fixed)) + .setContentText( + applicationContext.getString( + R.string.manga_replaced, + seed.title, + seed.source.getTitle(applicationContext), + replacement.title, + replacement.source.getTitle(applicationContext), + ), + ) + .setSmallIcon(R.drawable.ic_stat_done) + } else { + notification + .setContentTitle(applicationContext.getString(R.string.fixing_manga)) + .setContentText(applicationContext.getString(R.string.no_fix_required, seed.title)) + .setSmallIcon(android.R.drawable.stat_sys_warning) + } + }.onFailure { error -> + notification + .setContentTitle(applicationContext.getString(R.string.error_occurred)) + .setContentText( + if (error is AutoFixUseCase.NoAlternativesException) { + applicationContext.getString(R.string.no_alternatives_found, error.seed.manga.title) + } else { + error.getDisplayMessage(applicationContext.resources) + }, + ) + .setSmallIcon(android.R.drawable.stat_notify_error) + .addAction( + R.drawable.ic_alert_outline, + applicationContext.getString(R.string.report), + ErrorReporterReceiver.getPendingIntent(applicationContext, error), + ) + } + return notification.build() + } + + companion object { + + private const val DATA_IDS = "ids" + private const val TAG = "auto_fix" + private const val CHANNEL_ID = "auto_fix" + private const val FOREGROUND_NOTIFICATION_ID = 38 + + fun start(context: Context, mangaIds: Collection): Boolean = try { + val intent = Intent(context, AutoFixService::class.java) + intent.putExtra(DATA_IDS, mangaIds.toLongArray()) + ContextCompat.startForegroundService(context, intent) + true + } catch (e: Exception) { + e.printStackTraceDebug() + false + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/Manga.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/model/Manga.kt index 8a3674fa8..4114c6f58 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/Manga.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/model/Manga.kt @@ -110,6 +110,9 @@ fun Manga.getPreferredBranch(history: MangaHistory?): String? { val Manga.isLocal: Boolean get() = source == LocalMangaSource +val Manga.isBroken: Boolean + get() = source == UnknownMangaSource + val Manga.appUrl: Uri get() = Uri.parse("https://kotatsu.app/manga").buildUpon() .appendQueryParameter("source", source.name) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/MangaHistory.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/model/MangaHistory.kt index d72ed9d3a..21f1a1349 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/MangaHistory.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/model/MangaHistory.kt @@ -12,4 +12,5 @@ data class MangaHistory( val page: Int, val scroll: Int, val percent: Float, + val chaptersCount: Int, ) : Parcelable diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/CoroutineIntentService.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/CoroutineIntentService.kt index e8f167f32..5441134cf 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/CoroutineIntentService.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/CoroutineIntentService.kt @@ -1,12 +1,21 @@ package org.koitharu.kotatsu.core.ui +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context import android.content.Intent +import android.content.IntentFilter +import android.os.PatternMatcher import androidx.annotation.AnyThread import androidx.annotation.WorkerThread +import androidx.core.app.PendingIntentCompat +import androidx.core.content.ContextCompat +import androidx.core.net.toUri import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -20,7 +29,15 @@ abstract class CoroutineIntentService : BaseService() { final override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { super.onStartCommand(intent, flags, startId) - launchCoroutine(intent, startId) + val job = launchCoroutine(intent, startId) + val receiver = CancelReceiver(job) + ContextCompat.registerReceiver( + this, + receiver, + createIntentFilter(this, startId), + ContextCompat.RECEIVER_NOT_EXPORTED, + ) + job.invokeOnCompletion { unregisterReceiver(receiver) } return START_REDELIVER_INTENT } @@ -47,8 +64,45 @@ abstract class CoroutineIntentService : BaseService() { @AnyThread protected abstract fun onError(startId: Int, error: Throwable) + protected fun getCancelIntent(startId: Int) = PendingIntentCompat.getBroadcast( + this, + 0, + createCancelIntent(this, startId), + PendingIntent.FLAG_UPDATE_CURRENT, + false, + ) + private fun errorHandler(startId: Int) = CoroutineExceptionHandler { _, throwable -> throwable.printStackTraceDebug() onError(startId, throwable) } + + private class CancelReceiver( + private val job: Job + ) : BroadcastReceiver() { + + override fun onReceive(context: Context?, intent: Intent?) { + job.cancel() + } + } + + private companion object { + + private const val SCHEME = "startid" + private const val ACTION_SUFFIX_CANCEL = ".ACTION_CANCEL" + + fun createIntentFilter(service: CoroutineIntentService, startId: Int): IntentFilter { + val intentFilter = IntentFilter(cancelAction(service)) + intentFilter.addDataScheme(SCHEME) + intentFilter.addDataPath(startId.toString(), PatternMatcher.PATTERN_LITERAL) + return intentFilter + } + + fun createCancelIntent(service: CoroutineIntentService, startId: Int): Intent { + return Intent(cancelAction(service)) + .setData("$SCHEME://$startId".toUri()) + } + + private fun cancelAction(service: CoroutineIntentService) = service.javaClass.name + ACTION_SUFFIX_CANCEL + } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/list/FavouritesListFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/list/FavouritesListFragment.kt index 6a08dbcfa..0f632f025 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/list/FavouritesListFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/list/FavouritesListFragment.kt @@ -10,7 +10,6 @@ import androidx.fragment.app.viewModels import com.google.android.material.dialog.MaterialAlertDialogBuilder import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.model.isLocal import org.koitharu.kotatsu.core.ui.list.ListSelectionController import org.koitharu.kotatsu.core.util.ext.sortedByOrdinal import org.koitharu.kotatsu.core.util.ext.withArgs @@ -58,11 +57,6 @@ class FavouritesListFragment : MangaListFragment(), PopupMenu.OnMenuItemClickLis return super.onCreateActionMode(controller, mode, menu) } - override fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean { - menu.findItem(R.id.action_save)?.isVisible = selectedItems.none { it.isLocal } - return super.onPrepareActionMode(controller, mode, menu) - } - override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode, item: MenuItem): Boolean { return when (item.itemId) { R.id.action_remove -> { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/history/data/EntityMapping.kt b/app/src/main/kotlin/org/koitharu/kotatsu/history/data/EntityMapping.kt index 664fbbc0d..436fc1657 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/history/data/EntityMapping.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/history/data/EntityMapping.kt @@ -10,4 +10,5 @@ fun HistoryEntity.toMangaHistory() = MangaHistory( page = page, scroll = scroll.toInt(), percent = percent, + chaptersCount = chaptersCount, ) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListFragment.kt index f9f54e021..da7a1d1c6 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListFragment.kt @@ -5,10 +5,9 @@ import android.view.Menu import android.view.MenuItem import androidx.appcompat.view.ActionMode import androidx.fragment.app.viewModels -import com.google.android.material.dialog.MaterialAlertDialogBuilder import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.model.isLocal +import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog import org.koitharu.kotatsu.core.ui.list.ListSelectionController import org.koitharu.kotatsu.core.ui.list.RecyclerScrollKeeper import org.koitharu.kotatsu.core.ui.util.MenuInvalidator @@ -40,11 +39,6 @@ class HistoryListFragment : MangaListFragment() { return super.onCreateActionMode(controller, mode, menu) } - override fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean { - menu.findItem(R.id.action_save)?.isVisible = selectedItems.none { it.isLocal } - return super.onPrepareActionMode(controller, mode, menu) - } - override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode, item: MenuItem): Boolean { return when (item.itemId) { R.id.action_remove -> { @@ -54,14 +48,16 @@ class HistoryListFragment : MangaListFragment() { } R.id.action_mark_current -> { - MaterialAlertDialogBuilder(context ?: return false) - .setTitle(item.title) - .setMessage(R.string.mark_as_completed_prompt) - .setNegativeButton(android.R.string.cancel, null) - .setPositiveButton(android.R.string.ok) { _, _ -> + buildAlertDialog(context ?: return false, isCentered = true) { + setTitle(item.title) + setIcon(item.icon) + setMessage(R.string.mark_as_completed_prompt) + setNegativeButton(android.R.string.cancel, null) + setPositiveButton(android.R.string.ok) { _, _ -> viewModel.markAsRead(selectedItems) mode.finish() - }.show() + } + }.show() true } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListFragment.kt index 57b78605d..3a9f02614 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListFragment.kt @@ -20,11 +20,14 @@ import coil.ImageLoader import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.alternatives.ui.AutoFixService import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver +import org.koitharu.kotatsu.core.model.isLocal import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.core.ui.BaseFragment +import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog import org.koitharu.kotatsu.core.ui.list.FitHeightGridLayoutManager import org.koitharu.kotatsu.core.ui.list.FitHeightLinearLayoutManager import org.koitharu.kotatsu.core.ui.list.ListSelectionController @@ -278,6 +281,14 @@ abstract class MangaListFragment : } } + @CallSuper + override fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean { + val hasNoLocal = selectedItems.none { it.isLocal } + menu.findItem(R.id.action_save)?.isVisible = hasNoLocal + menu.findItem(R.id.action_fix)?.isVisible = hasNoLocal + return super.onPrepareActionMode(controller, mode, menu) + } + override fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean { return menu.isNotEmpty() } @@ -310,6 +321,20 @@ abstract class MangaListFragment : true } + R.id.action_fix -> { + buildAlertDialog(context ?: return false, isCentered = true) { + setTitle(item.title) + setIcon(item.icon) + setMessage(R.string.manga_fix_prompt) + setNegativeButton(android.R.string.cancel, null) + setPositiveButton(R.string.fix) { _, _ -> + AutoFixService.start(context, selectedItemsIds) + mode.finish() + } + }.show() + true + } + else -> false } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/ImportService.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/ImportService.kt index 45003d292..f033847c2 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/ImportService.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/ImportService.kt @@ -1,5 +1,6 @@ package org.koitharu.kotatsu.local.ui +import android.annotation.SuppressLint import android.app.Notification import android.app.PendingIntent import android.content.Context @@ -47,7 +48,7 @@ class ImportService : CoroutineIntentService() { } override suspend fun processIntent(startId: Int, intent: Intent) { - val uri = requireNotNull(intent.getStringExtra(DATA_URI)?.toUriOrNull()) { "No unput uri" } + val uri = requireNotNull(intent.getStringExtra(DATA_URI)?.toUriOrNull()) { "No input uri" } startForeground() try { val result = runCatchingCancellable { @@ -69,7 +70,8 @@ class ImportService : CoroutineIntentService() { } } - private suspend fun startForeground() { + @SuppressLint("InlinedApi") + private fun startForeground() { val title = applicationContext.getString(R.string.importing_manga) val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_DEFAULT) .setName(title) diff --git a/app/src/main/res/drawable-anydpi-v24/ic_stat_auto_fix.xml b/app/src/main/res/drawable-anydpi-v24/ic_stat_auto_fix.xml new file mode 100644 index 000000000..7c78d96b8 --- /dev/null +++ b/app/src/main/res/drawable-anydpi-v24/ic_stat_auto_fix.xml @@ -0,0 +1,17 @@ + + + + + diff --git a/app/src/main/res/drawable-hdpi/ic_stat_auto_fix.png b/app/src/main/res/drawable-hdpi/ic_stat_auto_fix.png new file mode 100644 index 0000000000000000000000000000000000000000..08f66829c1d293e854a736fdaeb6b80611ecd2be GIT binary patch literal 631 zcmV--0*L*IP)HjorQmd#R51Qf z4shHDe05Lk2y~0%Q6TXT)YR)^V!6w{5b$2ooc!wKW^WWoyr~N$m9 zz7X&p*PQ3j1!&U@*cS!zYS0SB%Fu=zO9S3ERHfJz=#CqULLwh+1H5mbALcIboR2(f z=LU;6%{GXA0{5Yd?3oxS{z{v9V*WMsTgT_wTR15CQHwX6*c|jz?463*zOjH}FSMD5 zp1F$Hm*5u96#@!<&}Iz(Z4F{;I`^LE#tw?Sbec)H?94yVSpWPh6d7!jk0t1lUcctt zv4bM(PVqFNUz#I>A|0+8AbOi*)B_fYEv_2CduI@T2c2=o=IFUCqkN!A{5EwsHvw~p z)bma38MbW(?8`(3$3YgE+XT-l<Fu{#6Ar%L~WBn?CF1+;|9d9aqJ(MJUU!6NQ-j| z1I0dbb)xv=z(wrs{mxw|-j`nCi2aV?rk=5H;-HDHHuIL}rUsgs=h%#C^A}%>VCxTi R*AM^z002ovPDHLkV1hhwCr1DP literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-mdpi/ic_stat_auto_fix.png b/app/src/main/res/drawable-mdpi/ic_stat_auto_fix.png new file mode 100644 index 0000000000000000000000000000000000000000..47978e54385a35095180a9f6dd288fc7b32df8ca GIT binary patch literal 404 zcmV;F0c-w=P)YLCK>(;2kyn?8L9L zcTo#A&!9J7Uf|XTXkhb|q2$YyYqC434F%7((c`|Npq%xkj$_J71p>z+cxiDzNvEYN9PRms)Cv)o7YJGm2IO#N&QuXeF2{~tHGa-%qHzD yb-)X1*=#Lbk>o%9g1*g+4W-lSfYIChmwW>@f*|o7oMLSj#9sHG*c6PiHWx4) z3yj|&)`-1(7=9o+SnmpY&jvp45nYJu!Dquf)_WoL+c&JE{2atb#8sVl1%2&-?*T-! z%A51OL_F4dZ(!7?Pr#tIT4EM5Fd9Ifl417=xU*j_C zN(lztqlUhLIITX9u)b7az{U2o--mtZW-o&{+(w~k z=bRTX+D}8dvWvZHptcb)f@q7VEk!)%ycaMU)DvZ(hrJlFj~C_@vR~z%1^HgB^#Ue` z)IeF-%3e`GH(&Lbx3U@^SeqFMudrF@W3MEjf5HY}3F|TegX8Sz$Ts#$L+tLJ#_SKp zzNw`m{+CVaHP49M<$KFUV34&=pz#PhIZ|OS7o`3;Vzz9|o$hJf^3T24M9^?jHD|%= z*#>=WLI0>ZR{3ROw{PxfeInxjgRQWaE9mW)gl`$O4dSo`UnM+~GqHfWbRC d*Py{a!Y?H=iG&r5gZ}^k002ovPDHLkV1mM;o^b#G literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xxhdpi/ic_stat_auto_fix.png b/app/src/main/res/drawable-xxhdpi/ic_stat_auto_fix.png new file mode 100644 index 0000000000000000000000000000000000000000..9df83c85a094b44f8296a3341fdb75dbbf4aaeb4 GIT binary patch literal 1274 zcmVt*7+gJ zr!0$9r_g2-%LSDugTd~^|2(C7gYrjMu6mp0s&5PHyou#sU;nFiU10No*BcvDpA@+N zV8&l8k9qlhEWZT0@6qz|BRM!bXs@a&6qLzA<6_7bMU>RF@0} zJC2G6R%<-V_hp@E0_*$7lBDt?;D+K(1b0|6P;$qVLBF{wQ{WE3M_4{qT}YL>im*hf ztTV9jKVlR+#?*eK(M#ZlY!NqRiJnO?0Y6Am_Q|y!Bj&5$tbiT=Rg$sK%byjwQE5@= z4ag_w9nn86dfRtJT?uC_$PcUCYGY9TlGxcUeW}{@3U=@}>GUUR6GHuSVs8i2e%?v! z*dgicdumez?OqVSJ!;c=kl*Kgt~O=R{_DW^C)IZv6zp@L7L5 z9c5p)>BkJ$5|SEUYw9l%yvK+8+AaKms`k;O5X7I5Qzd5s+`h+FuqCQD z;O3|IeLLP%eQj9DQD0ds4rz>7z-?I9sk{id-=Nz$1ZGciGT7-=nuRg+e^`x!onM6VQ}RjmnRPG;D>W;gBWgQqf%_G6_>4E%ub4Xx k6LXKs1_lNO1_q|ee|;OYP27M0!vFvP07*qoM6N<$f+jI%!2kdN literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable/ic_auto_fix.xml b/app/src/main/res/drawable/ic_auto_fix.xml new file mode 100644 index 000000000..3c63ca799 --- /dev/null +++ b/app/src/main/res/drawable/ic_auto_fix.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_heart.xml b/app/src/main/res/drawable/ic_heart.xml index 3d86975e1..a39f5cb42 100644 --- a/app/src/main/res/drawable/ic_heart.xml +++ b/app/src/main/res/drawable/ic_heart.xml @@ -6,6 +6,6 @@ android:viewportWidth="24" android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_heart_outline.xml b/app/src/main/res/drawable/ic_heart_outline.xml index 79f083a80..79caa9949 100644 --- a/app/src/main/res/drawable/ic_heart_outline.xml +++ b/app/src/main/res/drawable/ic_heart_outline.xml @@ -6,6 +6,6 @@ android:viewportWidth="24" android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_save.xml b/app/src/main/res/drawable/ic_save.xml index 8ec39892f..1d98b66a1 100644 --- a/app/src/main/res/drawable/ic_save.xml +++ b/app/src/main/res/drawable/ic_save.xml @@ -6,6 +6,6 @@ android:viewportWidth="24" android:viewportHeight="24"> diff --git a/app/src/main/res/menu/mode_favourites.xml b/app/src/main/res/menu/mode_favourites.xml index 4c5d3897e..347d3cd26 100644 --- a/app/src/main/res/menu/mode_favourites.xml +++ b/app/src/main/res/menu/mode_favourites.xml @@ -21,6 +21,12 @@ android:title="@string/save" app:showAsAction="ifRoom|withText" /> + + + + Background downloads Download new chapters Manga with downloaded chapters + Manga %1$s(%2$s) replaced with %3$s(%4$s) + Fixing manga + Fixed successfully + No fix required for %s + No alternatives found for %s + This function will find alternative sources for the selected manga. The task will take some time and will proceed in the background