From f4997f5a7f1fcae60173cf2c9699331f92ab7ed9 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Sat, 24 May 2025 09:43:08 +0300 Subject: [PATCH] Improve captcha notifications --- .../browser/cloudflare/CaptchaNotifier.kt | 111 -------- .../browser/cloudflare/CloudFlareActivity.kt | 20 +- .../org/koitharu/kotatsu/core/AppModule.kt | 5 +- .../kotatsu/core/db/dao/MangaSourcesDao.kt | 7 + .../exceptions/CloudFlareBlockedException.kt | 12 +- .../core/exceptions/CloudFlareException.kt | 14 + .../CloudFlareProtectedException.kt | 12 +- .../core/exceptions/resolve/CaptchaHandler.kt | 264 ++++++++++++++++++ .../kotatsu/core/ui/image/FaviconView.kt | 2 +- .../kotatsu/core/util/ext/Coroutines.kt | 15 + .../kotatsu/core/util/ext/Resources.kt | 7 + .../ui/worker/DownloadNotificationFactory.kt | 11 +- .../suggestions/ui/SuggestionsWorker.kt | 8 +- .../kotatsu/tracker/work/TrackWorker.kt | 9 +- 14 files changed, 356 insertions(+), 141 deletions(-) delete mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/browser/cloudflare/CaptchaNotifier.kt create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/CloudFlareException.kt create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/CaptchaHandler.kt diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/browser/cloudflare/CaptchaNotifier.kt b/app/src/main/kotlin/org/koitharu/kotatsu/browser/cloudflare/CaptchaNotifier.kt deleted file mode 100644 index 41ea4dc6c..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/browser/cloudflare/CaptchaNotifier.kt +++ /dev/null @@ -1,111 +0,0 @@ -package org.koitharu.kotatsu.browser.cloudflare - -import android.content.Context -import android.content.Intent -import android.os.Build -import android.provider.Settings -import androidx.core.app.NotificationChannelCompat -import androidx.core.app.NotificationCompat -import androidx.core.app.NotificationManagerCompat -import androidx.core.app.PendingIntentCompat -import androidx.core.net.toUri -import coil3.EventListener -import coil3.Extras -import coil3.request.ErrorResult -import coil3.request.ImageRequest -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException -import org.koitharu.kotatsu.core.model.getTitle -import org.koitharu.kotatsu.core.model.isNsfw -import org.koitharu.kotatsu.core.nav.AppRouter -import org.koitharu.kotatsu.core.prefs.SourceSettings -import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission -import org.koitharu.kotatsu.parsers.model.MangaSource - -class CaptchaNotifier( - private val context: Context, -) : EventListener() { - - fun notify(exception: CloudFlareProtectedException) { - if (!context.checkNotificationPermission(CHANNEL_ID)) { - return - } - if (exception.source != null && SourceSettings(context, exception.source).isCaptchaNotificationsDisabled) { - return - } - val manager = NotificationManagerCompat.from(context) - val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_LOW) - .setName(context.getString(R.string.captcha_required)) - .setShowBadge(true) - .setVibrationEnabled(false) - .setSound(null, null) - .setLightsEnabled(false) - .build() - manager.createNotificationChannel(channel) - - val intent = AppRouter.cloudFlareResolveIntent(context, exception) - .setData(exception.url.toUri()) - val notification = NotificationCompat.Builder(context, CHANNEL_ID) - .setContentTitle(channel.name) - .setPriority(NotificationCompat.PRIORITY_LOW) - .setDefaults(0) - .setSmallIcon(R.drawable.ic_bot) - .setGroup(GROUP_CAPTCHA) - .setAutoCancel(true) - .setVisibility( - if (exception.source?.isNsfw() == true) { - NotificationCompat.VISIBILITY_SECRET - } else { - NotificationCompat.VISIBILITY_PUBLIC - }, - ) - .setContentText( - context.getString( - R.string.captcha_required_summary, - exception.source?.getTitle(context) ?: context.getString(R.string.app_name), - ), - ) - .setContentIntent(PendingIntentCompat.getActivity(context, 0, intent, 0, false)) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val actionIntent = PendingIntentCompat.getActivity( - context, SETTINGS_ACTION_CODE, - Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS) - .putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) - .putExtra(Settings.EXTRA_CHANNEL_ID, CHANNEL_ID), - 0, false, - ) - notification.addAction( - R.drawable.ic_settings, - context.getString(R.string.notifications_settings), - actionIntent, - ) - } - manager.notify(TAG, exception.source.hashCode(), notification.build()) - } - - fun dismiss(source: MangaSource) { - NotificationManagerCompat.from(context).cancel(TAG, source.hashCode()) - } - - override fun onError(request: ImageRequest, result: ErrorResult) { - super.onError(request, result) - val e = result.throwable - if (e is CloudFlareProtectedException && request.extras[ignoreCaptchaKey] != true) { - notify(e) - } - } - - companion object { - - fun ImageRequest.Builder.ignoreCaptchaErrors() = apply { - extras[ignoreCaptchaKey] = true - } - - val ignoreCaptchaKey = Extras.Key(false) - - private const val CHANNEL_ID = "captcha" - private const val TAG = CHANNEL_ID - private const val GROUP_CAPTCHA = "org.koitharu.kotatsu.CAPTCHA" - private const val SETTINGS_ACTION_CODE = 3 - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/browser/cloudflare/CloudFlareActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/browser/cloudflare/CloudFlareActivity.kt index cb17ca1cc..b703858db 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/browser/cloudflare/CloudFlareActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/browser/cloudflare/CloudFlareActivity.kt @@ -19,14 +19,17 @@ import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import org.koitharu.kotatsu.R import org.koitharu.kotatsu.browser.BaseBrowserActivity import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException +import org.koitharu.kotatsu.core.exceptions.resolve.CaptchaHandler import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar import org.koitharu.kotatsu.core.parser.ParserMangaRepository import org.koitharu.kotatsu.core.util.ext.getDisplayMessage +import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.network.CloudFlareHelper import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty +import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import javax.inject.Inject @AndroidEntryPoint @@ -37,6 +40,9 @@ class CloudFlareActivity : BaseBrowserActivity(), CloudFlareCallback { @Inject lateinit var cookieJar: MutableCookieJar + @Inject + lateinit var captchaHandler: CaptchaHandler + private lateinit var cfClient: CloudFlareClient override fun onCreate2(savedInstanceState: Bundle?, source: MangaSource, repository: ParserMangaRepository?) { @@ -98,11 +104,17 @@ class CloudFlareActivity : BaseBrowserActivity(), CloudFlareCallback { override fun onCheckPassed() { pendingResult = RESULT_OK - val source = intent?.getStringExtra(AppRouter.KEY_SOURCE) - if (source != null) { - CaptchaNotifier(this).dismiss(MangaSource(source)) + lifecycleScope.launch { + val source = intent?.getStringExtra(AppRouter.KEY_SOURCE) + if (source != null) { + runCatchingCancellable { + captchaHandler.discard(MangaSource(source)) + }.onFailure { + it.printStackTraceDebug() + } + } + finishAfterTransition() } - finishAfterTransition() } override fun onTitleChanged(title: CharSequence, subtitle: CharSequence?) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/AppModule.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/AppModule.kt index d9e402e05..92e4d71ab 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/AppModule.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/AppModule.kt @@ -31,8 +31,8 @@ import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow import okhttp3.OkHttpClient import org.koitharu.kotatsu.BuildConfig -import org.koitharu.kotatsu.browser.cloudflare.CaptchaNotifier import org.koitharu.kotatsu.core.db.MangaDatabase +import org.koitharu.kotatsu.core.exceptions.resolve.CaptchaHandler import org.koitharu.kotatsu.core.image.AvifImageDecoder import org.koitharu.kotatsu.core.image.CbzFetcher import org.koitharu.kotatsu.core.image.MangaSourceHeaderInterceptor @@ -106,6 +106,7 @@ interface AppModule { pageFetcherFactory: MangaPageFetcher.Factory, coverRestoreInterceptor: CoverRestoreInterceptor, networkStateProvider: Provider, + captchaHandler: CaptchaHandler, ): ImageLoader { val diskCacheFactory = { val rootDir = context.externalCacheDir ?: context.cacheDir @@ -121,7 +122,7 @@ interface AppModule { .diskCache(diskCacheFactory) .logger(if (BuildConfig.DEBUG) DebugLogger() else null) .allowRgb565(context.isLowRamDevice()) - .eventListener(CaptchaNotifier(context)) + .eventListener(captchaHandler) .components { add( OkHttpNetworkFetcherFactory( diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/dao/MangaSourcesDao.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/dao/MangaSourcesDao.kt index bc61aba4c..474e4095a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/dao/MangaSourcesDao.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/dao/MangaSourcesDao.kt @@ -14,6 +14,7 @@ import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity import org.koitharu.kotatsu.explore.data.SourcesSortOrder import org.koitharu.kotatsu.parsers.network.CloudFlareHelper +import org.koitharu.kotatsu.parsers.network.CloudFlareHelper.PROTECTION_CAPTCHA @Dao abstract class MangaSourcesDao { @@ -51,6 +52,9 @@ abstract class MangaSourcesDao { @Query("UPDATE sources SET pinned = :isPinned WHERE source = :source") abstract suspend fun setPinned(source: String, isPinned: Boolean) + @Query("UPDATE sources SET cf_state = :state WHERE source = :source") + abstract suspend fun setCfState(source: String, state: Int) + @Insert(onConflict = OnConflictStrategy.IGNORE) @Transaction abstract suspend fun insertIfAbsent(entries: Collection) @@ -61,6 +65,9 @@ abstract class MangaSourcesDao { @Query("SELECT * FROM sources WHERE pinned = 1") abstract suspend fun findAllPinned(): List + @Query("SELECT * FROM sources WHERE cf_state = $PROTECTION_CAPTCHA") + abstract suspend fun findAllCaptchaRequired(): List + fun observeAll(enabledOnly: Boolean, order: SourcesSortOrder): Flow> = observeImpl(getQuery(enabledOnly, order)) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/CloudFlareBlockedException.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/CloudFlareBlockedException.kt index 4860e4880..614abea7f 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/CloudFlareBlockedException.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/CloudFlareBlockedException.kt @@ -1,9 +1,13 @@ package org.koitharu.kotatsu.core.exceptions -import okio.IOException +import org.koitharu.kotatsu.core.model.UnknownMangaSource import org.koitharu.kotatsu.parsers.model.MangaSource +import org.koitharu.kotatsu.parsers.network.CloudFlareHelper class CloudFlareBlockedException( - val url: String, - val source: MangaSource?, -) : IOException("Blocked by CloudFlare") + override val url: String, + source: MangaSource?, +) : CloudFlareException("Blocked by CloudFlare", CloudFlareHelper.PROTECTION_BLOCKED) { + + override val source: MangaSource = source ?: UnknownMangaSource +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/CloudFlareException.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/CloudFlareException.kt new file mode 100644 index 000000000..8aa5e6db5 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/CloudFlareException.kt @@ -0,0 +1,14 @@ +package org.koitharu.kotatsu.core.exceptions + +import okio.IOException +import org.koitharu.kotatsu.parsers.model.MangaSource + +abstract class CloudFlareException( + message: String, + val state: Int, +) : IOException(message) { + + abstract val url: String + + abstract val source: MangaSource +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/CloudFlareProtectedException.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/CloudFlareProtectedException.kt index 23b2523a0..4b254a00d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/CloudFlareProtectedException.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/CloudFlareProtectedException.kt @@ -1,11 +1,15 @@ package org.koitharu.kotatsu.core.exceptions import okhttp3.Headers -import okio.IOException +import org.koitharu.kotatsu.core.model.UnknownMangaSource import org.koitharu.kotatsu.parsers.model.MangaSource +import org.koitharu.kotatsu.parsers.network.CloudFlareHelper class CloudFlareProtectedException( - val url: String, - val source: MangaSource?, + override val url: String, + source: MangaSource?, @Transient val headers: Headers, -) : IOException("Protected by CloudFlare") +) : CloudFlareException("Protected by CloudFlare", CloudFlareHelper.PROTECTION_CAPTCHA) { + + override val source: MangaSource = source ?: UnknownMangaSource +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/CaptchaHandler.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/CaptchaHandler.kt new file mode 100644 index 000000000..7c37cc9e3 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/CaptchaHandler.kt @@ -0,0 +1,264 @@ +package org.koitharu.kotatsu.core.exceptions.resolve + +import android.Manifest +import android.app.Notification +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.Build +import android.provider.Settings +import androidx.annotation.RequiresPermission +import androidx.collection.MutableScatterMap +import androidx.core.app.NotificationChannelCompat +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.app.PendingIntentCompat +import androidx.core.content.ContextCompat +import androidx.core.net.toUri +import androidx.lifecycle.coroutineScope +import coil3.EventListener +import coil3.Extras +import coil3.ImageLoader +import coil3.request.ErrorResult +import coil3.request.ImageRequest +import coil3.request.allowConversionToBitmap +import coil3.request.allowHardware +import coil3.request.lifecycle +import coil3.size.Scale +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.LocalizedAppContext +import org.koitharu.kotatsu.core.db.MangaDatabase +import org.koitharu.kotatsu.core.exceptions.CloudFlareException +import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException +import org.koitharu.kotatsu.core.model.MangaSource +import org.koitharu.kotatsu.core.model.UnknownMangaSource +import org.koitharu.kotatsu.core.model.getTitle +import org.koitharu.kotatsu.core.model.isNsfw +import org.koitharu.kotatsu.core.nav.AppRouter +import org.koitharu.kotatsu.core.parser.favicon.faviconUri +import org.koitharu.kotatsu.core.prefs.SourceSettings +import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission +import org.koitharu.kotatsu.core.util.ext.getNotificationIconSize +import org.koitharu.kotatsu.core.util.ext.goAsync +import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra +import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug +import org.koitharu.kotatsu.core.util.ext.processLifecycleScope +import org.koitharu.kotatsu.core.util.ext.toBitmapOrNull +import org.koitharu.kotatsu.parsers.model.MangaSource +import org.koitharu.kotatsu.parsers.network.CloudFlareHelper +import org.koitharu.kotatsu.parsers.util.runCatchingCancellable +import javax.inject.Inject +import javax.inject.Provider +import javax.inject.Singleton + +@Singleton +class CaptchaHandler @Inject constructor( + @LocalizedAppContext private val context: Context, + private val databaseProvider: Provider, + private val coilProvider: Provider, +) : EventListener() { + + private val exceptionMap = MutableScatterMap() + private val mutex = Mutex() + + init { + ContextCompat.registerReceiver( + context, + object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + val sourceName = intent?.getStringExtra(AppRouter.KEY_SOURCE) ?: return + goAsync { + discard(MangaSource(sourceName)) + } + } + }, + IntentFilter().apply { addAction(ACTION_DISCARD) }, + ContextCompat.RECEIVER_NOT_EXPORTED, + ) + } + + suspend fun handle(exception: CloudFlareException): Boolean = handleException(exception.source, exception) + + suspend fun discard(source: MangaSource) { + handleException(source, null) + } + + override fun onError(request: ImageRequest, result: ErrorResult) { + super.onError(request, result) + val e = result.throwable + if (e is CloudFlareException && request.extras[ignoreCaptchaKey] != true) { + val scope = request.lifecycle?.coroutineScope ?: processLifecycleScope + scope.launch { + handleException(e.source, e) + } + } + } + + private suspend fun handleException( + source: MangaSource, + exception: CloudFlareException? + ): Boolean = withContext(Dispatchers.Default) { + if (source == UnknownMangaSource) { + return@withContext false + } + mutex.withLock { + if (exception is CloudFlareProtectedException) { + exceptionMap[source] = exception + } else { + exceptionMap.remove(source) + } + val dao = databaseProvider.get().getSourcesDao() + dao.setCfState(source.name, exception?.state ?: CloudFlareHelper.PROTECTION_NOT_DETECTED) + + val exceptions = dao.findAllCaptchaRequired().mapNotNull { + it.source.toMangaSourceOrNull() + }.filterNot { + SourceSettings(context, it).isCaptchaNotificationsDisabled + }.mapNotNull { + exceptionMap[it] + } + if (exceptions.isNotEmpty() && context.checkNotificationPermission(CHANNEL_ID)) { + notify(exceptions) + } + } + true + } + + @RequiresPermission(Manifest.permission.POST_NOTIFICATIONS) + private suspend fun notify(exceptions: List) { + val manager = NotificationManagerCompat.from(context) + val channel = NotificationChannelCompat.Builder( + CHANNEL_ID, + NotificationManagerCompat.IMPORTANCE_LOW, + ) + .setName(context.getString(R.string.captcha_required)) + .setShowBadge(true) + .setVibrationEnabled(false) + .setSound(null, null) + .setLightsEnabled(false) + .build() + manager.createNotificationChannel(channel) + + coroutineScope { + exceptions.map { + async { it to buildNotification(it) } + }.awaitAll() + }.forEach { (exception, notification) -> + manager.notify(TAG, exception.source.hashCode(), notification) + } + if (exceptions.size > 1) { + val groupNotification = NotificationCompat.Builder(context, CHANNEL_ID) + .setGroupSummary(true) + .setContentTitle(context.getString(R.string.captcha_required)) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setDefaults(0) + .setOnlyAlertOnce(true) + .setSmallIcon(R.drawable.ic_bot) + .setGroup(GROUP_CAPTCHA) + .setContentText( + context.getString( + R.string.captcha_required_summary, context.getString(R.string.app_name), + ), + ) + .setVisibility( + if (exceptions.any { it.source.isNsfw() }) { + NotificationCompat.VISIBILITY_SECRET + } else { + NotificationCompat.VISIBILITY_PUBLIC + }, + ) + manager.notify(TAG, GROUP_NOTIFICATION_ID, groupNotification.build()) + } else { + manager.cancel(TAG, GROUP_NOTIFICATION_ID) + } + } + + private suspend fun buildNotification(exception: CloudFlareProtectedException): Notification { + val intent = AppRouter.cloudFlareResolveIntent(context, exception) + .setData(exception.url.toUri()) + val discardIntent = Intent(ACTION_DISCARD) + .putExtra(AppRouter.KEY_SOURCE, exception.source.name) + .setData("source://${exception.source.name}".toUri()) + val notification = NotificationCompat.Builder(context, CHANNEL_ID) + .setContentTitle(context.getString(R.string.captcha_required)) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setDefaults(0) + .setSmallIcon(R.drawable.ic_bot) + .setGroup(GROUP_CAPTCHA) + .setOnlyAlertOnce(true) + .setAutoCancel(true) + .setDeleteIntent(PendingIntentCompat.getBroadcast(context, 0, discardIntent, 0, false)) + .setLargeIcon(getFavicon(exception.source)) + .setVisibility( + if (exception.source.isNsfw()) { + NotificationCompat.VISIBILITY_SECRET + } else { + NotificationCompat.VISIBILITY_PUBLIC + }, + ) + .setContentText( + context.getString( + R.string.captcha_required_summary, + exception.source.getTitle(context), + ), + ) + .setContentIntent(PendingIntentCompat.getActivity(context, 0, intent, 0, false)) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val actionIntent = PendingIntentCompat.getActivity( + context, SETTINGS_ACTION_CODE, + Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS) + .putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) + .putExtra(Settings.EXTRA_CHANNEL_ID, CHANNEL_ID), + 0, false, + ) + notification.addAction( + R.drawable.ic_settings, + context.getString(R.string.notifications_settings), + actionIntent, + ) + } + return notification.build() + } + + private fun String.toMangaSourceOrNull() = MangaSource(this).takeUnless { it == UnknownMangaSource } + + private suspend fun getFavicon(source: MangaSource) = runCatchingCancellable { + coilProvider.get().execute( + ImageRequest.Builder(context) + .data(source.faviconUri()) + .allowHardware(false) + .allowConversionToBitmap(true) + .mangaSourceExtra(source) + .size(context.resources.getNotificationIconSize()) + .scale(Scale.FILL) + .build(), + ).toBitmapOrNull() + }.onFailure { + it.printStackTraceDebug() + }.getOrNull() + + companion object { + + fun ImageRequest.Builder.ignoreCaptchaErrors() = apply { + extras[ignoreCaptchaKey] = true + } + + val ignoreCaptchaKey = Extras.Key(false) + + private const val CHANNEL_ID = "captcha" + private const val TAG = CHANNEL_ID + private const val GROUP_CAPTCHA = "org.koitharu.kotatsu.CAPTCHA" + private const val GROUP_NOTIFICATION_ID = 34 + private const val SETTINGS_ACTION_CODE = 3 + private const val ACTION_DISCARD = "org.koitharu.kotatsu.CAPTCHA_DISCARD" + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/FaviconView.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/FaviconView.kt index 300ed45a1..564c31c38 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/FaviconView.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/FaviconView.kt @@ -10,7 +10,7 @@ import coil3.asImage import coil3.request.Disposable import coil3.request.ImageRequest import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.browser.cloudflare.CaptchaNotifier.Companion.ignoreCaptchaErrors +import org.koitharu.kotatsu.core.exceptions.resolve.CaptchaHandler.Companion.ignoreCaptchaErrors import org.koitharu.kotatsu.core.image.CoilImageView import org.koitharu.kotatsu.core.parser.favicon.faviconUri import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Coroutines.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Coroutines.kt index 5a5162110..a724e48bb 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Coroutines.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Coroutines.kt @@ -1,5 +1,6 @@ package org.koitharu.kotatsu.core.util.ext +import android.content.BroadcastReceiver import androidx.lifecycle.ProcessLifecycleOwner import androidx.lifecycle.lifecycleScope import dagger.hilt.android.lifecycle.RetainedLifecycle @@ -7,11 +8,14 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Deferred import kotlinx.coroutines.Job import kotlinx.coroutines.joinAll +import kotlinx.coroutines.launch import kotlinx.coroutines.plus import org.koitharu.kotatsu.core.util.AcraCoroutineErrorHandler import org.koitharu.kotatsu.core.util.RetainedLifecycleCoroutineScope import org.koitharu.kotatsu.parsers.util.cancelAll import org.koitharu.kotatsu.parsers.util.runCatchingCancellable +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext import kotlin.coroutines.cancellation.CancellationException val processLifecycleScope: CoroutineScope @@ -42,3 +46,14 @@ suspend fun CoroutineScope.cancelChildrenAndJoin(cause: CancellationException? = jobs.cancelAll(cause) jobs.joinAll() } + +fun BroadcastReceiver.goAsync(context: CoroutineContext = EmptyCoroutineContext, block: suspend () -> Unit) { + val pendingResult = goAsync() + processLifecycleScope.launch(context) { + try { + block() + } finally { + pendingResult.finish() + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Resources.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Resources.kt index bc2a051f9..ba0641195 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Resources.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Resources.kt @@ -7,7 +7,9 @@ import android.os.Build import androidx.annotation.PluralsRes import androidx.annotation.Px import androidx.core.util.TypedValueCompat +import coil3.size.Size import kotlin.math.roundToInt +import androidx.core.R as androidxR @Px fun Resources.resolveDp(dp: Int) = resolveDp(dp.toFloat()).roundToInt() @@ -38,3 +40,8 @@ fun Resources.getQuantityStringSafe(@PluralsRes resId: Int, quantity: Int, varar throw e } } + +fun Resources.getNotificationIconSize() = Size( + getDimensionPixelSize(androidxR.dimen.compat_notification_large_icon_max_width), + getDimensionPixelSize(androidxR.dimen.compat_notification_large_icon_max_height), +) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadNotificationFactory.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadNotificationFactory.kt index ecf6af769..9bb19d8b5 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadNotificationFactory.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadNotificationFactory.kt @@ -28,6 +28,7 @@ import org.koitharu.kotatsu.core.model.LocalMangaSource import org.koitharu.kotatsu.core.model.isNsfw import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.util.ext.getDrawableOrThrow +import org.koitharu.kotatsu.core.util.ext.getNotificationIconSize import org.koitharu.kotatsu.core.util.ext.isReportable import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug @@ -51,16 +52,10 @@ class DownloadNotificationFactory @AssistedInject constructor( @Assisted val isSilent: Boolean, ) { - private val covers = HashMap() + private val covers = HashMap() // TODO cache private val builder = NotificationCompat.Builder(context, if (isSilent) CHANNEL_ID_SILENT else CHANNEL_ID_DEFAULT) private val mutex = Mutex() - private val coverWidth = context.resources.getDimensionPixelSize( - androidx.core.R.dimen.compat_notification_large_icon_max_width, - ) - private val coverHeight = context.resources.getDimensionPixelSize( - androidx.core.R.dimen.compat_notification_large_icon_max_height, - ) private val queueIntent = PendingIntentCompat.getActivity( context, 0, @@ -282,7 +277,7 @@ class DownloadNotificationFactory @AssistedInject constructor( .data(manga.coverUrl) .allowHardware(false) .mangaSourceExtra(manga.source) - .size(coverWidth, coverHeight) + .size(context.resources.getNotificationIconSize()) .scale(Scale.FILL) .build(), ).getDrawableOrThrow() diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/ui/SuggestionsWorker.kt b/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/ui/SuggestionsWorker.kt index 2d8142cf8..96f30714d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/ui/SuggestionsWorker.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/ui/SuggestionsWorker.kt @@ -45,8 +45,9 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.browser.cloudflare.CaptchaNotifier +import org.koitharu.kotatsu.core.exceptions.CloudFlareException import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException +import org.koitharu.kotatsu.core.exceptions.resolve.CaptchaHandler import org.koitharu.kotatsu.core.model.distinctById import org.koitharu.kotatsu.core.model.getLocale import org.koitharu.kotatsu.core.model.isNsfw @@ -97,6 +98,7 @@ class SuggestionsWorker @AssistedInject constructor( private val historyRepository: HistoryRepository, private val favouritesRepository: FavouritesRepository, private val appSettings: AppSettings, + private val captchaHandler: CaptchaHandler, private val workManager: WorkManager, private val mangaRepositoryFactory: MangaRepository.Factory, private val sourcesRepository: MangaSourcesRepository, @@ -283,8 +285,8 @@ class SuggestionsWorker @AssistedInject constructor( list.shuffle() list.take(MAX_SOURCE_RESULTS) }.onFailure { e -> - if (e is CloudFlareProtectedException) { - CaptchaNotifier(applicationContext).notify(e) + if (e is CloudFlareException) { + captchaHandler.handle(e) } e.printStackTraceDebug() }.getOrDefault(emptyList()) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/work/TrackWorker.kt b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/work/TrackWorker.kt index bb8e565d5..e83cb4160 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/work/TrackWorker.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/work/TrackWorker.kt @@ -42,9 +42,9 @@ import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.browser.cloudflare.CaptchaNotifier import org.koitharu.kotatsu.core.db.MangaDatabase -import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException +import org.koitharu.kotatsu.core.exceptions.CloudFlareException +import org.koitharu.kotatsu.core.exceptions.resolve.CaptchaHandler import org.koitharu.kotatsu.core.model.ids import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.prefs.AppSettings @@ -76,6 +76,7 @@ import androidx.appcompat.R as appcompatR class TrackWorker @AssistedInject constructor( @Assisted context: Context, @Assisted workerParams: WorkerParameters, + private val captchaHandler: CaptchaHandler, private val notificationHelper: TrackerNotificationHelper, private val settings: AppSettings, private val getTracksUseCase: GetTracksUseCase, @@ -151,8 +152,8 @@ class TrackWorker @AssistedInject constructor( when (it) { is MangaUpdates.Failure -> { val e = it.error - if (e is CloudFlareProtectedException) { - CaptchaNotifier(applicationContext).notify(e) + if (e is CloudFlareException) { + captchaHandler.handle(e) } }