From 099590c419bb8325387a7d2bef54c4606137a2fa Mon Sep 17 00:00:00 2001 From: Koitharu Date: Sun, 25 May 2025 19:30:55 +0300 Subject: [PATCH] AdBlock for WebView --- app/src/main/AndroidManifest.xml | 15 ++- .../kotatsu/browser/AdListUpdateService.kt | 20 +++ .../kotatsu/browser/BaseBrowserActivity.kt | 4 + .../kotatsu/browser/BrowserActivity.kt | 2 +- .../koitharu/kotatsu/browser/BrowserClient.kt | 33 ++++- .../browser/cloudflare/CloudFlareActivity.kt | 2 +- .../browser/cloudflare/CloudFlareClient.kt | 4 +- .../kotatsu/core/network/CommonHeaders.kt | 4 + .../core/network/webview/adblock/AdBlock.kt | 122 ++++++++++++++++++ .../network/webview/adblock/CSSRuleBuilder.kt | 21 +++ .../core/network/webview/adblock/Rule.kt | 57 ++++++++ .../core/network/webview/adblock/RulesList.kt | 91 +++++++++++++ .../kotatsu/core/prefs/AppSettings.kt | 4 + .../koitharu/kotatsu/main/ui/MainActivity.kt | 4 + .../sources/auth/SourceAuthActivity.kt | 2 +- app/src/main/res/values/strings.xml | 2 + app/src/main/res/xml/pref_network.xml | 10 +- 17 files changed, 386 insertions(+), 11 deletions(-) create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/browser/AdListUpdateService.kt create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/core/network/webview/adblock/AdBlock.kt create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/core/network/webview/adblock/CSSRuleBuilder.kt create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/core/network/webview/adblock/Rule.kt create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/core/network/webview/adblock/RulesList.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index be636a6c5..9b6ee3686 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -5,7 +5,9 @@ - + @@ -19,17 +21,19 @@ - + + tools:ignore="PackageVisibilityPolicy,QueryAllPackagesPermission" /> + tools:ignore="AllFilesAccessPolicy,ScopedStorage" /> @@ -338,6 +342,9 @@ android:name="org.koitharu.kotatsu.details.service.MangaPrefetchService" android:exported="false" android:label="@string/prefetch_content" /> + (), Bro @Inject lateinit var mangaRepositoryFactory: MangaRepository.Factory + @Inject + lateinit var adBlock: AdBlock + private lateinit var onBackPressedCallback: WebViewBackPressedCallback override fun onCreate(savedInstanceState: Bundle?) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/browser/BrowserActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/browser/BrowserActivity.kt index 9a2fa9480..ec604eb86 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/browser/BrowserActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/browser/BrowserActivity.kt @@ -24,7 +24,7 @@ class BrowserActivity : BaseBrowserActivity() { override fun onCreate2(savedInstanceState: Bundle?, source: MangaSource, repository: ParserMangaRepository?) { setDisplayHomeAsUp(isEnabled = true, showUpAsClose = true) - viewBinding.webView.webViewClient = BrowserClient(this) + viewBinding.webView.webViewClient = BrowserClient(this, adBlock) lifecycleScope.launch { try { proxyProvider.applyWebViewConfig() diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/browser/BrowserClient.kt b/app/src/main/kotlin/org/koitharu/kotatsu/browser/BrowserClient.kt index e13231611..5c3470efb 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/browser/BrowserClient.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/browser/BrowserClient.kt @@ -1,11 +1,17 @@ package org.koitharu.kotatsu.browser import android.graphics.Bitmap +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse import android.webkit.WebView import android.webkit.WebViewClient +import androidx.annotation.WorkerThread +import org.koitharu.kotatsu.core.network.webview.adblock.AdBlock +import java.io.ByteArrayInputStream open class BrowserClient( - private val callback: BrowserCallback + private val callback: BrowserCallback, + private val adBlock: AdBlock, ) : WebViewClient() { /** @@ -31,4 +37,29 @@ open class BrowserClient( super.doUpdateVisitedHistory(view, url, isReload) callback.onHistoryChanged() } + + @WorkerThread + @Deprecated("Deprecated in Java") + override fun shouldInterceptRequest( + view: WebView?, + url: String? + ): WebResourceResponse? = if (url.isNullOrEmpty() || adBlock.shouldLoadUrl(url, view?.url)) { + super.shouldInterceptRequest(view, url) + } else { + emptyResponse() + } + + @WorkerThread + override fun shouldInterceptRequest( + view: WebView?, + request: WebResourceRequest? + ): WebResourceResponse? = if (request == null || adBlock.shouldLoadUrl(request.url.toString(), view?.url)) { + view?.originalUrl + super.shouldInterceptRequest(view, request) + } else { + emptyResponse() + } + + private fun emptyResponse(): WebResourceResponse = + WebResourceResponse("text/plain", "utf-8", ByteArrayInputStream(byteArrayOf())) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/browser/cloudflare/CloudFlareActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/browser/cloudflare/CloudFlareActivity.kt index b703858db..61eef6740 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/browser/cloudflare/CloudFlareActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/browser/cloudflare/CloudFlareActivity.kt @@ -52,7 +52,7 @@ class CloudFlareActivity : BaseBrowserActivity(), CloudFlareCallback { finishAfterTransition() return } - cfClient = CloudFlareClient(cookieJar, this, url) + cfClient = CloudFlareClient(cookieJar, this, adBlock, url) viewBinding.webView.webViewClient = cfClient lifecycleScope.launch { try { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/browser/cloudflare/CloudFlareClient.kt b/app/src/main/kotlin/org/koitharu/kotatsu/browser/cloudflare/CloudFlareClient.kt index 5cb637a16..19ca0596a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/browser/cloudflare/CloudFlareClient.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/browser/cloudflare/CloudFlareClient.kt @@ -4,6 +4,7 @@ import android.graphics.Bitmap import android.webkit.WebView import org.koitharu.kotatsu.browser.BrowserClient import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar +import org.koitharu.kotatsu.core.network.webview.adblock.AdBlock import org.koitharu.kotatsu.parsers.network.CloudFlareHelper private const val LOOP_COUNTER = 3 @@ -11,8 +12,9 @@ private const val LOOP_COUNTER = 3 class CloudFlareClient( private val cookieJar: MutableCookieJar, private val callback: CloudFlareCallback, + adBlock: AdBlock, private val targetUrl: String, -) : BrowserClient(callback) { +) : BrowserClient(callback, adBlock) { private val oldClearance = getClearance() private var counter = 0 diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/CommonHeaders.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/network/CommonHeaders.kt index f63117f81..17529f358 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/CommonHeaders.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/network/CommonHeaders.kt @@ -16,8 +16,12 @@ object CommonHeaders { const val CACHE_CONTROL = "Cache-Control" const val PROXY_AUTHORIZATION = "Proxy-Authorization" const val RETRY_AFTER = "Retry-After" + const val LAST_MODIFIED = "Last-Modified" + const val IF_MODIFIED_SINCE = "If-Modified-Since" const val MANGA_SOURCE = "X-Manga-Source" val CACHE_CONTROL_NO_STORE: CacheControl get() = CacheControl.Builder().noStore().build() + + const val DATE_FORMAT = "EEE, dd MMM yyyy HH:mm:ss zzz" } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/webview/adblock/AdBlock.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/network/webview/adblock/AdBlock.kt new file mode 100644 index 000000000..8f05272e4 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/network/webview/adblock/AdBlock.kt @@ -0,0 +1,122 @@ +package org.koitharu.kotatsu.core.network.webview.adblock + +import android.content.Context +import android.util.Log +import androidx.annotation.WorkerThread +import dagger.Reusable +import dagger.hilt.android.qualifiers.ApplicationContext +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.OkHttpClient +import okhttp3.Request +import okio.sink +import org.koitharu.kotatsu.core.network.BaseHttpClient +import org.koitharu.kotatsu.core.network.CommonHeaders +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.core.util.ext.isNotEmpty +import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug +import org.koitharu.kotatsu.parsers.util.await +import org.koitharu.kotatsu.parsers.util.requireBody +import org.koitharu.kotatsu.parsers.util.runCatchingCancellable +import java.io.File +import java.net.HttpURLConnection +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import javax.inject.Inject + +@Reusable +class AdBlock @Inject constructor( + @ApplicationContext private val context: Context, + private val settings: AppSettings, +) { + + private var rules: RulesList? = null + + @WorkerThread + fun shouldLoadUrl(url: String, baseUrl: String?): Boolean { + return shouldLoadUrl( + url.lowercase().toHttpUrlOrNull() ?: return true, + baseUrl?.lowercase()?.toHttpUrlOrNull(), + ) + } + + @WorkerThread + fun shouldLoadUrl(url: HttpUrl, baseUrl: HttpUrl?): Boolean { + if (!settings.isAdBlockEnabled) { + return true + } + return synchronized(this) { + rules ?: parseRules().also { rules = it } + }?.let { + val rule = it[url, baseUrl] + if (rule != null) { + Log.i(TAG, "Blocked $url by $rule") + } + rule == null + } ?: true + } + + @WorkerThread + private fun parseRules() = runCatchingCancellable { + listFile(context).useLines { lines -> + val rules = RulesList() + lines.forEach { line -> rules.add(line) } + rules.trimToSize() + rules + } + }.onFailure { e -> + e.printStackTraceDebug() + }.getOrNull() + + class Updater @Inject constructor( + @ApplicationContext private val context: Context, + @BaseHttpClient private val okHttpClient: OkHttpClient, + ) { + + suspend fun updateList() { + val file = listFile(context) + val dateFormat = SimpleDateFormat(CommonHeaders.DATE_FORMAT, Locale.ENGLISH) + val requestBuilder = Request.Builder() + .url(EASYLIST_URL) + .get() + if (file.exists() && file.isNotEmpty()) { + val lastModified = file.lastModified() + requestBuilder.header(CommonHeaders.IF_MODIFIED_SINCE, dateFormat.format(Date(lastModified))) + } + okHttpClient.newCall( + requestBuilder.build(), + ).await().use { response -> + if (response.code == HttpURLConnection.HTTP_NOT_MODIFIED) { + return + } + val lastModified = response.header(CommonHeaders.LAST_MODIFIED)?.let { + runCatching { + dateFormat.parse(it) + }.getOrNull() + }?.time ?: System.currentTimeMillis() + response.requireBody().source().use { source -> + file.sink().use { sink -> + source.readAll(sink) + } + file.setLastModified(lastModified) + } + } + } + + } + + private companion object { + + fun listFile(context: Context): File { + val root = File(context.externalCacheDir ?: context.cacheDir, LIST_DIR) + root.mkdir() + return File(root, LIST_FILENAME) + } + + private const val LIST_FILENAME = "easylist.txt" + private const val LIST_DIR = "adblock" + private const val EASYLIST_URL = "https://easylist.to/easylist/easylist.txt" + private const val TAG = "AdBlock" + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/webview/adblock/CSSRuleBuilder.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/network/webview/adblock/CSSRuleBuilder.kt new file mode 100644 index 000000000..8b1b7d8b3 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/network/webview/adblock/CSSRuleBuilder.kt @@ -0,0 +1,21 @@ +package org.koitharu.kotatsu.core.network.webview.adblock + +import androidx.collection.ArraySet + +class CSSRuleBuilder { + + private val selectors = ArraySet() + + fun add(selector: String) { + selectors.add(selector) + } + + fun build() = buildString { + append("") + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/webview/adblock/Rule.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/network/webview/adblock/Rule.kt new file mode 100644 index 000000000..1aca71a34 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/network/webview/adblock/Rule.kt @@ -0,0 +1,57 @@ +package org.koitharu.kotatsu.core.network.webview.adblock + +import okhttp3.HttpUrl + +sealed interface Rule { + + operator fun invoke(url: HttpUrl, baseUrl: HttpUrl?): Boolean + + data class Domain(private val domain: String) : Rule { + + override fun invoke(url: HttpUrl, baseUrl: HttpUrl?): Boolean = (url.topPrivateDomain() ?: url.host) == domain + } + + data class ExactUrl(private val url: HttpUrl) : Rule { + + override operator fun invoke(url: HttpUrl, baseUrl: HttpUrl?): Boolean = url == this.url + } + + data class Path(private val path: String, private val contains: Boolean) : Rule { + + override fun invoke(url: HttpUrl, baseUrl: HttpUrl?): Boolean { + val fullPath = url.host + "/" + url.encodedPath + return if (contains) { + fullPath.contains(path) + } else { + fullPath.endsWith(path) + } + } + } + + data class WithModifiers( + private val baseRule: Rule, + private val script: Boolean?, + private val thirdParty: Boolean?, + private val domains: Set?, + private val domainsNot: Set?, + ) : Rule { + + override fun invoke(url: HttpUrl, baseUrl: HttpUrl?): Boolean { + if (!baseRule.invoke(url, baseUrl)) { + return false + } + if (baseUrl == null) { + return true + } + thirdParty?.let { + val isThirdPartyRequest = + (url.topPrivateDomain() ?: url.host) != (baseUrl.topPrivateDomain() ?: baseUrl.host) + if (isThirdPartyRequest != it) { + return false + } + } + // TODO check other modifiers + return true + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/webview/adblock/RulesList.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/network/webview/adblock/RulesList.kt new file mode 100644 index 000000000..58e2ba021 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/network/webview/adblock/RulesList.kt @@ -0,0 +1,91 @@ +package org.koitharu.kotatsu.core.network.webview.adblock + +import androidx.annotation.CheckResult +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull + +/** + * Very simple implementation of adblock list parser + * Not all features are supported + */ +class RulesList { + + private val blockRules = ArrayList() + private val allowRules = ArrayList() + + operator fun get(url: HttpUrl, baseUrl: HttpUrl?): Rule? { + val rule = blockRules.find { x -> x(url, baseUrl) } + return rule?.takeIf { allowRules.none { x -> x(url, baseUrl) } } + } + + fun add(line: String) { + val parts = line.lowercase().trim().split('$') + parts.first().addImpl(isWhitelist = false, modifiers = parts.getOrNull(1)) + } + + fun trimToSize() { + blockRules.trimToSize() + allowRules.trimToSize() + } + + private fun String.addImpl(isWhitelist: Boolean, modifiers: String?) { + val list = if (isWhitelist) allowRules else blockRules + + when { + startsWith('!') || startsWith('[') -> { + // Comment, do nothing + } + + startsWith("||") -> { + // domain + list += Rule.Domain(substring(2).substringBefore('^').trim()).withModifiers(modifiers) + } + + startsWith('|') -> { + val url = substring(1).substringBefore('^').trim().toHttpUrlOrNull() + if (url != null) { + list += Rule.ExactUrl(url).withModifiers(modifiers) + } + } + + startsWith("@@") -> { + substring(2).substringBefore('^').trim().addImpl(!isWhitelist, modifiers) + } + + startsWith("##") -> { + // TODO css rules + } + + else -> { + if (endsWith('*')) { + list += Rule.Path(this.dropLast(1), contains = true).withModifiers(modifiers) + } else if (!contains('*')) { // wildcards is not supported yet + list += Rule.Path(this, contains = false).withModifiers(modifiers) + } + } + } + } + + @CheckResult + private fun Rule.withModifiers(options: String?): Rule { + if (options.isNullOrEmpty()) { + return this + } + var script: Boolean? = null + var thirdParty: Boolean? = null + options.split(',').forEach { + val isNot = it.startsWith('~') + when (it.removePrefix("~")) { + "script" -> script = !isNot + "third-party" -> thirdParty = !isNot + } + } + return Rule.WithModifiers( + baseRule = this, + script = script, + thirdParty = thirdParty, + domains = null, //TODO + domainsNot = null, //TODO + ) + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt index a66596bf6..ccb06a053 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt @@ -320,6 +320,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { val screenshotsPolicy: ScreenshotsPolicy get() = prefs.getEnumValue(KEY_SCREENSHOTS_POLICY, ScreenshotsPolicy.ALLOW) + val isAdBlockEnabled: Boolean + get() = prefs.getBoolean(KEY_ADBLOCK, false) + var userSpecifiedMangaDirectories: Set get() { val set = prefs.getStringSet(KEY_LOCAL_MANGA_DIRS, emptySet()).orEmpty() @@ -598,6 +601,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { const val TRACK_HISTORY = "history" const val TRACK_FAVOURITES = "favourites" + const val KEY_ADBLOCK = "adblock" const val KEY_LIST_MODE = "list_mode_2" const val KEY_LIST_MODE_HISTORY = "list_mode_history" const val KEY_LIST_MODE_FAVORITES = "list_mode_favorites" diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainActivity.kt index c9e02187a..9f2b0d6d8 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainActivity.kt @@ -43,6 +43,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.browser.AdListUpdateService import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.os.VoiceInputContract @@ -290,6 +291,9 @@ class MainActivity : BaseActivity(), AppBarOwner, BottomNav } startService(Intent(this@MainActivity, LocalIndexUpdateService::class.java)) startService(Intent(this@MainActivity, PeriodicalBackupService::class.java)) + if (settings.isAdBlockEnabled) { + startService(Intent(this@MainActivity, AdListUpdateService::class.java)) + } } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/auth/SourceAuthActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/auth/SourceAuthActivity.kt index 56815f0d0..583c66d0c 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/auth/SourceAuthActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/auth/SourceAuthActivity.kt @@ -45,7 +45,7 @@ class SourceAuthActivity : BaseBrowserActivity(), BrowserCallback { return } setDisplayHomeAsUp(isEnabled = true, showUpAsClose = true) - viewBinding.webView.webViewClient = BrowserClient(this) + viewBinding.webView.webViewClient = BrowserClient(this, adBlock) lifecycleScope.launch { try { proxyProvider.applyWebViewConfig() diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c8a98087f..d16e7293f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -842,4 +842,6 @@ Changes history for recently released versions Collapse Expand + Block ads in browser + Block advertisement in the built-in browser (beta) diff --git a/app/src/main/res/xml/pref_network.xml b/app/src/main/res/xml/pref_network.xml index ef56aae81..9420afadc 100644 --- a/app/src/main/res/xml/pref_network.xml +++ b/app/src/main/res/xml/pref_network.xml @@ -2,8 +2,8 @@ + xmlns:tools="http://schemas.android.com/tools" + android:title="@string/network"> + +