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 000000000..08f66829c
Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_stat_auto_fix.png differ
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 000000000..47978e543
Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_stat_auto_fix.png differ
diff --git a/app/src/main/res/drawable-xhdpi/ic_stat_auto_fix.png b/app/src/main/res/drawable-xhdpi/ic_stat_auto_fix.png
new file mode 100644
index 000000000..0b322b028
Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_stat_auto_fix.png differ
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 000000000..9df83c85a
Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_stat_auto_fix.png differ
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