Batch manga fix functionality
This commit is contained in:
@@ -20,7 +20,7 @@
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<uses-permission android:name="android.permission.REQUEST_DELETE_PACKAGES"/>
|
||||
<uses-permission android:name="android.permission.REQUEST_DELETE_PACKAGES" />
|
||||
<uses-permission
|
||||
android:name="android.permission.QUERY_ALL_PACKAGES"
|
||||
tools:ignore="QueryAllPackagesPermission" />
|
||||
@@ -273,6 +273,9 @@
|
||||
<service
|
||||
android:name="org.koitharu.kotatsu.local.ui.ImportService"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
<service
|
||||
android:name="org.koitharu.kotatsu.alternatives.ui.AutoFixService"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
<service
|
||||
android:name="org.koitharu.kotatsu.widget.shelf.ShelfWidgetService"
|
||||
android:permission="android.permission.BIND_REMOTEVIEWS" />
|
||||
|
||||
@@ -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<Manga> {
|
||||
suspend operator fun invoke(manga: Manga): Flow<Manga> = invoke(manga, MATCH_THRESHOLD_DEFAULT)
|
||||
|
||||
suspend operator fun invoke(manga: Manga, matchThreshold: Float): Flow<Manga> {
|
||||
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 {
|
||||
|
||||
@@ -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<Manga, Manga?> {
|
||||
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<Manga, Manga?>(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 <T> Flow<T>.selectLastWithTimeout(
|
||||
minCount: Int,
|
||||
timeout: Long,
|
||||
timeUnit: TimeUnit
|
||||
): T? = channelFlow<T?> {
|
||||
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()
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Pair<Manga, Manga?>>): 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<Long>): 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -12,4 +12,5 @@ data class MangaHistory(
|
||||
val page: Int,
|
||||
val scroll: Int,
|
||||
val percent: Float,
|
||||
val chaptersCount: Int,
|
||||
) : Parcelable
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 -> {
|
||||
|
||||
@@ -10,4 +10,5 @@ fun HistoryEntity.toMangaHistory() = MangaHistory(
|
||||
page = page,
|
||||
scroll = scroll.toInt(),
|
||||
percent = percent,
|
||||
chaptersCount = chaptersCount,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
17
app/src/main/res/drawable-anydpi-v24/ic_stat_auto_fix.xml
Normal file
17
app/src/main/res/drawable-anydpi-v24/ic_stat_auto_fix.xml
Normal file
@@ -0,0 +1,17 @@
|
||||
<vector
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="#FFFFFF"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<group
|
||||
android:scaleX="0.92"
|
||||
android:scaleY="0.92"
|
||||
android:translateX="0.96"
|
||||
android:translateY="0.96">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M7.5,5.6L5,7L6.4,4.5L5,2L7.5,3.4L10,2L8.6,4.5L10,7L7.5,5.6M19.5,15.4L22,14L20.6,16.5L22,19L19.5,17.6L17,19L18.4,16.5L17,14L19.5,15.4M22,2L20.6,4.5L22,7L19.5,5.6L17,7L18.4,4.5L17,2L19.5,3.4L22,2M13.34,12.78L15.78,10.34L13.66,8.22L11.22,10.66L13.34,12.78M14.37,7.29L16.71,9.63C17.1,10 17.1,10.65 16.71,11.04L5.04,22.71C4.65,23.1 4,23.1 3.63,22.71L1.29,20.37C0.9,20 0.9,19.35 1.29,18.96L12.96,7.29C13.35,6.9 14,6.9 14.37,7.29Z" />
|
||||
</group>
|
||||
</vector>
|
||||
BIN
app/src/main/res/drawable-hdpi/ic_stat_auto_fix.png
Normal file
BIN
app/src/main/res/drawable-hdpi/ic_stat_auto_fix.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 631 B |
BIN
app/src/main/res/drawable-mdpi/ic_stat_auto_fix.png
Normal file
BIN
app/src/main/res/drawable-mdpi/ic_stat_auto_fix.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 404 B |
BIN
app/src/main/res/drawable-xhdpi/ic_stat_auto_fix.png
Normal file
BIN
app/src/main/res/drawable-xhdpi/ic_stat_auto_fix.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 851 B |
BIN
app/src/main/res/drawable-xxhdpi/ic_stat_auto_fix.png
Normal file
BIN
app/src/main/res/drawable-xxhdpi/ic_stat_auto_fix.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
11
app/src/main/res/drawable/ic_auto_fix.xml
Normal file
11
app/src/main/res/drawable/ic_auto_fix.xml
Normal file
@@ -0,0 +1,11 @@
|
||||
<vector
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="#000"
|
||||
android:pathData="M7.5,5.6L5,7L6.4,4.5L5,2L7.5,3.4L10,2L8.6,4.5L10,7L7.5,5.6M19.5,15.4L22,14L20.6,16.5L22,19L19.5,17.6L17,19L18.4,16.5L17,14L19.5,15.4M22,2L20.6,4.5L22,7L19.5,5.6L17,7L18.4,4.5L17,2L19.5,3.4L22,2M13.34,12.78L15.78,10.34L13.66,8.22L11.22,10.66L13.34,12.78M14.37,7.29L16.71,9.63C17.1,10 17.1,10.65 16.71,11.04L5.04,22.71C4.65,23.1 4,23.1 3.63,22.71L1.29,20.37C0.9,20 0.9,19.35 1.29,18.96L12.96,7.29C13.35,6.9 14,6.9 14.37,7.29Z" />
|
||||
</vector>
|
||||
@@ -6,6 +6,6 @@
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:fillColor="#000"
|
||||
android:pathData="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41 0.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" />
|
||||
</vector>
|
||||
|
||||
@@ -6,6 +6,6 @@
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:fillColor="#000"
|
||||
android:pathData="M16.5 3c-1.74 0-3.41 0.81-4.5 2.09C10.91 3.81 9.24 3 7.5 3 4.42 3 2 5.42 2 8.5c0 3.78 3.4 6.86 8.55 11.54L12 21.35l1.45-1.32C18.6 15.36 22 12.28 22 8.5 22 5.42 19.58 3 16.5 3zm-4.4 15.55l-0.1 0.1-0.1-0.1C7.14 14.24 4 11.39 4 8.5 4 6.5 5.5 5 7.5 5c1.54 0 3.04 0.99 3.57 2.36h1.87C13.46 5.99 14.96 5 16.5 5c2 0 3.5 1.5 3.5 3.5 0 2.89-3.14 5.74-7.9 10.05z" />
|
||||
</vector>
|
||||
|
||||
@@ -6,6 +6,6 @@
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:fillColor="#000"
|
||||
android:pathData="M17 3H5C3.89 3 3 3.9 3 5v14c0 1.1 0.89 2 2 2h14c1.1 0 2-0.9 2-2V7l-4-4zm2 16H5V5h11.17L19 7.83V19zm-7-7c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3zM6 6h9v4H6z" />
|
||||
</vector>
|
||||
|
||||
@@ -21,6 +21,12 @@
|
||||
android:title="@string/save"
|
||||
app:showAsAction="ifRoom|withText" />
|
||||
|
||||
<item
|
||||
android:id="@+id/action_fix"
|
||||
android:icon="@drawable/ic_auto_fix"
|
||||
android:title="@string/fix"
|
||||
app:showAsAction="ifRoom|withText" />
|
||||
|
||||
<item
|
||||
android:id="@+id/action_favourite"
|
||||
android:icon="@drawable/ic_heart"
|
||||
|
||||
@@ -27,6 +27,12 @@
|
||||
android:title="@string/add_to_favourites"
|
||||
app:showAsAction="ifRoom|withText" />
|
||||
|
||||
<item
|
||||
android:id="@+id/action_fix"
|
||||
android:icon="@drawable/ic_auto_fix"
|
||||
android:title="@string/fix"
|
||||
app:showAsAction="ifRoom|withText" />
|
||||
|
||||
<item
|
||||
android:id="@+id/action_mark_current"
|
||||
android:icon="@drawable/ic_eye_check"
|
||||
|
||||
@@ -701,4 +701,10 @@
|
||||
<string name="downloads_background">Background downloads</string>
|
||||
<string name="download_new_chapters">Download new chapters</string>
|
||||
<string name="manga_with_downloaded_chapters">Manga with downloaded chapters</string>
|
||||
<string name="manga_replaced">Manga %1$s(%2$s) replaced with %3$s(%4$s)</string>
|
||||
<string name="fixing_manga">Fixing manga</string>
|
||||
<string name="fixed">Fixed successfully</string>
|
||||
<string name="no_fix_required">No fix required for %s</string>
|
||||
<string name="no_alternatives_found">No alternatives found for %s</string>
|
||||
<string name="manga_fix_prompt">This function will find alternative sources for the selected manga. The task will take some time and will proceed in the background</string>
|
||||
</resources>
|
||||
|
||||
Reference in New Issue
Block a user