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.R
import org.koitharu.kotatsu.browser.BaseBrowserActivity import org.koitharu.kotatsu.browser.BaseBrowserActivity
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException 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.model.MangaSource
import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
import org.koitharu.kotatsu.core.parser.ParserMangaRepository import org.koitharu.kotatsu.core.parser.ParserMangaRepository
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage 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.model.MangaSource
import org.koitharu.kotatsu.parsers.network.CloudFlareHelper import org.koitharu.kotatsu.parsers.network.CloudFlareHelper
import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import javax.inject.Inject import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
@@ -37,6 +40,9 @@ class CloudFlareActivity : BaseBrowserActivity(), CloudFlareCallback {
@Inject @Inject
lateinit var cookieJar: MutableCookieJar lateinit var cookieJar: MutableCookieJar
@Inject
lateinit var captchaHandler: CaptchaHandler
private lateinit var cfClient: CloudFlareClient private lateinit var cfClient: CloudFlareClient
override fun onCreate2(savedInstanceState: Bundle?, source: MangaSource, repository: ParserMangaRepository?) { override fun onCreate2(savedInstanceState: Bundle?, source: MangaSource, repository: ParserMangaRepository?) {
@@ -98,11 +104,17 @@ class CloudFlareActivity : BaseBrowserActivity(), CloudFlareCallback {
override fun onCheckPassed() { override fun onCheckPassed() {
pendingResult = RESULT_OK pendingResult = RESULT_OK
val source = intent?.getStringExtra(AppRouter.KEY_SOURCE) lifecycleScope.launch {
if (source != null) { val source = intent?.getStringExtra(AppRouter.KEY_SOURCE)
CaptchaNotifier(this).dismiss(MangaSource(source)) if (source != null) {
runCatchingCancellable {
captchaHandler.discard(MangaSource(source))
}.onFailure {
it.printStackTraceDebug()
}
}
finishAfterTransition()
} }
finishAfterTransition()
} }
override fun onTitleChanged(title: CharSequence, subtitle: CharSequence?) { override fun onTitleChanged(title: CharSequence, subtitle: CharSequence?) {

View File

@@ -31,8 +31,8 @@ import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asSharedFlow
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.browser.cloudflare.CaptchaNotifier
import org.koitharu.kotatsu.core.db.MangaDatabase 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.AvifImageDecoder
import org.koitharu.kotatsu.core.image.CbzFetcher import org.koitharu.kotatsu.core.image.CbzFetcher
import org.koitharu.kotatsu.core.image.MangaSourceHeaderInterceptor import org.koitharu.kotatsu.core.image.MangaSourceHeaderInterceptor
@@ -106,6 +106,7 @@ interface AppModule {
pageFetcherFactory: MangaPageFetcher.Factory, pageFetcherFactory: MangaPageFetcher.Factory,
coverRestoreInterceptor: CoverRestoreInterceptor, coverRestoreInterceptor: CoverRestoreInterceptor,
networkStateProvider: Provider<NetworkState>, networkStateProvider: Provider<NetworkState>,
captchaHandler: CaptchaHandler,
): ImageLoader { ): ImageLoader {
val diskCacheFactory = { val diskCacheFactory = {
val rootDir = context.externalCacheDir ?: context.cacheDir val rootDir = context.externalCacheDir ?: context.cacheDir
@@ -121,7 +122,7 @@ interface AppModule {
.diskCache(diskCacheFactory) .diskCache(diskCacheFactory)
.logger(if (BuildConfig.DEBUG) DebugLogger() else null) .logger(if (BuildConfig.DEBUG) DebugLogger() else null)
.allowRgb565(context.isLowRamDevice()) .allowRgb565(context.isLowRamDevice())
.eventListener(CaptchaNotifier(context)) .eventListener(captchaHandler)
.components { .components {
add( add(
OkHttpNetworkFetcherFactory( OkHttpNetworkFetcherFactory(

View File

@@ -14,6 +14,7 @@ import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
import org.koitharu.kotatsu.explore.data.SourcesSortOrder import org.koitharu.kotatsu.explore.data.SourcesSortOrder
import org.koitharu.kotatsu.parsers.network.CloudFlareHelper import org.koitharu.kotatsu.parsers.network.CloudFlareHelper
import org.koitharu.kotatsu.parsers.network.CloudFlareHelper.PROTECTION_CAPTCHA
@Dao @Dao
abstract class MangaSourcesDao { abstract class MangaSourcesDao {
@@ -51,6 +52,9 @@ abstract class MangaSourcesDao {
@Query("UPDATE sources SET pinned = :isPinned WHERE source = :source") @Query("UPDATE sources SET pinned = :isPinned WHERE source = :source")
abstract suspend fun setPinned(source: String, isPinned: Boolean) 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) @Insert(onConflict = OnConflictStrategy.IGNORE)
@Transaction @Transaction
abstract suspend fun insertIfAbsent(entries: Collection<MangaSourceEntity>) abstract suspend fun insertIfAbsent(entries: Collection<MangaSourceEntity>)
@@ -61,6 +65,9 @@ abstract class MangaSourcesDao {
@Query("SELECT * FROM sources WHERE pinned = 1") @Query("SELECT * FROM sources WHERE pinned = 1")
abstract suspend fun findAllPinned(): List<MangaSourceEntity> 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>> = fun observeAll(enabledOnly: Boolean, order: SourcesSortOrder): Flow<List<MangaSourceEntity>> =
observeImpl(getQuery(enabledOnly, order)) observeImpl(getQuery(enabledOnly, order))

View File

@@ -1,9 +1,13 @@
package org.koitharu.kotatsu.core.exceptions 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.model.MangaSource
import org.koitharu.kotatsu.parsers.network.CloudFlareHelper
class CloudFlareBlockedException( class CloudFlareBlockedException(
val url: String, override val url: String,
val source: MangaSource?, source: MangaSource?,
) : IOException("Blocked by CloudFlare") ) : 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 package org.koitharu.kotatsu.core.exceptions
import okhttp3.Headers 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.model.MangaSource
import org.koitharu.kotatsu.parsers.network.CloudFlareHelper
class CloudFlareProtectedException( class CloudFlareProtectedException(
val url: String, override val url: String,
val source: MangaSource?, source: MangaSource?,
@Transient val headers: Headers, @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.Disposable
import coil3.request.ImageRequest import coil3.request.ImageRequest
import org.koitharu.kotatsu.R 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.image.CoilImageView
import org.koitharu.kotatsu.core.parser.favicon.faviconUri import org.koitharu.kotatsu.core.parser.favicon.faviconUri
import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.core.util.ext package org.koitharu.kotatsu.core.util.ext
import android.content.BroadcastReceiver
import androidx.lifecycle.ProcessLifecycleOwner import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.lifecycle.RetainedLifecycle import dagger.hilt.android.lifecycle.RetainedLifecycle
@@ -7,11 +8,14 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.joinAll import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus import kotlinx.coroutines.plus
import org.koitharu.kotatsu.core.util.AcraCoroutineErrorHandler import org.koitharu.kotatsu.core.util.AcraCoroutineErrorHandler
import org.koitharu.kotatsu.core.util.RetainedLifecycleCoroutineScope import org.koitharu.kotatsu.core.util.RetainedLifecycleCoroutineScope
import org.koitharu.kotatsu.parsers.util.cancelAll import org.koitharu.kotatsu.parsers.util.cancelAll
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
import kotlin.coroutines.cancellation.CancellationException import kotlin.coroutines.cancellation.CancellationException
val processLifecycleScope: CoroutineScope val processLifecycleScope: CoroutineScope
@@ -42,3 +46,14 @@ suspend fun CoroutineScope.cancelChildrenAndJoin(cause: CancellationException? =
jobs.cancelAll(cause) jobs.cancelAll(cause)
jobs.joinAll() 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.PluralsRes
import androidx.annotation.Px import androidx.annotation.Px
import androidx.core.util.TypedValueCompat import androidx.core.util.TypedValueCompat
import coil3.size.Size
import kotlin.math.roundToInt import kotlin.math.roundToInt
import androidx.core.R as androidxR
@Px @Px
fun Resources.resolveDp(dp: Int) = resolveDp(dp.toFloat()).roundToInt() fun Resources.resolveDp(dp: Int) = resolveDp(dp.toFloat()).roundToInt()
@@ -38,3 +40,8 @@ fun Resources.getQuantityStringSafe(@PluralsRes resId: Int, quantity: Int, varar
throw e 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.model.isNsfw
import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.util.ext.getDrawableOrThrow 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.isReportable
import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
@@ -51,16 +52,10 @@ class DownloadNotificationFactory @AssistedInject constructor(
@Assisted val isSilent: Boolean, @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 builder = NotificationCompat.Builder(context, if (isSilent) CHANNEL_ID_SILENT else CHANNEL_ID_DEFAULT)
private val mutex = Mutex() 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( private val queueIntent = PendingIntentCompat.getActivity(
context, context,
0, 0,
@@ -282,7 +277,7 @@ class DownloadNotificationFactory @AssistedInject constructor(
.data(manga.coverUrl) .data(manga.coverUrl)
.allowHardware(false) .allowHardware(false)
.mangaSourceExtra(manga.source) .mangaSourceExtra(manga.source)
.size(coverWidth, coverHeight) .size(context.resources.getNotificationIconSize())
.scale(Scale.FILL) .scale(Scale.FILL)
.build(), .build(),
).getDrawableOrThrow() ).getDrawableOrThrow()

View File

@@ -45,8 +45,9 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.sync.withPermit
import org.koitharu.kotatsu.R 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.CloudFlareProtectedException
import org.koitharu.kotatsu.core.exceptions.resolve.CaptchaHandler
import org.koitharu.kotatsu.core.model.distinctById import org.koitharu.kotatsu.core.model.distinctById
import org.koitharu.kotatsu.core.model.getLocale import org.koitharu.kotatsu.core.model.getLocale
import org.koitharu.kotatsu.core.model.isNsfw import org.koitharu.kotatsu.core.model.isNsfw
@@ -97,6 +98,7 @@ class SuggestionsWorker @AssistedInject constructor(
private val historyRepository: HistoryRepository, private val historyRepository: HistoryRepository,
private val favouritesRepository: FavouritesRepository, private val favouritesRepository: FavouritesRepository,
private val appSettings: AppSettings, private val appSettings: AppSettings,
private val captchaHandler: CaptchaHandler,
private val workManager: WorkManager, private val workManager: WorkManager,
private val mangaRepositoryFactory: MangaRepository.Factory, private val mangaRepositoryFactory: MangaRepository.Factory,
private val sourcesRepository: MangaSourcesRepository, private val sourcesRepository: MangaSourcesRepository,
@@ -283,8 +285,8 @@ class SuggestionsWorker @AssistedInject constructor(
list.shuffle() list.shuffle()
list.take(MAX_SOURCE_RESULTS) list.take(MAX_SOURCE_RESULTS)
}.onFailure { e -> }.onFailure { e ->
if (e is CloudFlareProtectedException) { if (e is CloudFlareException) {
CaptchaNotifier(applicationContext).notify(e) captchaHandler.handle(e)
} }
e.printStackTraceDebug() e.printStackTraceDebug()
}.getOrDefault(emptyList()) }.getOrDefault(emptyList())

View File

@@ -42,9 +42,9 @@ import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.browser.cloudflare.CaptchaNotifier
import org.koitharu.kotatsu.core.db.MangaDatabase 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.model.ids
import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
@@ -76,6 +76,7 @@ import androidx.appcompat.R as appcompatR
class TrackWorker @AssistedInject constructor( class TrackWorker @AssistedInject constructor(
@Assisted context: Context, @Assisted context: Context,
@Assisted workerParams: WorkerParameters, @Assisted workerParams: WorkerParameters,
private val captchaHandler: CaptchaHandler,
private val notificationHelper: TrackerNotificationHelper, private val notificationHelper: TrackerNotificationHelper,
private val settings: AppSettings, private val settings: AppSettings,
private val getTracksUseCase: GetTracksUseCase, private val getTracksUseCase: GetTracksUseCase,
@@ -151,8 +152,8 @@ class TrackWorker @AssistedInject constructor(
when (it) { when (it) {
is MangaUpdates.Failure -> { is MangaUpdates.Failure -> {
val e = it.error val e = it.error
if (e is CloudFlareProtectedException) { if (e is CloudFlareException) {
CaptchaNotifier(applicationContext).notify(e) captchaHandler.handle(e)
} }
} }