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.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?) {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user