Improve captcha notifications

This commit is contained in:
Koitharu
2025-05-24 09:43:08 +03:00
parent ff5a873d3b
commit f4997f5a7f
14 changed files with 356 additions and 141 deletions

View File

@@ -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
}
}

View File

@@ -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?) {

View File

@@ -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<NetworkState>,
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(

View File

@@ -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<MangaSourceEntity>)
@@ -61,6 +65,9 @@ abstract class MangaSourcesDao {
@Query("SELECT * FROM sources WHERE pinned = 1")
abstract suspend fun findAllPinned(): List<MangaSourceEntity>
@Query("SELECT * FROM sources WHERE cf_state = $PROTECTION_CAPTCHA")
abstract suspend fun findAllCaptchaRequired(): List<MangaSourceEntity>
fun observeAll(enabledOnly: Boolean, order: SourcesSortOrder): Flow<List<MangaSourceEntity>> =
observeImpl(getQuery(enabledOnly, order))

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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<MangaDatabase>,
private val coilProvider: Provider<ImageLoader>,
) : EventListener() {
private val exceptionMap = MutableScatterMap<MangaSource, CloudFlareProtectedException>()
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<CloudFlareProtectedException>) {
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"
}
}

View File

@@ -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

View File

@@ -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()
}
}
}

View File

@@ -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),
)

View File

@@ -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<Manga, Drawable>()
private val covers = HashMap<Manga, Drawable>() // 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()

View File

@@ -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())

View File

@@ -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)
}
}