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">
+
+