AdBlock for WebView
parent
41d7fd1b86
commit
099590c419
@ -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
|
||||||
|
}
|
||||||
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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>")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue