Improve captcha notifications
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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?) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user