AdBlock for WebView

This commit is contained in:
Koitharu
2025-05-25 19:30:55 +03:00
parent 41d7fd1b86
commit 099590c419
17 changed files with 386 additions and 11 deletions

View File

@@ -5,7 +5,9 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission
android:name="android.permission.FOREGROUND_SERVICE"
tools:ignore="ForegroundServicesPolicy" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
@@ -19,17 +21,19 @@
<uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission
android:name="android.permission.REQUEST_INSTALL_PACKAGES"
tools:ignore="RequestInstallPackagesPolicy" />
<uses-permission android:name="android.permission.REQUEST_DELETE_PACKAGES" />
<uses-permission
android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission" />
tools:ignore="PackageVisibilityPolicy,QueryAllPackagesPermission" />
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="29" />
<uses-permission
android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
tools:ignore="ScopedStorage" />
tools:ignore="AllFilesAccessPolicy,ScopedStorage" />
<queries>
<intent>
@@ -338,6 +342,9 @@
android:name="org.koitharu.kotatsu.details.service.MangaPrefetchService"
android:exported="false"
android:label="@string/prefetch_content" />
<service
android:name="org.koitharu.kotatsu.browser.AdListUpdateService"
android:exported="false" />
<provider
android:name="org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider"

View File

@@ -0,0 +1,20 @@
package org.koitharu.kotatsu.browser
import android.content.Intent
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.core.network.webview.adblock.AdBlock
import org.koitharu.kotatsu.core.ui.CoroutineIntentService
import javax.inject.Inject
@AndroidEntryPoint
class AdListUpdateService : CoroutineIntentService() {
@Inject
lateinit var updater: AdBlock.Updater
override suspend fun IntentJobContext.processIntent(intent: Intent) {
updater.updateList()
}
override fun IntentJobContext.onError(error: Throwable) = Unit
}

View File

@@ -10,6 +10,7 @@ import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.network.proxy.ProxyProvider
import org.koitharu.kotatsu.core.network.webview.adblock.AdBlock
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
import org.koitharu.kotatsu.core.ui.BaseActivity
@@ -29,6 +30,9 @@ abstract class BaseBrowserActivity : BaseActivity<ActivityBrowserBinding>(), Bro
@Inject
lateinit var mangaRepositoryFactory: MangaRepository.Factory
@Inject
lateinit var adBlock: AdBlock
private lateinit var onBackPressedCallback: WebViewBackPressedCallback
override fun onCreate(savedInstanceState: Bundle?) {

View File

@@ -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()

View File

@@ -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()))
}

View File

@@ -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 {

View File

@@ -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

View File

@@ -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"
}

View File

@@ -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"
}
}

View File

@@ -0,0 +1,21 @@
package org.koitharu.kotatsu.core.network.webview.adblock
import androidx.collection.ArraySet
class CSSRuleBuilder {
private val selectors = ArraySet<String>()
fun add(selector: String) {
selectors.add(selector)
}
fun build() = buildString {
append("<style> {")
for (selector in selectors) {
append(selector)
append(";")
}
append("}!important</style>")
}
}

View File

@@ -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<String>?,
private val domainsNot: Set<String>?,
) : 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
}
}
}

View File

@@ -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<Rule>()
private val allowRules = ArrayList<Rule>()
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
)
}
}

View File

@@ -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<File>
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"

View File

@@ -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<ActivityMainBinding>(), 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))
}
}
}

View File

@@ -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()

View File

@@ -842,4 +842,6 @@
<string name="changelog_summary">Changes history for recently released versions</string>
<string name="collapse">Collapse</string>
<string name="expand">Expand</string>
<string name="adblock">Block ads in browser</string>
<string name="adblock_summary">Block advertisement in the built-in browser (beta)</string>
</resources>

View File

@@ -2,8 +2,8 @@
<PreferenceScreen
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:title="@string/network"
xmlns:tools="http://schemas.android.com/tools">
xmlns:tools="http://schemas.android.com/tools"
android:title="@string/network">
<ListPreference
android:defaultValue="0"
@@ -34,6 +34,12 @@
android:title="@string/dns_over_https"
app:useSimpleSummaryProvider="true" />
<SwitchPreferenceCompat
android:defaultValue="false"
android:key="adblock"
android:summary="@string/adblock_summary"
android:title="@string/adblock" />
<ListPreference
android:defaultValue="-1"
android:entries="@array/image_proxies"