Improve captcha notifications
parent
ff5a873d3b
commit
f4997f5a7f
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
|||||||
@ -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
|
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
|
||||||
|
}
|
||||||
|
|||||||
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue