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.POST_NOTIFICATIONS" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
<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_INSTALL_PACKAGES" />
|
||||||
<uses-permission android:name="android.permission.REQUEST_DELETE_PACKAGES"/>
|
<uses-permission android:name="android.permission.REQUEST_DELETE_PACKAGES" />
|
||||||
<uses-permission
|
<uses-permission
|
||||||
android:name="android.permission.QUERY_ALL_PACKAGES"
|
android:name="android.permission.QUERY_ALL_PACKAGES"
|
||||||
tools:ignore="QueryAllPackagesPermission" />
|
tools:ignore="QueryAllPackagesPermission" />
|
||||||
@@ -273,6 +273,9 @@
|
|||||||
<service
|
<service
|
||||||
android:name="org.koitharu.kotatsu.local.ui.ImportService"
|
android:name="org.koitharu.kotatsu.local.ui.ImportService"
|
||||||
android:foregroundServiceType="dataSync" />
|
android:foregroundServiceType="dataSync" />
|
||||||
|
<service
|
||||||
|
android:name="org.koitharu.kotatsu.alternatives.ui.AutoFixService"
|
||||||
|
android:foregroundServiceType="dataSync" />
|
||||||
<service
|
<service
|
||||||
android:name="org.koitharu.kotatsu.widget.shelf.ShelfWidgetService"
|
android:name="org.koitharu.kotatsu.widget.shelf.ShelfWidgetService"
|
||||||
android:permission="android.permission.BIND_REMOTEVIEWS" />
|
android:permission="android.permission.BIND_REMOTEVIEWS" />
|
||||||
|
|||||||
@@ -18,14 +18,16 @@ import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
|||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
private const val MAX_PARALLELISM = 4
|
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(
|
class AlternativesUseCase @Inject constructor(
|
||||||
private val sourcesRepository: MangaSourcesRepository,
|
private val sourcesRepository: MangaSourcesRepository,
|
||||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
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)
|
val sources = getSources(manga.source)
|
||||||
if (sources.isEmpty()) {
|
if (sources.isEmpty()) {
|
||||||
return emptyFlow()
|
return emptyFlow()
|
||||||
@@ -44,7 +46,7 @@ class AlternativesUseCase @Inject constructor(
|
|||||||
}
|
}
|
||||||
}.getOrDefault(emptyList())
|
}.getOrDefault(emptyList())
|
||||||
for (item in list) {
|
for (item in list) {
|
||||||
if (item.matches(manga)) {
|
if (item.matches(manga, matchThreshold)) {
|
||||||
send(item)
|
send(item)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -65,16 +67,16 @@ class AlternativesUseCase @Inject constructor(
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Manga.matches(ref: Manga): Boolean {
|
private fun Manga.matches(ref: Manga, threshold: Float): Boolean {
|
||||||
return matchesTitles(title, ref.title) ||
|
return matchesTitles(title, ref.title, threshold) ||
|
||||||
matchesTitles(title, ref.altTitle) ||
|
matchesTitles(title, ref.altTitle, threshold) ||
|
||||||
matchesTitles(altTitle, ref.title) ||
|
matchesTitles(altTitle, ref.title, threshold) ||
|
||||||
matchesTitles(altTitle, ref.altTitle)
|
matchesTitles(altTitle, ref.altTitle, threshold)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun matchesTitles(a: String?, b: String?): Boolean {
|
private fun matchesTitles(a: String?, b: String?, threshold: Float): Boolean {
|
||||||
return !a.isNullOrEmpty() && !b.isNullOrEmpty() && a.almostEquals(b, MATCH_THRESHOLD)
|
return !a.isNullOrEmpty() && !b.isNullOrEmpty() && a.almostEquals(b, threshold)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun MangaSource.priority(ref: MangaSource): Int {
|
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(
|
return HistoryEntity(
|
||||||
mangaId = newManga.id,
|
mangaId = newManga.id,
|
||||||
createdAt = history.createdAt,
|
createdAt = history.createdAt,
|
||||||
updatedAt = System.currentTimeMillis(),
|
updatedAt = history.updatedAt,
|
||||||
chapterId = currentChapter.id,
|
chapterId = currentChapter.id,
|
||||||
page = history.page,
|
page = history.page,
|
||||||
scroll = history.scroll,
|
scroll = history.scroll,
|
||||||
@@ -173,7 +173,7 @@ constructor(
|
|||||||
return HistoryEntity(
|
return HistoryEntity(
|
||||||
mangaId = newManga.id,
|
mangaId = newManga.id,
|
||||||
createdAt = history.createdAt,
|
createdAt = history.createdAt,
|
||||||
updatedAt = System.currentTimeMillis(),
|
updatedAt = history.updatedAt,
|
||||||
chapterId = newChapterId,
|
chapterId = newChapterId,
|
||||||
page = history.page,
|
page = history.page,
|
||||||
scroll = history.scroll,
|
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
|
val Manga.isLocal: Boolean
|
||||||
get() = source == LocalMangaSource
|
get() = source == LocalMangaSource
|
||||||
|
|
||||||
|
val Manga.isBroken: Boolean
|
||||||
|
get() = source == UnknownMangaSource
|
||||||
|
|
||||||
val Manga.appUrl: Uri
|
val Manga.appUrl: Uri
|
||||||
get() = Uri.parse("https://kotatsu.app/manga").buildUpon()
|
get() = Uri.parse("https://kotatsu.app/manga").buildUpon()
|
||||||
.appendQueryParameter("source", source.name)
|
.appendQueryParameter("source", source.name)
|
||||||
|
|||||||
@@ -12,4 +12,5 @@ data class MangaHistory(
|
|||||||
val page: Int,
|
val page: Int,
|
||||||
val scroll: Int,
|
val scroll: Int,
|
||||||
val percent: Float,
|
val percent: Float,
|
||||||
|
val chaptersCount: Int,
|
||||||
) : Parcelable
|
) : Parcelable
|
||||||
|
|||||||
@@ -1,12 +1,21 @@
|
|||||||
package org.koitharu.kotatsu.core.ui
|
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.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
|
import android.os.PatternMatcher
|
||||||
import androidx.annotation.AnyThread
|
import androidx.annotation.AnyThread
|
||||||
import androidx.annotation.WorkerThread
|
import androidx.annotation.WorkerThread
|
||||||
|
import androidx.core.app.PendingIntentCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.net.toUri
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.CoroutineExceptionHandler
|
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
@@ -20,7 +29,15 @@ abstract class CoroutineIntentService : BaseService() {
|
|||||||
|
|
||||||
final override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
final override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
super.onStartCommand(intent, flags, startId)
|
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
|
return START_REDELIVER_INTENT
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,8 +64,45 @@ abstract class CoroutineIntentService : BaseService() {
|
|||||||
@AnyThread
|
@AnyThread
|
||||||
protected abstract fun onError(startId: Int, error: Throwable)
|
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 ->
|
private fun errorHandler(startId: Int) = CoroutineExceptionHandler { _, throwable ->
|
||||||
throwable.printStackTraceDebug()
|
throwable.printStackTraceDebug()
|
||||||
onError(startId, throwable)
|
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 com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import org.koitharu.kotatsu.R
|
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.ui.list.ListSelectionController
|
||||||
import org.koitharu.kotatsu.core.util.ext.sortedByOrdinal
|
import org.koitharu.kotatsu.core.util.ext.sortedByOrdinal
|
||||||
import org.koitharu.kotatsu.core.util.ext.withArgs
|
import org.koitharu.kotatsu.core.util.ext.withArgs
|
||||||
@@ -58,11 +57,6 @@ class FavouritesListFragment : MangaListFragment(), PopupMenu.OnMenuItemClickLis
|
|||||||
return super.onCreateActionMode(controller, mode, menu)
|
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 {
|
override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode, item: MenuItem): Boolean {
|
||||||
return when (item.itemId) {
|
return when (item.itemId) {
|
||||||
R.id.action_remove -> {
|
R.id.action_remove -> {
|
||||||
|
|||||||
@@ -10,4 +10,5 @@ fun HistoryEntity.toMangaHistory() = MangaHistory(
|
|||||||
page = page,
|
page = page,
|
||||||
scroll = scroll.toInt(),
|
scroll = scroll.toInt(),
|
||||||
percent = percent,
|
percent = percent,
|
||||||
|
chaptersCount = chaptersCount,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,10 +5,9 @@ import android.view.Menu
|
|||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
import androidx.appcompat.view.ActionMode
|
import androidx.appcompat.view.ActionMode
|
||||||
import androidx.fragment.app.viewModels
|
import androidx.fragment.app.viewModels
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import org.koitharu.kotatsu.R
|
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.ListSelectionController
|
||||||
import org.koitharu.kotatsu.core.ui.list.RecyclerScrollKeeper
|
import org.koitharu.kotatsu.core.ui.list.RecyclerScrollKeeper
|
||||||
import org.koitharu.kotatsu.core.ui.util.MenuInvalidator
|
import org.koitharu.kotatsu.core.ui.util.MenuInvalidator
|
||||||
@@ -40,11 +39,6 @@ class HistoryListFragment : MangaListFragment() {
|
|||||||
return super.onCreateActionMode(controller, mode, menu)
|
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 {
|
override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode, item: MenuItem): Boolean {
|
||||||
return when (item.itemId) {
|
return when (item.itemId) {
|
||||||
R.id.action_remove -> {
|
R.id.action_remove -> {
|
||||||
@@ -54,14 +48,16 @@ class HistoryListFragment : MangaListFragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
R.id.action_mark_current -> {
|
R.id.action_mark_current -> {
|
||||||
MaterialAlertDialogBuilder(context ?: return false)
|
buildAlertDialog(context ?: return false, isCentered = true) {
|
||||||
.setTitle(item.title)
|
setTitle(item.title)
|
||||||
.setMessage(R.string.mark_as_completed_prompt)
|
setIcon(item.icon)
|
||||||
.setNegativeButton(android.R.string.cancel, null)
|
setMessage(R.string.mark_as_completed_prompt)
|
||||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
setNegativeButton(android.R.string.cancel, null)
|
||||||
|
setPositiveButton(android.R.string.ok) { _, _ ->
|
||||||
viewModel.markAsRead(selectedItems)
|
viewModel.markAsRead(selectedItems)
|
||||||
mode.finish()
|
mode.finish()
|
||||||
}.show()
|
}
|
||||||
|
}.show()
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,11 +20,14 @@ import coil.ImageLoader
|
|||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.koitharu.kotatsu.R
|
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.ExceptionResolver
|
||||||
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
|
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.AppSettings
|
||||||
import org.koitharu.kotatsu.core.prefs.ListMode
|
import org.koitharu.kotatsu.core.prefs.ListMode
|
||||||
import org.koitharu.kotatsu.core.ui.BaseFragment
|
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.FitHeightGridLayoutManager
|
||||||
import org.koitharu.kotatsu.core.ui.list.FitHeightLinearLayoutManager
|
import org.koitharu.kotatsu.core.ui.list.FitHeightLinearLayoutManager
|
||||||
import org.koitharu.kotatsu.core.ui.list.ListSelectionController
|
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 {
|
override fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
|
||||||
return menu.isNotEmpty()
|
return menu.isNotEmpty()
|
||||||
}
|
}
|
||||||
@@ -310,6 +321,20 @@ abstract class MangaListFragment :
|
|||||||
true
|
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
|
else -> false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package org.koitharu.kotatsu.local.ui
|
package org.koitharu.kotatsu.local.ui
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
import android.app.Notification
|
import android.app.Notification
|
||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
@@ -47,7 +48,7 @@ class ImportService : CoroutineIntentService() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun processIntent(startId: Int, intent: Intent) {
|
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()
|
startForeground()
|
||||||
try {
|
try {
|
||||||
val result = runCatchingCancellable {
|
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 title = applicationContext.getString(R.string.importing_manga)
|
||||||
val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_DEFAULT)
|
val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_DEFAULT)
|
||||||
.setName(title)
|
.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:viewportWidth="24"
|
||||||
android:viewportHeight="24">
|
android:viewportHeight="24">
|
||||||
<path
|
<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" />
|
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>
|
</vector>
|
||||||
|
|||||||
@@ -6,6 +6,6 @@
|
|||||||
android:viewportWidth="24"
|
android:viewportWidth="24"
|
||||||
android:viewportHeight="24">
|
android:viewportHeight="24">
|
||||||
<path
|
<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" />
|
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>
|
</vector>
|
||||||
|
|||||||
@@ -6,6 +6,6 @@
|
|||||||
android:viewportWidth="24"
|
android:viewportWidth="24"
|
||||||
android:viewportHeight="24">
|
android:viewportHeight="24">
|
||||||
<path
|
<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" />
|
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>
|
</vector>
|
||||||
|
|||||||
@@ -21,6 +21,12 @@
|
|||||||
android:title="@string/save"
|
android:title="@string/save"
|
||||||
app:showAsAction="ifRoom|withText" />
|
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
|
<item
|
||||||
android:id="@+id/action_favourite"
|
android:id="@+id/action_favourite"
|
||||||
android:icon="@drawable/ic_heart"
|
android:icon="@drawable/ic_heart"
|
||||||
|
|||||||
@@ -27,6 +27,12 @@
|
|||||||
android:title="@string/add_to_favourites"
|
android:title="@string/add_to_favourites"
|
||||||
app:showAsAction="ifRoom|withText" />
|
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
|
<item
|
||||||
android:id="@+id/action_mark_current"
|
android:id="@+id/action_mark_current"
|
||||||
android:icon="@drawable/ic_eye_check"
|
android:icon="@drawable/ic_eye_check"
|
||||||
|
|||||||
@@ -701,4 +701,10 @@
|
|||||||
<string name="downloads_background">Background downloads</string>
|
<string name="downloads_background">Background downloads</string>
|
||||||
<string name="download_new_chapters">Download new chapters</string>
|
<string name="download_new_chapters">Download new chapters</string>
|
||||||
<string name="manga_with_downloaded_chapters">Manga with downloaded 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>
|
</resources>
|
||||||
|
|||||||
Reference in New Issue
Block a user