diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/browser/cloudflare/CaptchaNotifier.kt b/app/src/main/kotlin/org/koitharu/kotatsu/browser/cloudflare/CaptchaNotifier.kt new file mode 100644 index 000000000..8a4f40516 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/browser/cloudflare/CaptchaNotifier.kt @@ -0,0 +1,71 @@ +package org.koitharu.kotatsu.browser.cloudflare + +import android.annotation.SuppressLint +import android.content.Context +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 dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException +import org.koitharu.kotatsu.parsers.model.ContentType +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class CaptchaNotifier @Inject constructor( + @ApplicationContext private val context: Context, +) { + + private val mutex = Mutex() + + @SuppressLint("MissingPermission") + suspend fun notify(exception: CloudFlareProtectedException) = mutex.withLock { + val manager = NotificationManagerCompat.from(context) + if (!manager.areNotificationsEnabled()) { + return@withLock + } + val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_DEFAULT) + .setName(context.getString(R.string.captcha_required)) + .setShowBadge(true) + .setVibrationEnabled(false) + .setSound(null, null) + .setLightsEnabled(false) + .build() + manager.createNotificationChannel(channel) + + val intent = CloudFlareActivity.newIntent(context, exception.url, exception.headers) + .setData(exception.url.toUri()) + val notification = NotificationCompat.Builder(context, CHANNEL_ID) + .setContentTitle(channel.name) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setDefaults(NotificationCompat.DEFAULT_SOUND) + .setSmallIcon(android.R.drawable.stat_notify_error) + .setVisibility( + if (exception.source?.contentType == ContentType.HENTAI) { + NotificationCompat.VISIBILITY_SECRET + } else { + NotificationCompat.VISIBILITY_PUBLIC + }, + ) + .setContentText( + context.getString( + R.string.captcha_required_summary, + exception.source?.title ?: context.getString(R.string.app_name), + ), + ) + .setContentIntent(PendingIntentCompat.getActivity(context, 0, intent, 0, false)) + .build() + manager.notify(TAG, exception.source.hashCode(), notification) + } + + private companion object { + + private const val CHANNEL_ID = "captcha" + private const val TAG = CHANNEL_ID + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/CloudFlareProtectedException.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/CloudFlareProtectedException.kt index 100c0d0f5..23b2523a0 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/CloudFlareProtectedException.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/CloudFlareProtectedException.kt @@ -2,8 +2,10 @@ package org.koitharu.kotatsu.core.exceptions import okhttp3.Headers import okio.IOException +import org.koitharu.kotatsu.parsers.model.MangaSource class CloudFlareProtectedException( val url: String, + val source: MangaSource?, @Transient val headers: Headers, ) : IOException("Protected by CloudFlare") diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/CloudFlareInterceptor.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/network/CloudFlareInterceptor.kt index 44eda7c96..2e2390741 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/CloudFlareInterceptor.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/network/CloudFlareInterceptor.kt @@ -4,6 +4,7 @@ import okhttp3.Interceptor import okhttp3.Response import okhttp3.internal.closeQuietly import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException +import org.koitharu.kotatsu.parsers.model.MangaSource import java.net.HttpURLConnection.HTTP_FORBIDDEN import java.net.HttpURLConnection.HTTP_UNAVAILABLE @@ -20,6 +21,7 @@ class CloudFlareInterceptor : Interceptor { response.closeQuietly() throw CloudFlareProtectedException( url = request.url.toString(), + source = request.tag(MangaSource::class.java), headers = request.headers, ) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/ui/SuggestionsWorker.kt b/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/ui/SuggestionsWorker.kt index 412e2d63f..dc6fbfa41 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/ui/SuggestionsWorker.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/ui/SuggestionsWorker.kt @@ -38,6 +38,8 @@ 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.CloudFlareProtectedException import org.koitharu.kotatsu.core.model.distinctById import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.prefs.AppSettings @@ -80,6 +82,7 @@ class SuggestionsWorker @AssistedInject constructor( private val appSettings: AppSettings, private val mangaRepositoryFactory: MangaRepository.Factory, private val sourcesRepository: MangaSourcesRepository, + private val captchaNotifier: CaptchaNotifier, ) : CoroutineWorker(appContext, params) { private val notificationManager by lazy { NotificationManagerCompat.from(appContext) } @@ -206,11 +209,17 @@ class SuggestionsWorker @AssistedInject constructor( } list.shuffle() list.take(MAX_SOURCE_RESULTS) - }.onFailure { - it.printStackTraceDebug() + }.onFailure { e -> + if (e is CloudFlareProtectedException) { + captchaNotifier.notify(e) + } + e.printStackTraceDebug() }.getOrDefault(emptyList()) private suspend fun showNotification(manga: Manga) { + if (!notificationManager.areNotificationsEnabled()) { + return + } val channel = NotificationChannelCompat.Builder(MANGA_CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_DEFAULT) .setName(applicationContext.getString(R.string.suggestions)) .setDescription(applicationContext.getString(R.string.suggestions_summary)) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/work/TrackWorker.kt b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/work/TrackWorker.kt index 8dae73b52..820f05c6d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/work/TrackWorker.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/work/TrackWorker.kt @@ -1,5 +1,6 @@ package org.koitharu.kotatsu.tracker.work +import android.annotation.SuppressLint import android.app.PendingIntent import android.content.Context import android.os.Build @@ -42,6 +43,8 @@ import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.browser.cloudflare.CaptchaNotifier +import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException import org.koitharu.kotatsu.core.logs.FileLogger import org.koitharu.kotatsu.core.logs.TrackerLogger import org.koitharu.kotatsu.core.prefs.AppSettings @@ -66,6 +69,7 @@ class TrackWorker @AssistedInject constructor( private val settings: AppSettings, private val tracker: Tracker, @TrackerLogger private val logger: FileLogger, + private val captchaNotifier: CaptchaNotifier, ) : CoroutineWorker(context, workerParams) { private val notificationManager by lazy { NotificationManagerCompat.from(applicationContext) } @@ -124,8 +128,11 @@ class TrackWorker @AssistedInject constructor( semaphore.withPermit { runCatchingCancellable { tracker.fetchUpdates(track, commit = true) - }.onFailure { - logger.log("checkUpdatesAsync", it) + }.onFailure { e -> + if (e is CloudFlareProtectedException) { + captchaNotifier.notify(e) + } + logger.log("checkUpdatesAsync", e) }.onSuccess { updates -> if (updates.isValid && updates.isNotEmpty()) { showNotification( @@ -141,8 +148,9 @@ class TrackWorker @AssistedInject constructor( } } + @SuppressLint("MissingPermission") private suspend fun showNotification(manga: Manga, channelId: String?, newChapters: List) { - if (newChapters.isEmpty() || channelId == null) { + if (newChapters.isEmpty() || channelId == null || !notificationManager.areNotificationsEnabled()) { return } val id = manga.url.hashCode() diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bedbf8bd2..307eb633c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -468,4 +468,5 @@ Added View list Show + %s requires a captcha to be resolved to work properly