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