mirror of
https://github.com/twinksoftware/Gearslip.git
synced 2026-11-12 08:42:25 +01:00
The car browser gets a start page with bookmark tiles, a search engine choice, and ad blocking
Bookmarks are added from the star in the car, from the phone's settings, or by sharing a page to Gearslip from any browser. Ads and trackers are blocked with EasyList and EasyPrivacy, or any list added by its address, refreshed weekly on Wi-Fi, and YouTube's video ads are skipped.
This commit is contained in:
1 parent
f139ac48c4
commit
43f9259803
37 files changed
+136249
-67
No files matched your search
@@ -162,6 +162,7 @@ class GearslipActivity : ComponentActivity(), Projection {
|
||||
// Before any car connects: CarServices only starts once the car shows Gearslip's screen,
|
||||
// and a car that waits to know the phone before it shows it would never hear (issue #43).
|
||||
app.seb3thehacker.gearslip.car.CarBluetooth.init(this)
|
||||
app.seb3thehacker.gearslip.browser.BrowserSettings.init(this)
|
||||
AppSettings.applyGearslipEnabled(this)
|
||||
// A capture that died without tidying up would leave notifications exposed to every
|
||||
// later screen share. Nothing is capturing if the service is not running, so undo it.
|
||||
|
||||
@@ -8,7 +8,8 @@ object ProjectedContent {
|
||||
|
||||
/**
|
||||
* Precedence: the URL saved in Settings, then a `url.txt` in the external files dir (the
|
||||
* pre-Settings way, still honoured so existing setups keep working), then [DEFAULT_URL].
|
||||
* pre-Settings way, still honoured so existing setups keep working). Blank opens the
|
||||
* browser's own start page.
|
||||
*/
|
||||
fun resolve(context: Context): String {
|
||||
val saved = AppSettings.startupUrl(context)
|
||||
@@ -25,12 +26,9 @@ object ProjectedContent {
|
||||
return url
|
||||
}
|
||||
}
|
||||
return DEFAULT_URL
|
||||
return ""
|
||||
}
|
||||
|
||||
/** What the Web app opens until a page is chosen in Settings. */
|
||||
const val DEFAULT_URL = "https://duckduckgo.com"
|
||||
|
||||
/** A bare "example.com" is a URL the user meant; raw HTML is passed through untouched. */
|
||||
private fun normalize(value: String): String =
|
||||
if (value.startsWith("<") || "://" in value) value else "https://$value"
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
package app.seb3thehacker.gearslip.browser
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import java.net.URLEncoder
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
/** A page saved to the browser's start page. */
|
||||
data class Bookmark(val title: String, val url: String) {
|
||||
/** The host without "www.", for labels and icons. */
|
||||
val host: String get() = runCatching { android.net.Uri.parse(url).host.orEmpty() }.getOrDefault("").removePrefix("www.")
|
||||
}
|
||||
|
||||
/** Where typed searches go. [query] is the address with the search terms appended, encoded. */
|
||||
enum class SearchEngine(val id: String, val label: String, private val query: String) {
|
||||
DUCKDUCKGO("duckduckgo", "DuckDuckGo", "https://duckduckgo.com/?q="),
|
||||
STARTPAGE("startpage", "Startpage", "https://www.startpage.com/do/search?q="),
|
||||
BRAVE("brave", "Brave Search", "https://search.brave.com/search?q="),
|
||||
ECOSIA("ecosia", "Ecosia", "https://www.ecosia.org/search?q="),
|
||||
GOOGLE("google", "Google", "https://www.google.com/search?q="),
|
||||
BING("bing", "Bing", "https://www.bing.com/search?q=");
|
||||
|
||||
fun search(text: String): String = query + URLEncoder.encode(text, "UTF-8")
|
||||
}
|
||||
|
||||
/**
|
||||
* A filter list the ad blocker can use: one of the two it ships with, or one the driver added by
|
||||
* its address. [id] names the cached copy on disk.
|
||||
*/
|
||||
data class FilterList(val id: String, val title: String, val url: String, val builtIn: Boolean, val enabled: Boolean)
|
||||
|
||||
/**
|
||||
* The browser's own settings: bookmarks, the search engine, and the ad blocker's switches and
|
||||
* lists. Kept in a preferences file of their own and mirrored into flows, so the car's browser
|
||||
* and the phone's settings show the same thing. The ad blocker reads only the flows' values,
|
||||
* never the file, since it answers on WebView's request thread (see [adblock.AdBlockManager]).
|
||||
*/
|
||||
object BrowserSettings {
|
||||
|
||||
private const val FILE = "browser"
|
||||
private const val KEY_BOOKMARKS = "bookmarks"
|
||||
private const val KEY_SEARCH = "search_engine"
|
||||
private const val KEY_ADBLOCK = "adblock"
|
||||
private const val KEY_YOUTUBE = "adblock_youtube"
|
||||
private const val KEY_LISTS_OFF = "adblock_lists_off"
|
||||
private const val KEY_CUSTOM_LISTS = "adblock_custom_lists"
|
||||
private const val KEY_LAST_CHECK = "adblock_last_check"
|
||||
|
||||
/** The lists Gearslip ships a snapshot of, on unless turned off. */
|
||||
val BUILT_IN_LISTS = listOf(
|
||||
FilterList("easylist", "EasyList", "https://easylist.to/easylist/easylist.txt", builtIn = true, enabled = true),
|
||||
FilterList("easyprivacy", "EasyPrivacy", "https://easylist.to/easylist/easyprivacy.txt", builtIn = true, enabled = true),
|
||||
)
|
||||
|
||||
/** What a new install's start page offers, until the driver changes it. */
|
||||
private val DEFAULT_BOOKMARKS = listOf(
|
||||
Bookmark("YouTube", "https://m.youtube.com"),
|
||||
Bookmark("Wikipedia", "https://en.m.wikipedia.org"),
|
||||
)
|
||||
|
||||
private val bookmarksFlow = MutableStateFlow<List<Bookmark>>(emptyList())
|
||||
private val searchFlow = MutableStateFlow(SearchEngine.DUCKDUCKGO)
|
||||
private val adBlockFlow = MutableStateFlow(true)
|
||||
private val youTubeFlow = MutableStateFlow(true)
|
||||
private val listsFlow = MutableStateFlow(BUILT_IN_LISTS)
|
||||
|
||||
val bookmarks: StateFlow<List<Bookmark>> = bookmarksFlow
|
||||
val searchEngine: StateFlow<SearchEngine> = searchFlow
|
||||
val adBlock: StateFlow<Boolean> = adBlockFlow
|
||||
val youTubeAdBlock: StateFlow<Boolean> = youTubeFlow
|
||||
val filterLists: StateFlow<List<FilterList>> = listsFlow
|
||||
|
||||
@Volatile private var prefs: SharedPreferences? = null
|
||||
|
||||
/** Loads everything once per process. Safe to call from anywhere that has a context. */
|
||||
fun init(context: Context) {
|
||||
if (prefs != null) return
|
||||
synchronized(this) {
|
||||
if (prefs != null) return
|
||||
val p = context.applicationContext.getSharedPreferences(FILE, Context.MODE_PRIVATE)
|
||||
bookmarksFlow.value = if (p.contains(KEY_BOOKMARKS)) readBookmarks(p.getString(KEY_BOOKMARKS, "[]").orEmpty()) else DEFAULT_BOOKMARKS
|
||||
searchFlow.value = SearchEngine.entries.firstOrNull { it.id == p.getString(KEY_SEARCH, null) } ?: SearchEngine.DUCKDUCKGO
|
||||
adBlockFlow.value = p.getBoolean(KEY_ADBLOCK, true)
|
||||
youTubeFlow.value = p.getBoolean(KEY_YOUTUBE, true)
|
||||
listsFlow.value = readLists(p)
|
||||
prefs = p
|
||||
}
|
||||
}
|
||||
|
||||
// Bookmarks
|
||||
|
||||
fun isBookmarked(url: String?): Boolean = url != null && bookmarksFlow.value.any { same(it.url, url) }
|
||||
|
||||
/** Adds [url], or renames it when it's already saved. Returns false for something that isn't a web address. */
|
||||
fun addBookmark(title: String, url: String): Boolean {
|
||||
val clean = url.trim()
|
||||
if (!clean.startsWith("http://") && !clean.startsWith("https://")) return false
|
||||
val name = title.trim().ifEmpty { Bookmark("", clean).host.ifEmpty { clean } }
|
||||
val list = bookmarksFlow.value
|
||||
val existing = list.indexOfFirst { same(it.url, clean) }
|
||||
saveBookmarks(if (existing >= 0) list.toMutableList().also { it[existing] = Bookmark(name, list[existing].url) } else list + Bookmark(name, clean))
|
||||
return true
|
||||
}
|
||||
|
||||
fun removeBookmark(url: String) = saveBookmarks(bookmarksFlow.value.filterNot { same(it.url, url) })
|
||||
|
||||
/** Moves the bookmark at [from] to [to], for reordering from the phone. */
|
||||
fun moveBookmark(from: Int, to: Int) {
|
||||
val list = bookmarksFlow.value.toMutableList()
|
||||
if (from !in list.indices || to !in list.indices) return
|
||||
list.add(to, list.removeAt(from))
|
||||
saveBookmarks(list)
|
||||
}
|
||||
|
||||
/** The same page whatever the trailing slash or scheme. */
|
||||
private fun same(a: String, b: String): Boolean {
|
||||
fun key(u: String) = u.trim().removePrefix("https://").removePrefix("http://").removePrefix("www.").trimEnd('/')
|
||||
return key(a).equals(key(b), ignoreCase = true)
|
||||
}
|
||||
|
||||
private fun saveBookmarks(list: List<Bookmark>) {
|
||||
bookmarksFlow.value = list
|
||||
val array = JSONArray()
|
||||
list.forEach { array.put(JSONObject().put("title", it.title).put("url", it.url)) }
|
||||
prefs?.edit()?.putString(KEY_BOOKMARKS, array.toString())?.apply()
|
||||
}
|
||||
|
||||
private fun readBookmarks(json: String): List<Bookmark> = runCatching {
|
||||
val array = JSONArray(json)
|
||||
(0 until array.length()).map { array.getJSONObject(it) }.map { Bookmark(it.optString("title"), it.optString("url")) }
|
||||
.filter { it.url.isNotEmpty() }
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
// Search
|
||||
|
||||
fun setSearchEngine(engine: SearchEngine) {
|
||||
searchFlow.value = engine
|
||||
prefs?.edit()?.putString(KEY_SEARCH, engine.id)?.apply()
|
||||
}
|
||||
|
||||
/** Something that looks like an address opens as one; anything else is searched for. */
|
||||
fun addressToUrl(typed: String): String {
|
||||
val text = typed.trim()
|
||||
return when {
|
||||
text.startsWith("http://") || text.startsWith("https://") -> text
|
||||
' ' !in text && '.' in text -> "https://$text"
|
||||
else -> searchFlow.value.search(text)
|
||||
}
|
||||
}
|
||||
|
||||
// Ad blocking
|
||||
|
||||
fun setAdBlock(on: Boolean) {
|
||||
adBlockFlow.value = on
|
||||
prefs?.edit()?.putBoolean(KEY_ADBLOCK, on)?.apply()
|
||||
}
|
||||
|
||||
fun setYouTubeAdBlock(on: Boolean) {
|
||||
youTubeFlow.value = on
|
||||
prefs?.edit()?.putBoolean(KEY_YOUTUBE, on)?.apply()
|
||||
}
|
||||
|
||||
fun setListEnabled(id: String, on: Boolean) {
|
||||
val p = prefs ?: return
|
||||
val off = p.getStringSet(KEY_LISTS_OFF, emptySet()).orEmpty().toMutableSet()
|
||||
if (on) off.remove(id) else off.add(id)
|
||||
p.edit().putStringSet(KEY_LISTS_OFF, off).apply()
|
||||
listsFlow.value = readLists(p)
|
||||
}
|
||||
|
||||
/** Adds a list by its address. Returns false for something that isn't one, or one already there. */
|
||||
fun addCustomList(url: String): Boolean {
|
||||
val p = prefs ?: return false
|
||||
val clean = url.trim()
|
||||
if (!clean.startsWith("https://") && !clean.startsWith("http://")) return false
|
||||
if (listsFlow.value.any { it.url.equals(clean, ignoreCase = true) }) return false
|
||||
p.edit().putString(KEY_CUSTOM_LISTS, (customUrls(p) + clean).joinToString("\n")).apply()
|
||||
listsFlow.value = readLists(p)
|
||||
return true
|
||||
}
|
||||
|
||||
fun removeCustomList(id: String) {
|
||||
val p = prefs ?: return
|
||||
val list = listsFlow.value.firstOrNull { it.id == id && !it.builtIn } ?: return
|
||||
p.edit().putString(KEY_CUSTOM_LISTS, customUrls(p).filterNot { it == list.url }.joinToString("\n")).apply()
|
||||
listsFlow.value = readLists(p)
|
||||
}
|
||||
|
||||
var lastUpdateCheck: Long
|
||||
get() = prefs?.getLong(KEY_LAST_CHECK, 0L) ?: 0L
|
||||
set(value) { prefs?.edit()?.putLong(KEY_LAST_CHECK, value)?.apply() }
|
||||
|
||||
private fun customUrls(p: SharedPreferences) = p.getString(KEY_CUSTOM_LISTS, "").orEmpty().lines().filter { it.isNotBlank() }
|
||||
|
||||
private fun readLists(p: SharedPreferences): List<FilterList> {
|
||||
val off = p.getStringSet(KEY_LISTS_OFF, emptySet()).orEmpty()
|
||||
val custom = customUrls(p).map { url ->
|
||||
val name = runCatching { android.net.Uri.parse(url).lastPathSegment }.getOrNull()?.takeIf { it.isNotBlank() } ?: url
|
||||
FilterList(customId(url), name, url, builtIn = false, enabled = true)
|
||||
}
|
||||
return (BUILT_IN_LISTS + custom).map { it.copy(enabled = it.id !in off) }
|
||||
}
|
||||
|
||||
/** A stable file name for a list known only by its address. */
|
||||
private fun customId(url: String): String {
|
||||
val digest = java.security.MessageDigest.getInstance("SHA-256").digest(url.toByteArray())
|
||||
return "custom-" + digest.take(8).joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package app.seb3thehacker.gearslip.browser
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.widget.Toast
|
||||
import app.seb3thehacker.gearslip.R
|
||||
|
||||
/**
|
||||
* The "Add to Gearslip" entry in Android's share sheet. A page shared from any browser becomes a
|
||||
* tile on the car browser's start page, so nobody has to type an address on the car's keyboard.
|
||||
* It shows nothing of its own: a toast says whether it worked, and it closes.
|
||||
*/
|
||||
class ShareToBrowserActivity : Activity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
BrowserSettings.init(this)
|
||||
val text = intent?.takeIf { it.action == Intent.ACTION_SEND }?.getStringExtra(Intent.EXTRA_TEXT).orEmpty()
|
||||
// Browsers share the bare address; some apps wrap it in a sentence, so take the first link.
|
||||
val url = URL_PATTERN.find(text)?.value
|
||||
val title = intent?.getStringExtra(Intent.EXTRA_SUBJECT) ?: intent?.getStringExtra(Intent.EXTRA_TITLE) ?: ""
|
||||
val added = url != null && BrowserSettings.addBookmark(title, url)
|
||||
Toast.makeText(this, getString(if (added) R.string.browser_shared_added else R.string.browser_shared_not_url), Toast.LENGTH_SHORT).show()
|
||||
finish()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val URL_PATTERN = Regex("""https?://\S+""")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package app.seb3thehacker.gearslip.browser
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import app.seb3thehacker.gearslip.GearslipLog
|
||||
import java.io.File
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* Each site's icon for the start page's tiles, kept on disk by host. The browser saves the icon
|
||||
* a page hands it while loading ([save]); a bookmark that hasn't been opened yet asks its own
|
||||
* site once ([fetch]), for its touch icon and then its favicon. Nothing goes to a third-party
|
||||
* icon service, so no list of the driver's bookmarks leaves the phone.
|
||||
*/
|
||||
object SiteIcons {
|
||||
|
||||
private const val DIRECTORY = "site-icons"
|
||||
/** Smaller than this is a 16px favicon that would look blurry on a tile; a bigger one wins. */
|
||||
private const val GOOD_ENOUGH_PX = 96
|
||||
|
||||
private val memory = ConcurrentHashMap<String, Bitmap>()
|
||||
private val asked = ConcurrentHashMap.newKeySet<String>()
|
||||
private val changed = MutableStateFlow(0)
|
||||
|
||||
/** Bumps whenever an icon is saved, so tiles look again. */
|
||||
val version: StateFlow<Int> = changed
|
||||
|
||||
fun icon(context: Context, host: String): Bitmap? {
|
||||
if (host.isEmpty()) return null
|
||||
memory[host]?.let { return it }
|
||||
val file = file(context, host)
|
||||
if (!file.isFile) return null
|
||||
return runCatching { BitmapFactory.decodeFile(file.path) }.getOrNull()?.also { memory[host] = it }
|
||||
}
|
||||
|
||||
/** An icon the page gave the browser. Kept when it's bigger than the one already saved. */
|
||||
fun save(context: Context, host: String, bitmap: Bitmap) {
|
||||
if (host.isEmpty()) return
|
||||
val current = icon(context, host)
|
||||
if (current != null && current.width >= bitmap.width) return
|
||||
store(context, host, bitmap)
|
||||
}
|
||||
|
||||
/** Asks [host] for its icon in the background, once per run, when none is saved or it's small. */
|
||||
fun fetch(context: Context, host: String) {
|
||||
if (host.isEmpty() || !asked.add(host)) return
|
||||
val current = icon(context, host)
|
||||
if (current != null && current.width >= GOOD_ENOUGH_PX) return
|
||||
val app = context.applicationContext
|
||||
Thread({
|
||||
for (path in listOf("/apple-touch-icon.png", "/favicon.ico")) {
|
||||
val bitmap = runCatching { download("https://$host$path") }.getOrNull() ?: continue
|
||||
if (current == null || bitmap.width > current.width) store(app, host, bitmap)
|
||||
return@Thread
|
||||
}
|
||||
}, "site-icon").apply { isDaemon = true }.start()
|
||||
}
|
||||
|
||||
private fun store(context: Context, host: String, bitmap: Bitmap) {
|
||||
memory[host] = bitmap
|
||||
runCatching {
|
||||
val file = file(context, host)
|
||||
file.parentFile?.mkdirs()
|
||||
file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) }
|
||||
}.onFailure { GearslipLog.w("browser: couldn't save the icon for $host: ${it.message}") }
|
||||
changed.value++
|
||||
}
|
||||
|
||||
private fun download(url: String): Bitmap? {
|
||||
val connection = (URL(url).openConnection() as HttpURLConnection).apply {
|
||||
connectTimeout = 10_000
|
||||
readTimeout = 10_000
|
||||
instanceFollowRedirects = true
|
||||
}
|
||||
return try {
|
||||
if (connection.responseCode !in 200..299) return null
|
||||
val bytes = connection.inputStream.use { it.readBytes() }
|
||||
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private fun file(context: Context, host: String) =
|
||||
File(File(context.filesDir, DIRECTORY), host.replace(Regex("[^A-Za-z0-9.-]"), "_") + ".png")
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
package app.seb3thehacker.gearslip.browser.adblock
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
import android.util.Base64
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebResourceResponse
|
||||
import app.seb3thehacker.gearslip.GearslipLog
|
||||
import app.seb3thehacker.gearslip.browser.BrowserSettings
|
||||
import app.seb3thehacker.gearslip.browser.FilterList
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.File
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
/**
|
||||
* Owns the filter lists: downloading them, keeping a parsed [FilterEngine] in memory, and
|
||||
* answering the two questions the WebView asks: block this request, and what to hide on
|
||||
* this page.
|
||||
*
|
||||
* Everything the request path touches is cached in memory. `shouldInterceptRequest` runs on a
|
||||
* background thread for every subresource on the page, so it must never read preferences or
|
||||
* touch disk. The switches come from [BrowserSettings]'s flows, which are plain memory reads.
|
||||
*
|
||||
* Downloading a list contacts the server it comes from (easylist.to for the two built in), and
|
||||
* nothing else: no page the driver visits is ever sent anywhere.
|
||||
*/
|
||||
object AdBlockManager {
|
||||
|
||||
sealed class RefreshResult {
|
||||
data class Success(val networkRules: Int, val cosmeticRules: Int) : RefreshResult()
|
||||
data class Failure(val message: String) : RefreshResult()
|
||||
}
|
||||
|
||||
private const val DIRECTORY = "adblock"
|
||||
private const val BUNDLED_ASSET = "adblock/bundled.txt"
|
||||
private const val MAX_LIST_BYTES = 12L * 1024 * 1024
|
||||
|
||||
/**
|
||||
* How stale the lists may get before the browser refreshes them on its own. EasyList
|
||||
* publishes several times a day, but the parts that matter, the domains ads are served from,
|
||||
* turn over slowly, and each update costs a few megabytes.
|
||||
*/
|
||||
private const val UPDATE_INTERVAL_MS = 7L * 24 * 60 * 60 * 1000
|
||||
|
||||
// 1x1 transparent GIF, returned for blocked images so pages show a gap rather than a
|
||||
// broken-image icon.
|
||||
private const val TRANSPARENT_GIF_BASE64 =
|
||||
"R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
|
||||
|
||||
private val EMPTY = ByteArray(0)
|
||||
private val EMPTY_JSON = "{}".toByteArray()
|
||||
|
||||
val transparentGifBytes: ByteArray by lazy {
|
||||
Base64.decode(TRANSPARENT_GIF_BASE64, Base64.DEFAULT)
|
||||
}
|
||||
|
||||
@Volatile
|
||||
private var engine: FilterEngine? = null
|
||||
|
||||
/** No per-site allowlist yet; kept so the request path reads the same as AABrowser's. */
|
||||
private val allowlist: Set<String> = emptySet()
|
||||
|
||||
private val enabled: Boolean get() = BrowserSettings.adBlock.value
|
||||
private val youTubeEnabled: Boolean get() = BrowserSettings.youTubeAdBlock.value
|
||||
|
||||
private val loadStarted = AtomicBoolean(false)
|
||||
private val refreshRunning = AtomicBoolean(false)
|
||||
private val autoUpdateAttempted = AtomicBoolean(false)
|
||||
private val blockedCount = AtomicLong(0)
|
||||
|
||||
val isEngineReady: Boolean get() = engine != null
|
||||
val blockedRequestCount: Long get() = blockedCount.get()
|
||||
val networkRuleCount: Int get() = engine?.networkRuleCount ?: 0
|
||||
val cosmeticRuleCount: Int get() = engine?.cosmeticRuleCount ?: 0
|
||||
|
||||
/** Loads the cached lists from disk once per process, on a background thread. */
|
||||
fun ensureInitialized(context: Context) {
|
||||
BrowserSettings.init(context)
|
||||
if (!enabled) return
|
||||
if (!loadStarted.compareAndSet(false, true)) return
|
||||
|
||||
val appContext = context.applicationContext
|
||||
Thread({
|
||||
runCatching { loadFromDisk(appContext) }
|
||||
.onFailure { GearslipLog.w("adblock: could not load the filter lists: ${it.message}") }
|
||||
}, "adblock-load").apply {
|
||||
// Blocking does nothing until this finishes, so it gets a normal share of the CPU.
|
||||
// On a Pixel 6 a release build parses both lists in about 250ms.
|
||||
priority = Thread.NORM_PRIORITY
|
||||
start()
|
||||
}
|
||||
}
|
||||
|
||||
/** Rebuilds the engine from what's on disk after a list was turned on or off. */
|
||||
fun reload(context: Context) {
|
||||
val appContext = context.applicationContext
|
||||
loadStarted.set(true)
|
||||
Thread({ runCatching { loadFromDisk(appContext) } }, "adblock-load").start()
|
||||
}
|
||||
|
||||
fun lastUpdatedAt(context: Context): Long =
|
||||
BrowserSettings.filterLists.value.mapNotNull { cacheFile(context, it).takeIf { file -> file.length() > 0 } }
|
||||
.maxOfOrNull { it.lastModified() } ?: 0L
|
||||
|
||||
private fun cacheFile(context: Context, source: FilterList): File =
|
||||
File(File(context.filesDir, DIRECTORY), "${source.id}.txt")
|
||||
|
||||
private fun loadFromDisk(context: Context) {
|
||||
val lists = BrowserSettings.filterLists.value.filter { it.enabled }
|
||||
val files = lists.map { cacheFile(context, it) }.filter { it.length() > 0 }
|
||||
val builder = FilterEngine.Builder()
|
||||
|
||||
// A built-in list that hasn't downloaded yet comes from the snapshot in the APK, so
|
||||
// blocking works on the very first page rather than only after a download.
|
||||
val needsBundled = lists.any { it.builtIn && cacheFile(context, it).length() == 0L }
|
||||
if (needsBundled) {
|
||||
context.assets.open(BUNDLED_ASSET).bufferedReader(Charsets.UTF_8).use { reader ->
|
||||
while (true) {
|
||||
val line = reader.readLine() ?: break
|
||||
builder.addLine(line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (file in files) {
|
||||
file.bufferedReader(bufferSize = 64 * 1024).use { reader ->
|
||||
while (true) {
|
||||
val line = reader.readLine() ?: break
|
||||
builder.addLine(line)
|
||||
}
|
||||
}
|
||||
}
|
||||
engine = builder.finish()
|
||||
GearslipLog.i("adblock: ${networkRuleCount} network and ${cosmeticRuleCount} hiding rules from ${lists.size} lists")
|
||||
}
|
||||
|
||||
/** Downloads every list that's on, replaces the cached copies, and rebuilds the engine. */
|
||||
fun refreshLists(context: Context): RefreshResult {
|
||||
// The automatic refresh and the button in settings can both land here; two downloads
|
||||
// writing the same files would leave one of them truncated.
|
||||
val appContext = context.applicationContext
|
||||
if (!refreshRunning.compareAndSet(false, true)) {
|
||||
return RefreshResult.Failure("An update is already running.")
|
||||
}
|
||||
return try {
|
||||
refresh(appContext).also {
|
||||
// A manual update counts as a check, so the automatic one waits a full interval.
|
||||
if (it is RefreshResult.Success) BrowserSettings.lastUpdateCheck = System.currentTimeMillis()
|
||||
}
|
||||
} finally {
|
||||
refreshRunning.set(false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the lists when they have gone stale and the phone is on Wi-Fi, on a background
|
||||
* thread. Runs when the car's browser opens; there is no scheduled job. Only one attempt per
|
||||
* process, and the time is written on success alone, so a phone that was offline tries again
|
||||
* next time rather than in a week.
|
||||
*/
|
||||
fun refreshIfDue(context: Context) {
|
||||
val appContext = context.applicationContext
|
||||
BrowserSettings.init(appContext)
|
||||
if (!enabled || !isUpdateDue(appContext) || !onUnmeteredNetwork(appContext)) return
|
||||
if (!autoUpdateAttempted.compareAndSet(false, true)) return
|
||||
Thread({
|
||||
val result = refreshLists(appContext)
|
||||
GearslipLog.i("adblock: weekly list update - $result")
|
||||
}, "adblock-update").start()
|
||||
}
|
||||
|
||||
private fun isUpdateDue(context: Context): Boolean {
|
||||
val now = System.currentTimeMillis()
|
||||
val lastCheck = BrowserSettings.lastUpdateCheck
|
||||
if (lastCheck > 0 && now - lastCheck < UPDATE_INTERVAL_MS) return false
|
||||
val baseline = lastUpdatedAt(context)
|
||||
// Nothing downloaded yet: the snapshot in the APK may be months old, so fetch now.
|
||||
if (baseline <= 0L) return true
|
||||
// A clock that has jumped backwards would otherwise freeze the lists indefinitely.
|
||||
if (baseline > now) return true
|
||||
return now - baseline >= UPDATE_INTERVAL_MS
|
||||
}
|
||||
|
||||
private fun onUnmeteredNetwork(context: Context): Boolean {
|
||||
val manager = context.getSystemService(ConnectivityManager::class.java) ?: return false
|
||||
val caps = manager.getNetworkCapabilities(manager.activeNetwork) ?: return false
|
||||
return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
|
||||
}
|
||||
|
||||
private fun refresh(context: Context): RefreshResult {
|
||||
val directory = File(context.filesDir, DIRECTORY)
|
||||
if (!directory.exists() && !directory.mkdirs()) {
|
||||
return RefreshResult.Failure("Could not create the filter list folder.")
|
||||
}
|
||||
|
||||
for (source in BrowserSettings.filterLists.value.filter { it.enabled }) {
|
||||
val result = runCatching { download(source, cacheFile(context, source)) }
|
||||
val error = result.exceptionOrNull()
|
||||
if (error != null) return RefreshResult.Failure("${source.title}: ${error.message ?: "download failed"}")
|
||||
val failure = result.getOrNull()
|
||||
if (failure != null) return RefreshResult.Failure("${source.title}: $failure")
|
||||
}
|
||||
|
||||
loadStarted.set(true)
|
||||
val loadError = runCatching { loadFromDisk(context) }.exceptionOrNull()
|
||||
if (loadError != null) return RefreshResult.Failure(loadError.message ?: "Could not read the lists.")
|
||||
|
||||
val current = engine ?: return RefreshResult.Failure("The lists downloaded but contained no rules.")
|
||||
return RefreshResult.Success(current.networkRuleCount, current.cosmeticRuleCount)
|
||||
}
|
||||
|
||||
/** Returns null on success, or a human-readable reason for failure. */
|
||||
private fun download(source: FilterList, destination: File): String? {
|
||||
val connection = (URL(source.url).openConnection() as HttpURLConnection).apply {
|
||||
connectTimeout = 15_000
|
||||
readTimeout = 30_000
|
||||
setRequestProperty("Accept", "text/plain")
|
||||
}
|
||||
try {
|
||||
if (connection.responseCode !in 200..299) return "HTTP ${connection.responseCode}"
|
||||
val temporary = File(destination.parentFile, destination.name + ".tmp")
|
||||
var total = 0L
|
||||
|
||||
temporary.outputStream().use { output ->
|
||||
connection.inputStream.use { input ->
|
||||
val buffer = ByteArray(16 * 1024)
|
||||
while (true) {
|
||||
val read = input.read(buffer)
|
||||
if (read < 0) break
|
||||
total += read
|
||||
if (total > MAX_LIST_BYTES) {
|
||||
temporary.delete()
|
||||
return "list is unexpectedly large"
|
||||
}
|
||||
output.write(buffer, 0, read)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (total == 0L) {
|
||||
temporary.delete()
|
||||
return "empty response"
|
||||
}
|
||||
if (!temporary.renameTo(destination)) {
|
||||
temporary.delete()
|
||||
return "could not save the list"
|
||||
}
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Deletes the cached copy of a list the driver removed. */
|
||||
fun forget(context: Context, list: FilterList) {
|
||||
cacheFile(context, list).delete()
|
||||
}
|
||||
|
||||
fun isAllowlisted(host: String?): Boolean {
|
||||
if (host.isNullOrEmpty()) return false
|
||||
val current = allowlist
|
||||
if (current.isEmpty()) return false
|
||||
val normalized = host.lowercase()
|
||||
return current.any { DomainUtils.hostMatchesDomain(normalized, it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Decides a single subresource request and, if it should be blocked, builds the response for
|
||||
* it. [pageUrl] is the document the request belongs to, tracked by the WebView client because
|
||||
* a WebView's own url can only be read on the UI thread. Null means let the request through.
|
||||
*/
|
||||
fun response(request: WebResourceRequest, pageUrl: String?): WebResourceResponse? {
|
||||
if (!enabled) return null
|
||||
val current = engine ?: return null
|
||||
|
||||
// Never interfere with top-level navigation: a mistake there costs the whole page.
|
||||
if (request.isForMainFrame) return null
|
||||
|
||||
val url = request.url ?: return null
|
||||
val scheme = url.scheme?.lowercase()
|
||||
if (scheme != "http" && scheme != "https") return null
|
||||
|
||||
val documentHost = pageUrl?.let { runCatching { android.net.Uri.parse(it).host }.getOrNull() }
|
||||
if (isAllowlisted(documentHost)) return null
|
||||
|
||||
val requestHost = url.host?.lowercase()
|
||||
val type = ResourceType.infer(request)
|
||||
val isThirdParty = DomainUtils.isThirdParty(requestHost, documentHost)
|
||||
|
||||
val match = runCatching {
|
||||
current.matchBlockingRule(url.toString(), requestHost, documentHost, type, isThirdParty)
|
||||
}.getOrNull() ?: return null
|
||||
|
||||
blockedCount.incrementAndGet()
|
||||
return blockedResponse(request, match.redirectResource)
|
||||
}
|
||||
|
||||
/**
|
||||
* The stand-in served for a blocked request.
|
||||
*
|
||||
* The content type matters as much as the emptiness. A page that asked for a script and
|
||||
* receives `text/plain` does not get a script that does nothing — it gets a resource the
|
||||
* browser refuses to execute, and any code waiting on that script waits until it times
|
||||
* out. YouTube's player does exactly this with its ad-status check, which showed up as a
|
||||
* five to ten second stall before playback. So each blocked request is answered with a
|
||||
* valid, empty thing of the kind it asked for, which is what uBlock Origin's `noop`
|
||||
* redirect resources do.
|
||||
*
|
||||
* When the matched rule named a specific `$redirect=` resource we recognise, that exact
|
||||
* stub is served instead of the generic empty one -- see [RedirectResources] for why that
|
||||
* sometimes matters.
|
||||
*/
|
||||
private fun blockedResponse(request: WebResourceRequest, redirectResource: String?): WebResourceResponse {
|
||||
val headers = mapOf(
|
||||
"Access-Control-Allow-Origin" to "*",
|
||||
"Cache-Control" to "no-store"
|
||||
)
|
||||
|
||||
if (redirectResource != null) {
|
||||
RedirectResources.response(redirectResource, headers)?.let { return it }
|
||||
}
|
||||
|
||||
fun response(mimeType: String, encoding: String?, body: ByteArray) =
|
||||
WebResourceResponse(mimeType, encoding, 200, "OK", headers, ByteArrayInputStream(body))
|
||||
|
||||
return when (ResourceType.infer(request)) {
|
||||
ResourceType.IMAGE -> response("image/gif", null, transparentGifBytes)
|
||||
ResourceType.SCRIPT -> response("application/javascript", "utf-8", EMPTY)
|
||||
ResourceType.STYLESHEET -> response("text/css", "utf-8", EMPTY)
|
||||
ResourceType.SUBDOCUMENT, ResourceType.DOCUMENT -> response("text/html", "utf-8", EMPTY)
|
||||
// Callers parse this; an empty body would throw where "{}" quietly yields nothing.
|
||||
ResourceType.XHR -> response("application/json", "utf-8", EMPTY_JSON)
|
||||
ResourceType.MEDIA -> response("video/mp4", null, EMPTY)
|
||||
ResourceType.FONT -> response("font/woff2", null, EMPTY)
|
||||
else -> response("text/plain", "utf-8", EMPTY)
|
||||
}
|
||||
}
|
||||
|
||||
/** CSS injected at document start, or an empty string when nothing applies. */
|
||||
fun cssForHost(host: String?): String {
|
||||
if (!enabled) return ""
|
||||
if (isAllowlisted(host)) return ""
|
||||
return engine?.cssForHost(host).orEmpty()
|
||||
}
|
||||
|
||||
/** Whether the YouTube-specific ad-stripping scripts should run for [host]. */
|
||||
fun isYouTubeBlockingEnabled(host: String?): Boolean {
|
||||
if (!enabled || !youTubeEnabled) return false
|
||||
return !isAllowlisted(host)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether non-ad cosmetic site tweaks (hiding a redundant footer nav, a Shorts shelf, ...)
|
||||
* should run. Independent of ad blocking: a user may want one without the other, and unlike
|
||||
* ad blocking there is no per-host allowlist for it, since it isn't blocking anything.
|
||||
*/
|
||||
fun isCustomSiteTweaksEnabled(): Boolean = false
|
||||
|
||||
/** Whether a page's own fixed bottom bar should be hidden, per the in-drawer toggle. */
|
||||
fun isHideSiteFootersEnabled(): Boolean = false
|
||||
|
||||
/** CSS for generic rules whose class or id turned up in the page's DOM. */
|
||||
fun cssForTokens(host: String?, classNames: String, ids: String): String {
|
||||
if (!enabled) return ""
|
||||
if (isAllowlisted(host)) return ""
|
||||
return engine?.cssForTokens(host, classNames, ids).orEmpty()
|
||||
}
|
||||
|
||||
/**
|
||||
* The `:has-text()` rules for [host], as a JSON array of `[selector, regexSource]` pairs, for
|
||||
* the page's own JavaScript to apply -- there is no CSS equivalent for a text match, unlike
|
||||
* every other cosmetic rule this engine serves.
|
||||
*/
|
||||
fun hasTextRulesForHost(host: String?): String {
|
||||
if (!enabled) return "[]"
|
||||
if (isAllowlisted(host)) return "[]"
|
||||
val rules = engine?.hasTextRulesForHost(host) ?: return "[]"
|
||||
if (rules.isEmpty()) return "[]"
|
||||
|
||||
val array = org.json.JSONArray()
|
||||
for ((selector, pattern) in rules) {
|
||||
array.put(org.json.JSONArray().put(selector).put(pattern))
|
||||
}
|
||||
return array.toString()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package app.seb3thehacker.gearslip.browser.adblock
|
||||
|
||||
import android.webkit.WebResourceRequest
|
||||
|
||||
/**
|
||||
* Resource types used by filter list options ($script, $image, ...).
|
||||
*
|
||||
* WebResourceRequest does not expose the resource type directly, so [infer] reconstructs it
|
||||
* from the Sec-Fetch-Dest request header where WebView sends one, falling back to the file
|
||||
* extension and then the Accept header.
|
||||
*/
|
||||
internal object ResourceType {
|
||||
const val NONE = 0
|
||||
const val DOCUMENT = 1 shl 0
|
||||
const val SUBDOCUMENT = 1 shl 1
|
||||
const val SCRIPT = 1 shl 2
|
||||
const val IMAGE = 1 shl 3
|
||||
const val STYLESHEET = 1 shl 4
|
||||
const val XHR = 1 shl 5
|
||||
const val FONT = 1 shl 6
|
||||
const val MEDIA = 1 shl 7
|
||||
const val WEBSOCKET = 1 shl 8
|
||||
const val PING = 1 shl 9
|
||||
const val OBJECT = 1 shl 10
|
||||
const val OTHER = 1 shl 11
|
||||
|
||||
fun fromOptionName(name: String): Int = when (name) {
|
||||
"document" -> DOCUMENT
|
||||
"subdocument", "frame" -> SUBDOCUMENT
|
||||
"script" -> SCRIPT
|
||||
"image" -> IMAGE
|
||||
"stylesheet", "css" -> STYLESHEET
|
||||
"xmlhttprequest", "xhr" -> XHR
|
||||
"font" -> FONT
|
||||
"media" -> MEDIA
|
||||
"websocket" -> WEBSOCKET
|
||||
"ping", "beacon" -> PING
|
||||
"object", "object-subrequest" -> OBJECT
|
||||
"other" -> OTHER
|
||||
else -> NONE
|
||||
}
|
||||
|
||||
fun infer(request: WebResourceRequest): Int {
|
||||
if (request.isForMainFrame) return DOCUMENT
|
||||
|
||||
val headers = request.requestHeaders
|
||||
val dest = headers?.entries
|
||||
?.firstOrNull { it.key.equals("Sec-Fetch-Dest", ignoreCase = true) }
|
||||
?.value
|
||||
?.lowercase()
|
||||
|
||||
if (!dest.isNullOrEmpty() && dest != "empty") {
|
||||
val fromDest = when (dest) {
|
||||
"document" -> DOCUMENT
|
||||
"iframe", "frame", "embed" -> SUBDOCUMENT
|
||||
"script", "worker", "sharedworker", "serviceworker" -> SCRIPT
|
||||
"image" -> IMAGE
|
||||
"style" -> STYLESHEET
|
||||
"font" -> FONT
|
||||
"audio", "video", "track", "audioworklet" -> MEDIA
|
||||
"object" -> OBJECT
|
||||
"report" -> PING
|
||||
"manifest" -> OTHER
|
||||
else -> NONE
|
||||
}
|
||||
if (fromDest != NONE) return fromDest
|
||||
}
|
||||
|
||||
val fromExtension = extensionType(request.url?.path)
|
||||
if (fromExtension != NONE) return fromExtension
|
||||
|
||||
val accept = headers?.entries
|
||||
?.firstOrNull { it.key.equals("Accept", ignoreCase = true) }
|
||||
?.value
|
||||
?.lowercase()
|
||||
if (accept != null) {
|
||||
when {
|
||||
accept.startsWith("image/") || accept.contains("image/avif") -> return IMAGE
|
||||
accept.startsWith("text/css") -> return STYLESHEET
|
||||
accept.startsWith("video/") || accept.startsWith("audio/") -> return MEDIA
|
||||
accept.contains("application/javascript") -> return SCRIPT
|
||||
}
|
||||
}
|
||||
|
||||
// "empty" is what fetch()/XMLHttpRequest report, and is also the fallback when
|
||||
// WebView sends no Sec-Fetch-Dest at all.
|
||||
return if (dest == "empty") XHR else OTHER
|
||||
}
|
||||
|
||||
private fun extensionType(path: String?): Int {
|
||||
if (path.isNullOrEmpty()) return NONE
|
||||
val dot = path.lastIndexOf('.')
|
||||
if (dot < 0 || dot == path.length - 1) return NONE
|
||||
return when (path.substring(dot + 1).lowercase()) {
|
||||
"js", "mjs" -> SCRIPT
|
||||
"css" -> STYLESHEET
|
||||
"png", "jpg", "jpeg", "gif", "webp", "svg", "ico", "bmp", "avif" -> IMAGE
|
||||
"woff", "woff2", "ttf", "otf", "eot" -> FONT
|
||||
"mp4", "webm", "ogg", "mp3", "m4a", "m4s", "ts", "m3u8", "mpd" -> MEDIA
|
||||
"json" -> XHR
|
||||
"swf" -> OBJECT
|
||||
else -> NONE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Helpers for comparing hosts without pulling in a full public suffix list. */
|
||||
internal object DomainUtils {
|
||||
/**
|
||||
* Common multi-label public suffixes. This is deliberately a short list rather than the
|
||||
* full PSL: it is only used to decide first-party vs third-party, where being wrong means
|
||||
* a $third-party rule misfires on an uncommon TLD, not a broken page.
|
||||
*/
|
||||
private val MULTI_LABEL_SUFFIXES = hashSetOf(
|
||||
"co.uk", "org.uk", "me.uk", "ac.uk", "gov.uk", "net.uk", "sch.uk",
|
||||
"co.jp", "ne.jp", "or.jp", "ac.jp", "go.jp",
|
||||
"com.au", "net.au", "org.au", "edu.au", "gov.au",
|
||||
"co.nz", "net.nz", "org.nz",
|
||||
"com.br", "net.br", "org.br",
|
||||
"co.in", "net.in", "org.in",
|
||||
"com.cn", "net.cn", "org.cn",
|
||||
"co.kr", "or.kr",
|
||||
"com.mx", "com.ar", "com.tr", "com.tw", "com.hk", "com.sg", "com.my",
|
||||
"co.za", "com.pl", "com.ua", "co.il", "com.vn", "com.ph", "co.id",
|
||||
"com.es", "com.pt", "com.gr", "com.ro", "com.ru", "org.ru", "net.ru"
|
||||
)
|
||||
|
||||
fun registrableDomain(host: String?): String? {
|
||||
if (host.isNullOrEmpty()) return null
|
||||
val normalized = host.trimEnd('.').lowercase()
|
||||
if (normalized.isEmpty()) return null
|
||||
if (normalized.indexOf(':') >= 0) return normalized
|
||||
|
||||
val labels = normalized.split('.')
|
||||
if (labels.size <= 2) return normalized
|
||||
if (labels.all { label -> label.isNotEmpty() && label.all { it in '0'..'9' } }) return normalized
|
||||
|
||||
val lastTwo = labels[labels.size - 2] + "." + labels[labels.size - 1]
|
||||
if (lastTwo in MULTI_LABEL_SUFFIXES && labels.size >= 3) {
|
||||
return labels[labels.size - 3] + "." + lastTwo
|
||||
}
|
||||
return lastTwo
|
||||
}
|
||||
|
||||
fun isThirdParty(requestHost: String?, documentHost: String?): Boolean {
|
||||
if (requestHost.isNullOrEmpty() || documentHost.isNullOrEmpty()) return false
|
||||
val request = registrableDomain(requestHost) ?: return false
|
||||
val document = registrableDomain(documentHost) ?: return false
|
||||
return !request.equals(document, ignoreCase = true)
|
||||
}
|
||||
|
||||
/** True when [host] is [domain] itself or a subdomain of it. */
|
||||
fun hostMatchesDomain(host: String, domain: String): Boolean {
|
||||
if (host == domain) return true
|
||||
return host.length > domain.length &&
|
||||
host.endsWith(domain) &&
|
||||
host[host.length - domain.length - 1] == '.'
|
||||
}
|
||||
|
||||
/** Emits `a.b.example.com`, `b.example.com`, `example.com`, `com`. */
|
||||
inline fun forEachHostSuffix(host: String, action: (String) -> Unit) {
|
||||
var index = 0
|
||||
while (true) {
|
||||
action(if (index == 0) host else host.substring(index))
|
||||
val dot = host.indexOf('.', index)
|
||||
if (dot < 0) return
|
||||
index = dot + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
+336
@@ -0,0 +1,336 @@
|
||||
package app.seb3thehacker.gearslip.browser.adblock
|
||||
|
||||
import android.content.Context
|
||||
import android.webkit.JavascriptInterface
|
||||
import android.webkit.WebView
|
||||
import androidx.webkit.WebViewCompat
|
||||
import androidx.webkit.WebViewFeature
|
||||
|
||||
/**
|
||||
* Applies element-hiding rules to a page.
|
||||
*
|
||||
* Which rules apply depends on the page's hostname and on what is actually in its DOM, neither
|
||||
* of which is known until the document starts loading, so the page fetches its own stylesheet
|
||||
* through a JavaScript bridge. The bridge exposes two pure functions that return CSS: they read
|
||||
* no page state and perform no action, so handing them to untrusted page content costs nothing.
|
||||
*
|
||||
* Hiding happens in two passes. At document start the page gets the rules written for its own
|
||||
* host, which must land before anything renders — injecting on page finish would let ads appear
|
||||
* and then vanish, which is worse than not hiding them. The generic rules follow once the DOM
|
||||
* exists, keyed on the classes and ids actually present, because shipping all of them up front
|
||||
* would mean roughly 400 KB of CSS per page.
|
||||
*/
|
||||
object CosmeticFilterBridge {
|
||||
|
||||
const val BRIDGE_OBJECT_NAME = "GearslipContentBlocker"
|
||||
|
||||
/** Stop watching for new elements after this long, to spare the CPU on a head unit. */
|
||||
private const val OBSERVE_DURATION_MS = 30000
|
||||
|
||||
/** Upper bound on how many new class/id names are sent to the bridge in one pass. */
|
||||
private const val MAX_TOKENS_PER_PASS = 4000
|
||||
|
||||
/** True when document-start injection is unavailable and the client must inject later. */
|
||||
val needsPageStartFallback: Boolean
|
||||
get() = !WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)
|
||||
|
||||
val INJECTION_JS: String = """
|
||||
(function () {
|
||||
try {
|
||||
if (window.__gearslipCosmetic) { return; }
|
||||
var bridge = window.$BRIDGE_OBJECT_NAME;
|
||||
if (!bridge || typeof bridge.cssForHost !== 'function') { return; }
|
||||
|
||||
var host = location.hostname;
|
||||
var seenClass = Object.create(null);
|
||||
var seenId = Object.create(null);
|
||||
window.__gearslipCosmetic = true;
|
||||
|
||||
function addStyle(css) {
|
||||
if (!css) { return; }
|
||||
var style = document.createElement('style');
|
||||
style.setAttribute('data-gearslip', 'content-blocker');
|
||||
style.textContent = css;
|
||||
var root = document.head || document.documentElement;
|
||||
if (root) { root.appendChild(style); }
|
||||
}
|
||||
|
||||
// The engine loads from disk in the background and may not be ready when the
|
||||
// first page starts. Keep asking until it answers, so a page that loses the race
|
||||
// still gets its hiding rules rather than none at all.
|
||||
var hostCssApplied = false;
|
||||
function applyHostCss() {
|
||||
if (hostCssApplied) { return true; }
|
||||
var css = bridge.cssForHost(host);
|
||||
if (!css) { return false; }
|
||||
hostCssApplied = true;
|
||||
addStyle(css);
|
||||
return true;
|
||||
}
|
||||
applyHostCss();
|
||||
var retries = [250, 750, 1500, 3000, 5000, 8000, 12000];
|
||||
for (var r = 0; r < retries.length; r++) {
|
||||
setTimeout(function () {
|
||||
try {
|
||||
if (!hostCssApplied && applyHostCss()) { collect(); }
|
||||
} catch (e) {}
|
||||
}, retries[r]);
|
||||
}
|
||||
|
||||
var classes = [];
|
||||
var ids = [];
|
||||
|
||||
function scan(root, budget) {
|
||||
if (!root || root.nodeType !== 1) { return budget; }
|
||||
var elements = root.getElementsByTagName('*');
|
||||
var total = elements.length + 1;
|
||||
for (var i = 0; i < total && budget > 0; i++) {
|
||||
var element = (i === 0) ? root : elements[i - 1];
|
||||
var id = element.id;
|
||||
if (id && !seenId[id]) { seenId[id] = true; ids.push(id); budget--; }
|
||||
// SVG elements expose className as an SVGAnimatedString, not a string.
|
||||
var names = element.className;
|
||||
if (names && typeof names === 'string') {
|
||||
var parts = names.split(/\s+/);
|
||||
for (var j = 0; j < parts.length && budget > 0; j++) {
|
||||
var name = parts[j];
|
||||
if (name && !seenClass[name]) {
|
||||
seenClass[name] = true;
|
||||
classes.push(name);
|
||||
budget--;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return budget;
|
||||
}
|
||||
|
||||
function collect() {
|
||||
classes.length = 0;
|
||||
ids.length = 0;
|
||||
var budget = $MAX_TOKENS_PER_PASS;
|
||||
if (!scannedOnce) {
|
||||
scannedOnce = true;
|
||||
budget = scan(document.documentElement, budget);
|
||||
} else {
|
||||
// Only look at what the page actually added. Re-walking the whole document
|
||||
// on every mutation costs far more than it finds: on a large single-page app
|
||||
// it means a full DOM traversal every 50ms while the page is starting up.
|
||||
for (var i = 0; i < pending.length && budget > 0; i++) {
|
||||
budget = scan(pending[i], budget);
|
||||
}
|
||||
}
|
||||
pending.length = 0;
|
||||
if (classes.length || ids.length) {
|
||||
addStyle(bridge.cssForTokens(host, classes.join(' '), ids.join(' ')));
|
||||
}
|
||||
}
|
||||
|
||||
var scannedOnce = false;
|
||||
var pending = [];
|
||||
var scheduled = false;
|
||||
function schedule() {
|
||||
if (scheduled) { return; }
|
||||
scheduled = true;
|
||||
setTimeout(function () {
|
||||
scheduled = false;
|
||||
try { collect(); } catch (e) {}
|
||||
}, 50);
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', schedule);
|
||||
} else {
|
||||
schedule();
|
||||
}
|
||||
|
||||
if (typeof MutationObserver === 'function' && document.documentElement) {
|
||||
var observer = new MutationObserver(function (records) {
|
||||
for (var i = 0; i < records.length; i++) {
|
||||
var added = records[i].addedNodes;
|
||||
for (var j = 0; j < added.length; j++) {
|
||||
if (added[j].nodeType === 1) { pending.push(added[j]); }
|
||||
}
|
||||
}
|
||||
// A burst larger than this is a page rebuilding itself; the tail is enough.
|
||||
if (pending.length > 300) { pending.splice(0, pending.length - 300); }
|
||||
if (pending.length) { schedule(); }
|
||||
});
|
||||
observer.observe(document.documentElement, { childList: true, subtree: true });
|
||||
setTimeout(function () {
|
||||
try { observer.disconnect(); } catch (e) {}
|
||||
}, $OBSERVE_DURATION_MS);
|
||||
}
|
||||
} catch (e) {}
|
||||
})();
|
||||
(function () {
|
||||
// Hides a site's own fixed bottom nav bar, which duplicates navigation the app
|
||||
// already provides and costs a sixth of the screen on a ~480px-tall car display.
|
||||
// Found by measuring rather than by a list of known selectors, so it works on any
|
||||
// site. Toggled from the drawer.
|
||||
//
|
||||
// Hiding rather than repositioning is deliberate: an earlier version moved these
|
||||
// bars instead, which fought sites that animate the bar's own position (Twitch
|
||||
// slides its nav bar on scroll) and left it stranded mid-page. display:none has
|
||||
// nothing to fight.
|
||||
try {
|
||||
if (window.__gearslipFooterHiding) { return; }
|
||||
window.__gearslipFooterHiding = true;
|
||||
var bridge = window.$BRIDGE_OBJECT_NAME;
|
||||
if (!bridge || typeof bridge.hideSiteFootersEnabled !== 'function') { return; }
|
||||
if (!bridge.hideSiteFootersEnabled()) { return; }
|
||||
|
||||
var MAX_BAR_HEIGHT = 140;
|
||||
|
||||
function hideBottomBars(root, depth) {
|
||||
if (!root || depth < 0 || !root.children) { return; }
|
||||
for (var i = 0; i < root.children.length; i++) {
|
||||
var el = root.children[i];
|
||||
if (el.__gearslipFooterHidden) { continue; }
|
||||
var cs;
|
||||
try { cs = getComputedStyle(el); } catch (e) { continue; }
|
||||
if (cs.display === 'none') { continue; }
|
||||
if (cs.position === 'fixed' || cs.position === 'sticky') {
|
||||
var rect;
|
||||
try { rect = el.getBoundingClientRect(); } catch (e) { rect = null; }
|
||||
if (rect && rect.width >= window.innerWidth * 0.6 &&
|
||||
rect.height > 0 && rect.height <= MAX_BAR_HEIGHT &&
|
||||
Math.abs(window.innerHeight - rect.bottom) < 6) {
|
||||
el.__gearslipFooterHidden = true;
|
||||
el.style.setProperty('display', 'none', 'important');
|
||||
continue;
|
||||
}
|
||||
}
|
||||
hideBottomBars(el, depth - 1);
|
||||
}
|
||||
}
|
||||
|
||||
function run() {
|
||||
try {
|
||||
hideBottomBars(document.body, 3);
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', run);
|
||||
} else {
|
||||
run();
|
||||
}
|
||||
// Single-page apps mount their nav bar well after first paint, and swap it out
|
||||
// again on navigation, so one pass at load isn't enough.
|
||||
var passes = 0;
|
||||
var timer = setInterval(function () {
|
||||
run();
|
||||
if (++passes > 40) { clearInterval(timer); }
|
||||
}, 500);
|
||||
} catch (e) {}
|
||||
})();
|
||||
(function () {
|
||||
// ":has-text(pattern)" cosmetic rules: hides elements matching a base selector whose
|
||||
// own text also matches a pattern. There is no CSS equivalent for the text half, so
|
||||
// this runs the check in JavaScript instead of the plain <style> the rest of the
|
||||
// cosmetic filters use.
|
||||
try {
|
||||
if (window.__gearslipHasText) { return; }
|
||||
var bridge = window.$BRIDGE_OBJECT_NAME;
|
||||
if (!bridge || typeof bridge.hasTextRulesForHost !== 'function') { return; }
|
||||
|
||||
var rules;
|
||||
try {
|
||||
rules = JSON.parse(bridge.hasTextRulesForHost(location.hostname) || '[]');
|
||||
} catch (e) {
|
||||
rules = [];
|
||||
}
|
||||
if (!rules.length) { return; }
|
||||
window.__gearslipHasText = true;
|
||||
|
||||
var compiled = [];
|
||||
for (var i = 0; i < rules.length; i++) {
|
||||
try {
|
||||
compiled.push({ selector: rules[i][0], regex: new RegExp(rules[i][1], 'i') });
|
||||
} catch (e) {}
|
||||
}
|
||||
if (!compiled.length) { return; }
|
||||
|
||||
function run() {
|
||||
for (var i = 0; i < compiled.length; i++) {
|
||||
var rule = compiled[i];
|
||||
var elements;
|
||||
try { elements = document.querySelectorAll(rule.selector); } catch (e) { continue; }
|
||||
for (var j = 0; j < elements.length; j++) {
|
||||
var el = elements[j];
|
||||
if (el.__gearslipHasTextHidden) { continue; }
|
||||
var text = el.textContent;
|
||||
if (text && rule.regex.test(text)) {
|
||||
el.__gearslipHasTextHidden = true;
|
||||
el.style.setProperty('display', 'none', 'important');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', run);
|
||||
} else {
|
||||
run();
|
||||
}
|
||||
// Matches uBlock Origin's own behaviour for this rule type: it re-evaluates on a
|
||||
// timer rather than per-mutation, since a text match can only be judged once an
|
||||
// element's subtree has actually finished changing.
|
||||
var passes = 0;
|
||||
var timer = setInterval(function () {
|
||||
run();
|
||||
if (++passes > 40) { clearInterval(timer); }
|
||||
}, 500);
|
||||
} catch (e) {}
|
||||
})();
|
||||
""".trimIndent()
|
||||
|
||||
fun attach(webView: WebView) {
|
||||
webView.addJavascriptInterface(Bridge(webView.context.applicationContext), BRIDGE_OBJECT_NAME)
|
||||
if (!needsPageStartFallback) {
|
||||
runCatching {
|
||||
WebViewCompat.addDocumentStartJavaScript(webView, INJECTION_JS, setOf("*"))
|
||||
}
|
||||
runCatching {
|
||||
WebViewCompat.addDocumentStartJavaScript(
|
||||
webView,
|
||||
YouTubeAdBlock.INJECTION_JS,
|
||||
setOf("*")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Bridge(private val context: Context) {
|
||||
@JavascriptInterface
|
||||
fun cssForHost(host: String?): String = runCatching {
|
||||
AdBlockManager.cssForHost(host)
|
||||
}.getOrDefault("")
|
||||
|
||||
@JavascriptInterface
|
||||
fun youtubeBlockingEnabled(host: String?): Boolean = runCatching {
|
||||
AdBlockManager.isYouTubeBlockingEnabled(host)
|
||||
}.getOrDefault(false)
|
||||
|
||||
@JavascriptInterface
|
||||
fun customSiteTweaksEnabled(): Boolean = runCatching {
|
||||
AdBlockManager.isCustomSiteTweaksEnabled()
|
||||
}.getOrDefault(false)
|
||||
|
||||
@JavascriptInterface
|
||||
fun hideSiteFootersEnabled(): Boolean = runCatching {
|
||||
AdBlockManager.isHideSiteFootersEnabled()
|
||||
}.getOrDefault(false)
|
||||
|
||||
@JavascriptInterface
|
||||
fun hasTextRulesForHost(host: String?): String = runCatching {
|
||||
AdBlockManager.hasTextRulesForHost(host)
|
||||
}.getOrDefault("[]")
|
||||
|
||||
@JavascriptInterface
|
||||
fun cssForTokens(host: String?, classNames: String?, ids: String?): String = runCatching {
|
||||
AdBlockManager.cssForTokens(host, classNames.orEmpty(), ids.orEmpty())
|
||||
}.getOrDefault("")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,610 @@
|
||||
package app.seb3thehacker.gearslip.browser.adblock
|
||||
|
||||
import android.util.LruCache
|
||||
|
||||
/**
|
||||
* An immutable, queryable index over a set of parsed filter rules.
|
||||
*
|
||||
* Network rules are bucketed so that a request only ever tests a handful of them:
|
||||
* - `||host^` rules are indexed by host and found by walking the request host's suffixes;
|
||||
* - every other rule is indexed under one literal token from its pattern, and a request only
|
||||
* tests rules whose token appears in the URL;
|
||||
* - the few rules with no usable token are tested on every request.
|
||||
*
|
||||
* Element-hiding rules are split the same way. Nearly all generic hiding rules are anchored on
|
||||
* a single class or id (`.ad-banner`, `#sponsored`), so they are indexed by that name and only
|
||||
* handed to a page once that class or id actually appears in its DOM. Injecting all of them
|
||||
* unconditionally would mean roughly 400 KB of CSS on every page load.
|
||||
*/
|
||||
internal class FilterEngine private constructor(
|
||||
private val blockRules: RuleSet,
|
||||
private val allowRules: RuleSet,
|
||||
private val genericComplexSelectors: List<String>,
|
||||
private val genericByClass: HashMap<String, MutableList<String>>,
|
||||
private val genericById: HashMap<String, MutableList<String>>,
|
||||
private val specificSelectors: HashMap<String, MutableList<String>>,
|
||||
private val cosmeticExceptions: HashMap<String, MutableList<String>>,
|
||||
private val genericHasText: List<Pair<String, String>>,
|
||||
private val specificHasText: HashMap<String, MutableList<Pair<String, String>>>,
|
||||
val networkRuleCount: Int,
|
||||
val cosmeticRuleCount: Int
|
||||
) {
|
||||
|
||||
private val hostCssCache = LruCache<String, String>(32)
|
||||
|
||||
/** True when the request should be blocked at all, regardless of which rule matched. */
|
||||
fun shouldBlock(
|
||||
url: String,
|
||||
requestHost: String?,
|
||||
documentHost: String?,
|
||||
type: Int,
|
||||
isThirdParty: Boolean
|
||||
): Boolean = matchBlockingRule(url, requestHost, documentHost, type, isThirdParty) != null
|
||||
|
||||
/**
|
||||
* The block match for this request, or null when it is not blocked. Split out from
|
||||
* [shouldBlock] so the caller can also read the matched rule's `$redirect=` resource name
|
||||
* without a second, separate walk over the same rule sets. A plain `||host^` match (the
|
||||
* common case) carries no [NetworkRule] object at all, hence [BlockMatch] rather than
|
||||
* returning the rule directly.
|
||||
*/
|
||||
fun matchBlockingRule(
|
||||
url: String,
|
||||
requestHost: String?,
|
||||
documentHost: String?,
|
||||
type: Int,
|
||||
isThirdParty: Boolean
|
||||
): BlockMatch? {
|
||||
val lowerUrl = url.lowercase()
|
||||
val hostStart = hostStart(lowerUrl)
|
||||
val hostEnd = hostEnd(lowerUrl, hostStart)
|
||||
val tokens = ArrayList<String>(16)
|
||||
tokenize(lowerUrl, tokens)
|
||||
|
||||
val blockingMatch = matchingRule(blockRules, lowerUrl, hostStart, hostEnd, requestHost, documentHost, type, isThirdParty, tokens)
|
||||
?: return null
|
||||
if (matchesAny(allowRules, lowerUrl, hostStart, hostEnd, requestHost, documentHost, type, isThirdParty, tokens)) {
|
||||
return null
|
||||
}
|
||||
return blockingMatch
|
||||
}
|
||||
|
||||
class BlockMatch(val redirectResource: String?) {
|
||||
companion object {
|
||||
val PLAIN = BlockMatch(null)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The stylesheet injected at document start: rules written for this site, plus the handful
|
||||
* of generic rules that are not anchored on a class or id.
|
||||
*/
|
||||
fun cssForHost(host: String?): String {
|
||||
val normalized = host?.lowercase().orEmpty()
|
||||
hostCssCache.get(normalized)?.let { return it }
|
||||
|
||||
val exceptions = exceptionsFor(normalized)
|
||||
val selectors = ArrayList<String>(genericComplexSelectors.size + 32)
|
||||
if (normalized.isNotEmpty()) {
|
||||
DomainUtils.forEachHostSuffix(normalized) { suffix ->
|
||||
specificSelectors[suffix]?.let { selectors.addAll(it) }
|
||||
}
|
||||
}
|
||||
selectors.addAll(genericComplexSelectors)
|
||||
|
||||
val css = buildHidingCss(selectors, exceptions)
|
||||
hostCssCache.put(normalized, css)
|
||||
return css
|
||||
}
|
||||
|
||||
private val hasTextCache = LruCache<String, List<Pair<String, String>>>(32)
|
||||
|
||||
/**
|
||||
* The `:has-text()` rules (base selector, JS regex source) that apply to [host]. Delivered
|
||||
* separately from [cssForHost] because there is no CSS equivalent for a text match -- the
|
||||
* caller runs these with JavaScript instead of a `<style>` element.
|
||||
*/
|
||||
fun hasTextRulesForHost(host: String?): List<Pair<String, String>> {
|
||||
val normalized = host?.lowercase().orEmpty()
|
||||
hasTextCache.get(normalized)?.let { return it }
|
||||
|
||||
val rules = ArrayList<Pair<String, String>>(genericHasText.size + 4)
|
||||
if (normalized.isNotEmpty()) {
|
||||
DomainUtils.forEachHostSuffix(normalized) { suffix ->
|
||||
specificHasText[suffix]?.let { rules.addAll(it) }
|
||||
}
|
||||
}
|
||||
rules.addAll(genericHasText)
|
||||
|
||||
hasTextCache.put(normalized, rules)
|
||||
return rules
|
||||
}
|
||||
|
||||
/**
|
||||
* The stylesheet for generic rules whose class or id has turned up in the page. [classNames]
|
||||
* and [ids] are whitespace-separated, as collected from the live DOM.
|
||||
*/
|
||||
fun cssForTokens(host: String?, classNames: String, ids: String): String {
|
||||
val normalized = host?.lowercase().orEmpty()
|
||||
val exceptions = exceptionsFor(normalized)
|
||||
val selectors = ArrayList<String>()
|
||||
|
||||
collectByToken(classNames, genericByClass, selectors)
|
||||
collectByToken(ids, genericById, selectors)
|
||||
if (selectors.isEmpty()) return ""
|
||||
|
||||
return buildHidingCss(selectors, exceptions)
|
||||
}
|
||||
|
||||
private fun collectByToken(
|
||||
tokens: String,
|
||||
index: HashMap<String, MutableList<String>>,
|
||||
out: MutableList<String>
|
||||
) {
|
||||
if (tokens.isEmpty() || index.isEmpty()) return
|
||||
var start = 0
|
||||
val length = tokens.length
|
||||
while (start < length) {
|
||||
while (start < length && tokens[start].isWhitespace()) start++
|
||||
if (start >= length) break
|
||||
var end = start
|
||||
while (end < length && !tokens[end].isWhitespace()) end++
|
||||
index[tokens.substring(start, end)]?.let { out.addAll(it) }
|
||||
start = end
|
||||
}
|
||||
}
|
||||
|
||||
private fun exceptionsFor(host: String): Set<String> {
|
||||
if (host.isEmpty() || cosmeticExceptions.isEmpty()) return emptySet()
|
||||
var result: MutableSet<String>? = null
|
||||
DomainUtils.forEachHostSuffix(host) { suffix ->
|
||||
cosmeticExceptions[suffix]?.let { exceptions ->
|
||||
val target = result ?: HashSet<String>().also { result = it }
|
||||
target.addAll(exceptions)
|
||||
}
|
||||
}
|
||||
return result ?: emptySet()
|
||||
}
|
||||
|
||||
private fun buildHidingCss(selectors: List<String>, exceptions: Set<String>): String {
|
||||
val usable = if (exceptions.isEmpty()) {
|
||||
selectors
|
||||
} else {
|
||||
selectors.filterNot { it in exceptions }
|
||||
}
|
||||
if (usable.isEmpty()) return ""
|
||||
|
||||
val builder = StringBuilder(usable.size * 24)
|
||||
// One rule per chunk rather than one rule for everything: a browser drops an entire
|
||||
// rule if any selector in it fails to parse, so a chunk limits that blast radius.
|
||||
for (chunk in usable.chunked(SELECTORS_PER_RULE)) {
|
||||
builder.append(chunk.joinToString(","))
|
||||
builder.append("{display:none!important}")
|
||||
}
|
||||
return builder.toString()
|
||||
}
|
||||
|
||||
private fun matchesAny(
|
||||
ruleSet: RuleSet,
|
||||
url: String,
|
||||
hostStart: Int,
|
||||
hostEnd: Int,
|
||||
requestHost: String?,
|
||||
documentHost: String?,
|
||||
type: Int,
|
||||
isThirdParty: Boolean,
|
||||
tokens: List<String>
|
||||
): Boolean {
|
||||
if (!requestHost.isNullOrEmpty()) {
|
||||
var found = false
|
||||
DomainUtils.forEachHostSuffix(requestHost) { suffix ->
|
||||
if (!found) {
|
||||
if (suffix in ruleSet.plainHosts) {
|
||||
found = true
|
||||
} else {
|
||||
val candidates = ruleSet.hostIndex[suffix]
|
||||
if (candidates != null) {
|
||||
for (rule in candidates) {
|
||||
if (optionsMatch(rule, type, isThirdParty, documentHost)) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (found) return true
|
||||
}
|
||||
|
||||
for (token in tokens) {
|
||||
val candidates = ruleSet.tokenIndex[token] ?: continue
|
||||
for (rule in candidates) {
|
||||
if (optionsMatch(rule, type, isThirdParty, documentHost) &&
|
||||
matchesUrl(rule, url, hostStart, hostEnd)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (rule in ruleSet.catchAll) {
|
||||
if (optionsMatch(rule, type, isThirdParty, documentHost) &&
|
||||
matchesUrl(rule, url, hostStart, hostEnd)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Same walk as [matchesAny], but returns the match itself instead of a plain boolean. */
|
||||
private fun matchingRule(
|
||||
ruleSet: RuleSet,
|
||||
url: String,
|
||||
hostStart: Int,
|
||||
hostEnd: Int,
|
||||
requestHost: String?,
|
||||
documentHost: String?,
|
||||
type: Int,
|
||||
isThirdParty: Boolean,
|
||||
tokens: List<String>
|
||||
): BlockMatch? {
|
||||
if (!requestHost.isNullOrEmpty()) {
|
||||
var result: BlockMatch? = null
|
||||
DomainUtils.forEachHostSuffix(requestHost) { suffix ->
|
||||
if (result == null) {
|
||||
if (suffix in ruleSet.plainHosts) {
|
||||
result = BlockMatch.PLAIN
|
||||
} else {
|
||||
val candidates = ruleSet.hostIndex[suffix]
|
||||
if (candidates != null) {
|
||||
for (rule in candidates) {
|
||||
if (optionsMatch(rule, type, isThirdParty, documentHost)) {
|
||||
result = BlockMatch(rule.redirectResource)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (result != null) return result
|
||||
}
|
||||
|
||||
for (token in tokens) {
|
||||
val candidates = ruleSet.tokenIndex[token] ?: continue
|
||||
for (rule in candidates) {
|
||||
if (optionsMatch(rule, type, isThirdParty, documentHost) &&
|
||||
matchesUrl(rule, url, hostStart, hostEnd)
|
||||
) {
|
||||
return BlockMatch(rule.redirectResource)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (rule in ruleSet.catchAll) {
|
||||
if (optionsMatch(rule, type, isThirdParty, documentHost) &&
|
||||
matchesUrl(rule, url, hostStart, hostEnd)
|
||||
) {
|
||||
return BlockMatch(rule.redirectResource)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun optionsMatch(
|
||||
rule: NetworkRule,
|
||||
type: Int,
|
||||
isThirdParty: Boolean,
|
||||
documentHost: String?
|
||||
): Boolean {
|
||||
if (rule.typeMask != 0 && (rule.typeMask and type) == 0) return false
|
||||
if (rule.excludedTypeMask != 0 && (rule.excludedTypeMask and type) != 0) return false
|
||||
|
||||
val thirdParty = rule.thirdParty
|
||||
if (thirdParty != null && thirdParty != isThirdParty) return false
|
||||
|
||||
val include = rule.includeDomains
|
||||
val exclude = rule.excludeDomains
|
||||
if (include != null || exclude != null) {
|
||||
val host = documentHost ?: return false
|
||||
if (exclude != null && exclude.any { DomainUtils.hostMatchesDomain(host, it) }) return false
|
||||
if (include != null && !include.any { DomainUtils.hostMatchesDomain(host, it) }) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun matchesUrl(rule: NetworkRule, url: String, hostStart: Int, hostEnd: Int): Boolean {
|
||||
if (rule.anchorDomain) {
|
||||
if (matchFrom(url, hostStart, rule)) return true
|
||||
var index = hostStart
|
||||
while (index < hostEnd) {
|
||||
if (url[index] == '.' && matchFrom(url, index + 1, rule)) return true
|
||||
index++
|
||||
}
|
||||
return false
|
||||
}
|
||||
if (rule.anchorStart) return matchFrom(url, 0, rule)
|
||||
|
||||
val first = rule.segments[0]
|
||||
if (first.isNotEmpty() && first.indexOf('^') < 0) {
|
||||
var index = url.indexOf(first)
|
||||
while (index >= 0) {
|
||||
if (matchFrom(url, index, rule)) return true
|
||||
index = url.indexOf(first, index + 1)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var index = 0
|
||||
while (index <= url.length) {
|
||||
if (matchFrom(url, index, rule)) return true
|
||||
index++
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun matchFrom(url: String, start: Int, rule: NetworkRule): Boolean {
|
||||
val segments = rule.segments
|
||||
var position = matchSegmentAt(url, start, segments[0])
|
||||
if (position < 0) return false
|
||||
|
||||
for (i in 1 until segments.size) {
|
||||
val segment = segments[i]
|
||||
if (segment.isEmpty()) continue
|
||||
var probe = position
|
||||
var matchedEnd = -1
|
||||
while (probe <= url.length) {
|
||||
val end = matchSegmentAt(url, probe, segment)
|
||||
if (end >= 0) {
|
||||
matchedEnd = end
|
||||
break
|
||||
}
|
||||
probe++
|
||||
}
|
||||
if (matchedEnd < 0) return false
|
||||
position = matchedEnd
|
||||
}
|
||||
|
||||
if (rule.anchorEnd && segments[segments.size - 1].isNotEmpty() && position != url.length) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** Returns the index just past the match, or -1. `^` matches one separator or end-of-URL. */
|
||||
private fun matchSegmentAt(url: String, start: Int, segment: String): Int {
|
||||
var position = start
|
||||
for (i in segment.indices) {
|
||||
val expected = segment[i]
|
||||
if (expected == '^') {
|
||||
if (position >= url.length) {
|
||||
// A separator may stand for the end of the address, but only as the last
|
||||
// character of the segment.
|
||||
return if (i == segment.length - 1) position else -1
|
||||
}
|
||||
if (!isSeparator(url[position])) return -1
|
||||
position++
|
||||
} else {
|
||||
if (position >= url.length || url[position] != expected) return -1
|
||||
position++
|
||||
}
|
||||
}
|
||||
return position
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val MIN_TOKEN_LENGTH = 3
|
||||
private const val SELECTORS_PER_RULE = 100
|
||||
|
||||
private fun isSeparator(c: Char): Boolean = when {
|
||||
c in 'a'..'z' -> false
|
||||
c in 'A'..'Z' -> false
|
||||
c in '0'..'9' -> false
|
||||
c == '_' || c == '-' || c == '.' || c == '%' -> false
|
||||
else -> true
|
||||
}
|
||||
|
||||
private fun hostStart(url: String): Int {
|
||||
val scheme = url.indexOf("://")
|
||||
return if (scheme < 0) 0 else scheme + 3
|
||||
}
|
||||
|
||||
private fun hostEnd(url: String, hostStart: Int): Int {
|
||||
var index = hostStart
|
||||
while (index < url.length) {
|
||||
val c = url[index]
|
||||
if (c == '/' || c == '?' || c == '#' || c == ':') return index
|
||||
index++
|
||||
}
|
||||
return url.length
|
||||
}
|
||||
|
||||
private fun tokenize(text: String, out: MutableList<String>) {
|
||||
var index = 0
|
||||
val length = text.length
|
||||
while (index < length) {
|
||||
val c = text[index]
|
||||
if (c.isLetterOrDigit()) {
|
||||
var end = index + 1
|
||||
while (end < length && text[end].isLetterOrDigit()) end++
|
||||
if (end - index >= MIN_TOKEN_LENGTH) out.add(text.substring(index, end))
|
||||
index = end
|
||||
} else {
|
||||
index++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The longest literal token in the pattern, used as the rule's index key. */
|
||||
private fun bestToken(rule: NetworkRule): String? {
|
||||
var best: String? = null
|
||||
val tokens = ArrayList<String>(4)
|
||||
for (segment in rule.segments) {
|
||||
tokens.clear()
|
||||
tokenize(segment, tokens)
|
||||
for (token in tokens) {
|
||||
val current = best
|
||||
if (current == null || token.length > current.length) best = token
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
/**
|
||||
* The leading class or id a selector is anchored on, as (`.` or `#`) to name, or null
|
||||
* when the selector does not start with a plain class or id.
|
||||
*/
|
||||
private fun anchorToken(selector: String): Pair<Char, String>? {
|
||||
if (selector.length < 2) return null
|
||||
val kind = selector[0]
|
||||
if (kind != '.' && kind != '#') return null
|
||||
var end = 1
|
||||
while (end < selector.length) {
|
||||
val c = selector[end]
|
||||
if (c.isLetterOrDigit() || c == '-' || c == '_') end++ else break
|
||||
}
|
||||
if (end == 1) return null
|
||||
return kind to selector.substring(1, end)
|
||||
}
|
||||
|
||||
fun build(lines: Sequence<String>): FilterEngine {
|
||||
val builder = Builder()
|
||||
for (line in lines) builder.addLine(line)
|
||||
return builder.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Accumulates rules one line at a time, fed straight from a reader so that the whole list
|
||||
* never exists in memory as a collection of lines. Parsing EasyList and EasyPrivacy
|
||||
* together takes roughly 250ms on a Pixel 6 in a release build. Be wary of profiling this
|
||||
* in a debug build: `android:debuggable` disables ART's inlining and made the same work
|
||||
* take over nine seconds, which looks like an algorithmic problem but is not one.
|
||||
*/
|
||||
internal class Builder {
|
||||
private val blockRules = RuleSet()
|
||||
private val allowRules = RuleSet()
|
||||
private val genericComplex = LinkedHashSet<String>()
|
||||
// Pre-sized: EasyList and EasyPrivacy together fill these to roughly these counts, and
|
||||
// rehashing a 90k-entry map on the way there is pure waste.
|
||||
private val genericByClass = HashMap<String, MutableList<String>>(16384)
|
||||
private val genericById = HashMap<String, MutableList<String>>(8192)
|
||||
private val specificSelectors = HashMap<String, MutableList<String>>(16384)
|
||||
private val cosmeticExceptions = HashMap<String, MutableList<String>>(1024)
|
||||
private val genericHasText = mutableListOf<Pair<String, String>>()
|
||||
private val specificHasText = HashMap<String, MutableList<Pair<String, String>>>()
|
||||
private var networkRuleCount = 0
|
||||
private var cosmeticRuleCount = 0
|
||||
|
||||
fun addLine(rawLine: String) {
|
||||
if (rawLine.isEmpty()) return
|
||||
// Only pay for trim() on the rare line that actually has surrounding whitespace.
|
||||
val needsTrim = rawLine[0] == ' ' || rawLine[0] == '\t' ||
|
||||
rawLine[rawLine.length - 1] == ' ' || rawLine[rawLine.length - 1] == '\t'
|
||||
val line = if (needsTrim) rawLine.trim() else rawLine
|
||||
if (line.isEmpty()) return
|
||||
if (line[0] == '!' || line[0] == '[') return
|
||||
|
||||
val separator = FilterParser.cosmeticSeparatorIndex(line)
|
||||
if (separator >= 0) {
|
||||
addCosmeticRule(line, separator)
|
||||
return
|
||||
}
|
||||
|
||||
val isException = line.startsWith("@@")
|
||||
val rule = FilterParser.parseNetworkRule(line) ?: return
|
||||
networkRuleCount++
|
||||
val target = if (isException) allowRules else blockRules
|
||||
|
||||
val hostAnchor = rule.hostAnchor
|
||||
if (hostAnchor != null) {
|
||||
if (rule.hasNoOptions) {
|
||||
target.plainHosts.add(hostAnchor)
|
||||
} else {
|
||||
target.hostIndex.getOrPut(hostAnchor) { mutableListOf() }.add(rule)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val token = bestToken(rule)
|
||||
if (token != null) {
|
||||
target.tokenIndex.getOrPut(token) { mutableListOf() }.add(rule)
|
||||
} else {
|
||||
target.catchAll.add(rule)
|
||||
}
|
||||
}
|
||||
|
||||
private fun addCosmeticRule(line: String, separator: Int) {
|
||||
val rule = FilterParser.parseCosmeticRule(line, separator) ?: return
|
||||
cosmeticRuleCount++
|
||||
|
||||
// :has-text() has no CSS equivalent, so it is matched by JavaScript from its own
|
||||
// list instead of joining the CSS selector strings above. Exceptions for it are not
|
||||
// supported -- rare enough in EasyList/EasyPrivacy that dropping them outright (by
|
||||
// not indexing this rule) is a safe simplification rather than a real gap.
|
||||
val textPattern = rule.hasTextPattern
|
||||
if (textPattern != null) {
|
||||
if (rule.isException) return
|
||||
val entry = rule.selector to textPattern
|
||||
if (rule.includeDomains != null) {
|
||||
rule.includeDomains.forEach { domain ->
|
||||
specificHasText.getOrPut(domain) { mutableListOf() }.add(entry)
|
||||
}
|
||||
} else {
|
||||
genericHasText.add(entry)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
when {
|
||||
rule.isException -> {
|
||||
rule.includeDomains?.forEach { domain ->
|
||||
cosmeticExceptions.getOrPut(domain) { mutableListOf() }.add(rule.selector)
|
||||
}
|
||||
}
|
||||
rule.includeDomains != null -> {
|
||||
rule.includeDomains.forEach { domain ->
|
||||
specificSelectors.getOrPut(domain) { mutableListOf() }.add(rule.selector)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
val anchor = anchorToken(rule.selector)
|
||||
if (anchor == null) {
|
||||
genericComplex.add(rule.selector)
|
||||
} else {
|
||||
val index = if (anchor.first == '.') genericByClass else genericById
|
||||
index.getOrPut(anchor.second) { mutableListOf() }.add(rule.selector)
|
||||
}
|
||||
// `~example.com##.ad` hides everywhere except the excluded domains.
|
||||
rule.excludeDomains?.forEach { domain ->
|
||||
cosmeticExceptions.getOrPut(domain) { mutableListOf() }.add(rule.selector)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun finish(): FilterEngine = FilterEngine(
|
||||
blockRules = blockRules,
|
||||
allowRules = allowRules,
|
||||
genericComplexSelectors = genericComplex.toList(),
|
||||
genericByClass = genericByClass,
|
||||
genericById = genericById,
|
||||
specificSelectors = specificSelectors,
|
||||
cosmeticExceptions = cosmeticExceptions,
|
||||
genericHasText = genericHasText,
|
||||
specificHasText = specificHasText,
|
||||
networkRuleCount = networkRuleCount,
|
||||
cosmeticRuleCount = cosmeticRuleCount
|
||||
)
|
||||
}
|
||||
|
||||
internal class RuleSet {
|
||||
/** Bare `||host^` blocks with no options: matching the host is the whole test. */
|
||||
val plainHosts = HashSet<String>()
|
||||
val hostIndex = HashMap<String, MutableList<NetworkRule>>()
|
||||
val tokenIndex = HashMap<String, MutableList<NetworkRule>>()
|
||||
val catchAll = ArrayList<NetworkRule>()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
package app.seb3thehacker.gearslip.browser.adblock
|
||||
|
||||
/**
|
||||
* A parsed network filter rule (Adblock Plus syntax subset).
|
||||
*
|
||||
* [segments] is the pattern split on `*`; each segment is matched literally except for `^`,
|
||||
* which stands for a single separator character (or the end of the URL).
|
||||
*/
|
||||
internal class NetworkRule(
|
||||
val segments: Array<String>,
|
||||
val anchorDomain: Boolean,
|
||||
val anchorStart: Boolean,
|
||||
val anchorEnd: Boolean,
|
||||
val typeMask: Int,
|
||||
val excludedTypeMask: Int,
|
||||
val thirdParty: Boolean?,
|
||||
val includeDomains: Array<String>?,
|
||||
val excludeDomains: Array<String>?,
|
||||
/** Set when the whole pattern is a bare `||host^`, which lets the engine index it by host. */
|
||||
val hostAnchor: String?,
|
||||
/** Set for `$redirect=name` / `$redirect-rule=name` rules with a resource we can serve. */
|
||||
val redirectResource: String?
|
||||
) {
|
||||
/**
|
||||
* True when the rule carries no options, so matching the host is the whole test. The great
|
||||
* majority of list entries are of this shape and the engine stores them as bare strings.
|
||||
*/
|
||||
val hasNoOptions: Boolean
|
||||
get() = typeMask == 0 && excludedTypeMask == 0 && thirdParty == null &&
|
||||
includeDomains == null && excludeDomains == null && redirectResource == null
|
||||
}
|
||||
|
||||
internal class CosmeticRule(
|
||||
val selector: String,
|
||||
val includeDomains: List<String>?,
|
||||
val excludeDomains: List<String>?,
|
||||
val isException: Boolean,
|
||||
/** Set for a `selector:has-text(pattern)` rule: the text [pattern] to match within
|
||||
* [selector]'s matches, applied by JavaScript rather than as plain CSS. */
|
||||
val hasTextPattern: String? = null
|
||||
)
|
||||
|
||||
internal object FilterParser {
|
||||
|
||||
/**
|
||||
* Options that change *how* a request is answered rather than whether it is blocked.
|
||||
* A rule carrying one of these is dropped: honouring only its matching half would block
|
||||
* requests the list author meant to rewrite or merely restrict.
|
||||
*/
|
||||
private val UNSUPPORTED_OPTIONS = hashSetOf(
|
||||
"csp", "removeparam", "rewrite", "replace", "empty", "mp4",
|
||||
"inline-script", "inline-font", "genericblock", "generichide", "specifichide", "elemhide",
|
||||
"badfilter", "denyallow", "method", "header", "to", "from", "ipaddress", "permissions",
|
||||
"uritransform", "urlskip", "urltransform", "strict1p", "strict3p", "popup", "popunder",
|
||||
"cname", "webrtc", "stealth", "cookie", "network", "app", "extension", "jsonprune",
|
||||
"hls", "referrerpolicy", "match-case-path"
|
||||
)
|
||||
|
||||
/**
|
||||
* Procedural cosmetic syntax that needs uBlock Origin's own runtime to evaluate. These
|
||||
* selectors cannot be expressed as plain CSS, so the rules are skipped.
|
||||
*
|
||||
* `:has()` is not in this list: modern WebView's Chromium supports it natively as plain CSS,
|
||||
* so those selectors are just passed straight through. `:has-text()` is handled separately in
|
||||
* [parseCosmeticRule] rather than being skipped outright -- the simple, common form (a plain
|
||||
* selector followed by one `:has-text(...)`) is split out and matched by JavaScript instead of
|
||||
* CSS, since there is no CSS equivalent for a text match.
|
||||
*/
|
||||
private val PROCEDURAL_MARKERS = arrayOf(
|
||||
// ":has-text(" stays here as a safety net: it's stripped out before this check runs
|
||||
// when it's the simple trailing form [extractHasText] understands, so a leftover
|
||||
// occurrence here means that extraction either wasn't attempted (not a cosmetic rule
|
||||
// that reached it) or failed (nested/combined usage), and the rule should be dropped
|
||||
// rather than shipped as literal, invalid CSS.
|
||||
":has-text(",
|
||||
":matches-css", ":matches-media", ":matches-path", ":matches-attr",
|
||||
":xpath(", ":upward(", ":nth-ancestor(", ":style(", ":remove(", ":remove-attr(",
|
||||
":remove-class(", ":watch-attr(", ":min-text-length(", ":others(", ":shadow(", ":if(",
|
||||
":if-not(", ":not(:has"
|
||||
)
|
||||
|
||||
private fun isHostCharacter(c: Char): Boolean =
|
||||
c in 'a'..'z' || c in '0'..'9' || c == '.' || c == '-' || c == '_'
|
||||
|
||||
fun parseNetworkRule(line: String): NetworkRule? {
|
||||
var body = line
|
||||
if (body.startsWith("@@")) body = body.substring(2)
|
||||
if (body.isEmpty()) return null
|
||||
|
||||
var pattern = body
|
||||
var optionText: String? = null
|
||||
val dollar = body.lastIndexOf('$')
|
||||
if (dollar >= 0) {
|
||||
pattern = body.substring(0, dollar)
|
||||
optionText = body.substring(dollar + 1)
|
||||
}
|
||||
|
||||
// Regular-expression rules (/pattern/) are not supported.
|
||||
if (pattern.length > 2 && pattern.startsWith("/") && pattern.endsWith("/")) return null
|
||||
if (pattern.isEmpty()) return null
|
||||
|
||||
var anchorDomain = false
|
||||
var anchorStart = false
|
||||
var anchorEnd = false
|
||||
if (pattern.startsWith("||")) {
|
||||
anchorDomain = true
|
||||
pattern = pattern.substring(2)
|
||||
} else if (pattern.startsWith("|")) {
|
||||
anchorStart = true
|
||||
pattern = pattern.substring(1)
|
||||
}
|
||||
if (pattern.endsWith("|")) {
|
||||
anchorEnd = true
|
||||
pattern = pattern.dropLast(1)
|
||||
}
|
||||
if (pattern.isEmpty()) return null
|
||||
pattern = pattern.lowercase()
|
||||
|
||||
var typeMask = 0
|
||||
var excludedTypeMask = 0
|
||||
var thirdParty: Boolean? = null
|
||||
var includeDomains: MutableList<String>? = null
|
||||
var excludeDomains: MutableList<String>? = null
|
||||
var redirectResource: String? = null
|
||||
|
||||
if (optionText != null) {
|
||||
for (rawOption in optionText.split(',')) {
|
||||
var option = rawOption.trim().lowercase()
|
||||
if (option.isEmpty()) continue
|
||||
|
||||
var negated = false
|
||||
if (option.startsWith("~")) {
|
||||
negated = true
|
||||
option = option.substring(1)
|
||||
}
|
||||
|
||||
val equals = option.indexOf('=')
|
||||
val key = if (equals >= 0) option.substring(0, equals) else option
|
||||
val value = if (equals >= 0) option.substring(equals + 1) else null
|
||||
|
||||
if (key.isEmpty()) return null
|
||||
if (key in UNSUPPORTED_OPTIONS) return null
|
||||
|
||||
when (key) {
|
||||
"domain" -> {
|
||||
if (value.isNullOrEmpty()) return null
|
||||
for (entry in value.split('|')) {
|
||||
val domain = entry.trim()
|
||||
if (domain.isEmpty()) continue
|
||||
if (domain.startsWith("~")) {
|
||||
val excluded = domain.substring(1)
|
||||
if (excluded.isNotEmpty()) {
|
||||
(excludeDomains ?: mutableListOf<String>()
|
||||
.also { excludeDomains = it }).add(excluded)
|
||||
}
|
||||
} else {
|
||||
(includeDomains ?: mutableListOf<String>()
|
||||
.also { includeDomains = it }).add(domain)
|
||||
}
|
||||
}
|
||||
}
|
||||
"third-party", "3p" -> thirdParty = !negated
|
||||
"first-party", "1p" -> thirdParty = negated
|
||||
// Patterns are lower-cased for matching, so $match-case degrades to
|
||||
// case-insensitive. It only ever widens the match.
|
||||
"match-case", "important" -> Unit
|
||||
"all" -> Unit
|
||||
// `redirect-rule` only fires when another rule would already block the
|
||||
// request; treating it the same as `redirect` is a harmless simplification
|
||||
// since we only reach here once a block decision is otherwise made.
|
||||
"redirect", "redirect-rule" -> {
|
||||
// AdGuard's priority suffix (`name:10`) doesn't apply to a single-engine
|
||||
// blocker with no other redirect rule to rank against.
|
||||
val name = value?.substringBefore(':')
|
||||
if (name.isNullOrEmpty()) return null
|
||||
redirectResource = name
|
||||
}
|
||||
else -> {
|
||||
val bit = ResourceType.fromOptionName(key)
|
||||
if (bit == ResourceType.NONE) return null
|
||||
if (negated) excludedTypeMask = excludedTypeMask or bit
|
||||
else typeMask = typeMask or bit
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val segments = splitOnWildcards(pattern)
|
||||
if (segments.isEmpty()) return null
|
||||
|
||||
var hostAnchor: String? = null
|
||||
if (anchorDomain && !anchorEnd && segments.size == 1) {
|
||||
val candidate = if (pattern.endsWith("^")) pattern.dropLast(1) else pattern
|
||||
if (candidate.isNotEmpty() && candidate.all { isHostCharacter(it) }) {
|
||||
hostAnchor = candidate
|
||||
}
|
||||
}
|
||||
|
||||
return NetworkRule(
|
||||
segments = segments,
|
||||
anchorDomain = anchorDomain,
|
||||
anchorStart = anchorStart,
|
||||
anchorEnd = anchorEnd,
|
||||
typeMask = typeMask,
|
||||
excludedTypeMask = excludedTypeMask,
|
||||
thirdParty = thirdParty,
|
||||
includeDomains = includeDomains?.toTypedArray(),
|
||||
excludeDomains = excludeDomains?.toTypedArray(),
|
||||
hostAnchor = hostAnchor,
|
||||
redirectResource = redirectResource
|
||||
)
|
||||
}
|
||||
|
||||
private fun splitOnWildcards(pattern: String): Array<String> {
|
||||
if (pattern.indexOf('*') < 0) return arrayOf(pattern)
|
||||
return pattern.split('*').toTypedArray()
|
||||
}
|
||||
|
||||
/** Returns the index of the cosmetic separator, or -1 when the line is not a cosmetic rule. */
|
||||
fun cosmeticSeparatorIndex(line: String): Int {
|
||||
var search = 0
|
||||
while (true) {
|
||||
val hash = line.indexOf('#', search)
|
||||
if (hash < 0) return -1
|
||||
if (hash + 1 < line.length) {
|
||||
val next = line[hash + 1]
|
||||
if (next == '#') return hash
|
||||
if (next == '@' && hash + 2 < line.length && line[hash + 2] == '#') return hash
|
||||
// #?#, #$#, #$?#, #%# and friends are procedural or scriptlet syntax.
|
||||
if (next == '?' || next == '$' || next == '%') return hash
|
||||
}
|
||||
search = hash + 1
|
||||
}
|
||||
}
|
||||
|
||||
fun parseCosmeticRule(line: String, separatorIndex: Int): CosmeticRule? {
|
||||
val isException: Boolean
|
||||
val selectorStart: Int
|
||||
when {
|
||||
line.startsWith("#@#", separatorIndex) -> {
|
||||
isException = true
|
||||
selectorStart = separatorIndex + 3
|
||||
}
|
||||
line.startsWith("##", separatorIndex) -> {
|
||||
isException = false
|
||||
selectorStart = separatorIndex + 2
|
||||
}
|
||||
// Procedural / scriptlet separators cannot be represented as CSS.
|
||||
else -> return null
|
||||
}
|
||||
|
||||
if (selectorStart >= line.length) return null
|
||||
var selector = line.substring(selectorStart).trim()
|
||||
if (selector.isEmpty()) return null
|
||||
if (selector.startsWith("+js(") || selector.startsWith("script:")) return null
|
||||
|
||||
var hasTextPattern: String? = null
|
||||
val hasTextSplit = extractHasText(selector)
|
||||
if (hasTextSplit != null) {
|
||||
selector = hasTextSplit.first
|
||||
hasTextPattern = hasTextSplit.second
|
||||
}
|
||||
|
||||
val lowerSelector = selector.lowercase()
|
||||
if (PROCEDURAL_MARKERS.any { lowerSelector.contains(it) }) return null
|
||||
// A stray brace would let a selector escape its own rule when concatenated.
|
||||
if (selector.contains('{') || selector.contains('}')) return null
|
||||
|
||||
var includeDomains: MutableList<String>? = null
|
||||
var excludeDomains: MutableList<String>? = null
|
||||
val domainText = line.substring(0, separatorIndex).trim()
|
||||
if (domainText.isNotEmpty()) {
|
||||
for (entry in domainText.split(',')) {
|
||||
val domain = entry.trim().lowercase()
|
||||
if (domain.isEmpty()) continue
|
||||
if (domain.startsWith("~")) {
|
||||
val excluded = domain.substring(1)
|
||||
if (excluded.isNotEmpty()) {
|
||||
(excludeDomains ?: mutableListOf<String>().also { excludeDomains = it })
|
||||
.add(excluded)
|
||||
}
|
||||
} else {
|
||||
(includeDomains ?: mutableListOf<String>().also { includeDomains = it })
|
||||
.add(domain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return CosmeticRule(selector, includeDomains, excludeDomains, isException, hasTextPattern)
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits `selector:has-text(pattern)` into (`selector`, JS regex source). Only the simple,
|
||||
* common form is handled -- one `:has-text(...)` as the very last thing in the selector, with
|
||||
* a non-empty selector before it. Anything else (nested inside `:has()`, combined with other
|
||||
* procedural syntax, an empty base selector matching every element) returns null and the
|
||||
* caller falls back to discarding the rule, same as before this existed.
|
||||
*/
|
||||
private fun extractHasText(selector: String): Pair<String, String>? {
|
||||
val markerIndex = selector.indexOf(":has-text(")
|
||||
if (markerIndex <= 0) return null
|
||||
|
||||
val argStart = markerIndex + ":has-text(".length
|
||||
var depth = 1
|
||||
var index = argStart
|
||||
while (index < selector.length && depth > 0) {
|
||||
when (selector[index]) {
|
||||
'(' -> depth++
|
||||
')' -> depth--
|
||||
}
|
||||
if (depth > 0) index++
|
||||
}
|
||||
// The closing paren must be the last character: a trailing combinator or pseudo after
|
||||
// it means this is a more complex selector than the simple form handled here.
|
||||
if (depth != 0 || index != selector.length - 1) return null
|
||||
|
||||
val base = selector.substring(0, markerIndex).trim()
|
||||
if (base.isEmpty() || base.contains(":has-text(")) return null
|
||||
|
||||
var arg = selector.substring(argStart, index).trim()
|
||||
if (arg.isEmpty()) return null
|
||||
|
||||
val pattern = if (arg.length > 1 && arg.startsWith("/") && arg.lastIndexOf('/') > 0) {
|
||||
arg.substring(1, arg.lastIndexOf('/'))
|
||||
} else {
|
||||
Regex.escape(arg)
|
||||
}
|
||||
return base to pattern
|
||||
}
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
package app.seb3thehacker.gearslip.browser.adblock
|
||||
|
||||
import android.webkit.WebResourceResponse
|
||||
import java.io.ByteArrayInputStream
|
||||
|
||||
/**
|
||||
* A small subset of uBlock Origin's / AdGuard's named `$redirect=` resources.
|
||||
*
|
||||
* Blocking most tracking scripts outright is safe -- the page never had a live reference to
|
||||
* them, so nothing calls into the empty response [AdBlockManager.blockedResponse] already
|
||||
* serves. But a handful of scripts are different: page code assumes the object they define
|
||||
* (`ga`, `dataLayer`, `googletag`, ...) exists and calls it immediately, so an empty response
|
||||
* leaves that call throwing a `ReferenceError` that can break unrelated script on the same page.
|
||||
* These stubs define just enough of that surface to no-op safely, which is what the named
|
||||
* `$redirect=` resource is for in the filter list that requested it.
|
||||
*
|
||||
* Anything not in this map still gets blocked -- just with the generic typed-empty response
|
||||
* instead of an exact stub, same as before this existed.
|
||||
*/
|
||||
internal object RedirectResources {
|
||||
|
||||
private val EMPTY_JS = "".toByteArray()
|
||||
|
||||
private val GOOGLE_ANALYTICS_GA = """
|
||||
(function(){function g(){}var t=function(){t.c[t.q.push(arguments)-1]=null};
|
||||
t.q=[];t.c=[];window.ga=window.ga||t;window._gaq=window._gaq||{push:g};
|
||||
window.GoogleAnalyticsObject=window.GoogleAnalyticsObject||'ga';})();
|
||||
""".trimIndent().toByteArray()
|
||||
|
||||
private val GOOGLE_ANALYTICS_ANALYTICS = """
|
||||
(function(){function s(){}var g={
|
||||
create:function(){return{get:s,set:s,send:s}},getAll:function(){return[]},
|
||||
getByName:function(){return null},remove:s};
|
||||
window.ga=window.ga||function(){(window.ga.q=window.ga.q||[]).push(arguments)};
|
||||
window.ga.create=g.create;window.ga.getAll=g.getAll;window.ga.getByName=g.getByName;
|
||||
window.ga.remove=g.remove;window.gaplugins={Linker:function(){this.decorate=s;this.autoLink=s}};
|
||||
})();
|
||||
""".trimIndent().toByteArray()
|
||||
|
||||
private val GOOGLETAGMANAGER_GTM = """
|
||||
(function(){window.dataLayer=window.dataLayer||[];
|
||||
window.dataLayer.push=function(){};
|
||||
window.google_tag_manager=window.google_tag_manager||{};})();
|
||||
""".trimIndent().toByteArray()
|
||||
|
||||
private val GOOGLETAGSERVICES_GPT = """
|
||||
(function(){function s(){}function u(){return{addService:function(){return this},
|
||||
setTargeting:s,setCollapseEmptyDiv:s,addEventListener:function(){return this},
|
||||
removeEventListener:s,get:s,set:s,getSlotElementId:function(){return''},
|
||||
getSlotId:function(){return{getDomId:function(){return''}}}}}
|
||||
window.googletag=window.googletag||{};
|
||||
window.googletag.cmd=window.googletag.cmd||{push:function(fn){try{fn()}catch(e){}}};
|
||||
var pubads=u();
|
||||
window.googletag.pubads=function(){return pubads};
|
||||
window.googletag.defineSlot=function(){return u()};
|
||||
window.googletag.defineOutOfPageSlot=function(){return u()};
|
||||
window.googletag.enableServices=s;window.googletag.display=s;
|
||||
window.googletag.apiReady=true;})();
|
||||
""".trimIndent().toByteArray()
|
||||
|
||||
private val NOOP_CSS = "".toByteArray()
|
||||
private val NOOP_HTML = "<!doctype html><html><head></head><body></body></html>".toByteArray()
|
||||
private val NOOP_JSON = "{}".toByteArray()
|
||||
|
||||
private val STUBS: Map<String, Pair<String, ByteArray>> = buildMap {
|
||||
// Names as they appear across uBO's and AdGuard's resources.txt / redirect syntax.
|
||||
for (name in listOf("noopjs", "noop.js", "noop-js")) put(name, "application/javascript" to EMPTY_JS)
|
||||
for (name in listOf("noopcss", "noop.css", "noop-css")) put(name, "text/css" to NOOP_CSS)
|
||||
for (name in listOf("noop.txt", "nooptext")) put(name, "text/plain" to "".toByteArray())
|
||||
for (name in listOf("noop.json", "empty")) put(name, "application/json" to NOOP_JSON)
|
||||
for (name in listOf("noop.html", "click2load.html")) put(name, "text/html" to NOOP_HTML)
|
||||
for (name in listOf("google-analytics_ga.js", "google-analytics.com/ga.js", "ga.js"))
|
||||
put(name, "application/javascript" to GOOGLE_ANALYTICS_GA)
|
||||
for (name in listOf(
|
||||
"google-analytics_analytics.js", "google-analytics.com/analytics.js",
|
||||
"googletagmanager_gtag.js", "googletagmanager-gtag"
|
||||
)) put(name, "application/javascript" to GOOGLE_ANALYTICS_ANALYTICS)
|
||||
for (name in listOf("googletagmanager_gtm.js", "googletagmanager.com/gtm.js", "gtm.js"))
|
||||
put(name, "application/javascript" to GOOGLETAGMANAGER_GTM)
|
||||
for (name in listOf("googletagservices_gpt.js", "googletagservices.com/gpt.js", "gpt.js"))
|
||||
put(name, "application/javascript" to GOOGLETAGSERVICES_GPT)
|
||||
for (name in listOf("1x1.gif", "1x1-transparent.gif", "2x2.png", "3x2.png", "32x32.png"))
|
||||
put(name, "image/gif" to AdBlockManager.transparentGifBytes)
|
||||
}
|
||||
|
||||
/** Null when [name] is not one of the resources we ship a stub for. */
|
||||
fun response(name: String, headers: Map<String, String>): WebResourceResponse? {
|
||||
val stub = STUBS[name.lowercase()] ?: return null
|
||||
val (mimeType, body) = stub
|
||||
val encoding = if (mimeType.startsWith("image/")) null else "utf-8"
|
||||
return WebResourceResponse(mimeType, encoding, 200, "OK", headers, ByteArrayInputStream(body))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
package app.seb3thehacker.gearslip.browser.adblock
|
||||
|
||||
/**
|
||||
* YouTube ad blocking, plus the cosmetic cleanup that rides along with it.
|
||||
*
|
||||
* Filter lists cannot touch YouTube's video ads. The ad and the video are delivered by the same
|
||||
* hosts, so there is no request to block and no element to hide: the player decides to show an
|
||||
* ad because its own API response told it to. What can be done is what uBlock Origin's
|
||||
* scriptlets do — edit that response before the player reads it.
|
||||
*
|
||||
* Three hooks, all installed before any page script runs:
|
||||
* - `ytInitialPlayerResponse`, the JSON embedded in the page for the first video watched;
|
||||
* - `fetch`, used by the single-page app for every later video;
|
||||
* - `JSON.parse`, a backstop for whatever path the two above miss.
|
||||
*
|
||||
* Each strips the keys the player uses to schedule ads. A periodic sweep then clicks any skip
|
||||
* button and seeks past anything that still slipped through, which also covers ads stitched
|
||||
* into the stream server-side, where nothing client-side can remove them cleanly. All of this,
|
||||
* plus hiding the ad's own banner/in-feed markup, is gated on "Block YouTube video ads": it's
|
||||
* inherently maintenance-prone (YouTube changes these field and class names when it wants to,
|
||||
* and may respond with an anti-adblock notice), so it needs to be switchable off as a unit
|
||||
* without losing ordinary filter-list blocking.
|
||||
*
|
||||
* The Shorts shelf and filter chip row are a separate concern -- cosmetic preference, not ad
|
||||
* blocking -- so they sit behind their own "Custom site tweaks" setting instead, independent of
|
||||
* whether YouTube ad blocking is on. The floating menu button covering a page's own fixed
|
||||
* bottom bar is handled generically for every site in [CosmeticFilterBridge], not here.
|
||||
*/
|
||||
object YouTubeAdBlock {
|
||||
|
||||
val INJECTION_JS: String = """
|
||||
(function () {
|
||||
try {
|
||||
var host = location.hostname || '';
|
||||
if (!/(^|\.)(youtube\.com|youtube-nocookie\.com|youtubekids\.com)${'$'}/.test(host)) {
|
||||
return;
|
||||
}
|
||||
var bridge = window.${CosmeticFilterBridge.BRIDGE_OBJECT_NAME};
|
||||
if (!bridge) { return; }
|
||||
var adBlockingOn = typeof bridge.youtubeBlockingEnabled === 'function' &&
|
||||
bridge.youtubeBlockingEnabled(host);
|
||||
var tweaksOn = typeof bridge.customSiteTweaksEnabled === 'function' &&
|
||||
bridge.customSiteTweaksEnabled();
|
||||
if (!adBlockingOn && !tweaksOn) { return; }
|
||||
if (window.__gearslipYouTube) { return; }
|
||||
window.__gearslipYouTube = true;
|
||||
|
||||
var styleRules = '';
|
||||
|
||||
if (adBlockingOn) {
|
||||
// The fields the player consults to schedule ad breaks.
|
||||
var AD_KEYS = [
|
||||
'adPlacements', 'playerAds', 'adSlots', 'adBreakHeartbeatParams'
|
||||
];
|
||||
|
||||
var stripKeys = function (object) {
|
||||
if (!object || typeof object !== 'object') { return; }
|
||||
for (var i = 0; i < AD_KEYS.length; i++) {
|
||||
if (AD_KEYS[i] in object) {
|
||||
try { delete object[AD_KEYS[i]]; } catch (e) {}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Deliberately shallow. A player response is a large object and walking all of
|
||||
// it on every parse would cost more than the ads do.
|
||||
var prune = function (payload) {
|
||||
if (!payload || typeof payload !== 'object') { return payload; }
|
||||
if (Array.isArray(payload)) {
|
||||
for (var i = 0; i < payload.length && i < 50; i++) { prune(payload[i]); }
|
||||
return payload;
|
||||
}
|
||||
stripKeys(payload);
|
||||
if (payload.playerResponse) { stripKeys(payload.playerResponse); }
|
||||
if (payload.response) { stripKeys(payload.response); }
|
||||
if (payload.playerData) { stripKeys(payload.playerData); }
|
||||
return payload;
|
||||
};
|
||||
|
||||
// 1. The first video: YouTube assigns this from an inline script in the document.
|
||||
try {
|
||||
var initial;
|
||||
Object.defineProperty(window, 'ytInitialPlayerResponse', {
|
||||
configurable: true,
|
||||
get: function () { return initial; },
|
||||
set: function (value) { initial = prune(value); }
|
||||
});
|
||||
} catch (e) {}
|
||||
|
||||
// 2. Every later video in the single-page app.
|
||||
var originalFetch = window.fetch;
|
||||
if (typeof originalFetch === 'function') {
|
||||
window.fetch = function (input, init) {
|
||||
var url = '';
|
||||
try {
|
||||
url = (typeof input === 'string') ? input : ((input && input.url) || '');
|
||||
} catch (e) {}
|
||||
var pending = originalFetch.apply(this, arguments);
|
||||
// Only the player call carries ad scheduling. /next is the comments and
|
||||
// related-video payload: large, and re-serialising it buys nothing.
|
||||
if (url.indexOf('/youtubei/v1/player') < 0) {
|
||||
return pending;
|
||||
}
|
||||
return pending.then(function (response) {
|
||||
return response.text().then(function (text) {
|
||||
var body = text;
|
||||
// Cheap string test first: most responses have nothing to strip, and
|
||||
// parsing plus re-serialising a player response is not free.
|
||||
if (text.indexOf('"adPlacements"') >= 0 ||
|
||||
text.indexOf('"playerAds"') >= 0 ||
|
||||
text.indexOf('"adSlots"') >= 0) {
|
||||
try {
|
||||
var data = JSON.parse(text);
|
||||
prune(data);
|
||||
body = JSON.stringify(data);
|
||||
} catch (e) {
|
||||
body = text;
|
||||
}
|
||||
}
|
||||
// Content-Length describes the original bytes, and Content-Encoding a
|
||||
// compression this body no longer has. Copying either onto the edited
|
||||
// body leaves the player waiting on bytes that never arrive.
|
||||
var headers;
|
||||
try {
|
||||
headers = new Headers(response.headers);
|
||||
headers.delete('content-length');
|
||||
headers.delete('content-encoding');
|
||||
} catch (e) {
|
||||
headers = undefined;
|
||||
}
|
||||
return new Response(body, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: headers
|
||||
});
|
||||
}).catch(function () { return response; });
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
// 3. Backstop for any path the first two miss.
|
||||
var originalParse = JSON.parse;
|
||||
JSON.parse = function (text, reviver) {
|
||||
var result = originalParse.apply(this, arguments);
|
||||
try { prune(result); } catch (e) {}
|
||||
return result;
|
||||
};
|
||||
|
||||
// Hide the banner, overlay and in-feed ads, which are ordinary elements. The
|
||||
// :has() rules collapse the wrapping feed section too, instead of leaving an
|
||||
// empty gap where the ad used to be.
|
||||
styleRules +=
|
||||
'ytd-promoted-sparkles-web-renderer,ytd-promoted-video-renderer,' +
|
||||
'ytd-display-ad-renderer,ytd-ad-slot-renderer,ytd-in-feed-ad-layout-renderer,' +
|
||||
'ytd-banner-promo-renderer,ytd-statement-banner-renderer,' +
|
||||
'ytd-companion-slot-renderer,ytd-action-companion-ad-renderer,' +
|
||||
'ytm-promoted-video-renderer,ytm-companion-ad-renderer,' +
|
||||
'ytm-promoted-sparkles-web-renderer,ytm-search-promoted-video-renderer,' +
|
||||
'.ytp-ad-overlay-container,.ytp-ad-overlay-slot,.ytp-ad-message-container,' +
|
||||
'#masthead-ad,#player-ads,' +
|
||||
'ytm-item-section-renderer:has(>*>ytm-companion-ad-renderer),' +
|
||||
'ytm-item-section-renderer:has(>*>ytm-promoted-sparkles-web-renderer),' +
|
||||
'ytm-item-section-renderer:has(>*>ytm-promoted-video-renderer),' +
|
||||
'ytm-item-section-renderer:has(>*>ytm-search-promoted-video-renderer),' +
|
||||
'ytm-item-section-renderer:has(>*>ytd-ad-slot-renderer),' +
|
||||
// The "desktop site" grid feed uses different wrapper elements entirely: each
|
||||
// card is a ytd-rich-item-renderer grid cell, so hiding just the ad renderer
|
||||
// inside it still leaves an empty cell-shaped hole in the grid.
|
||||
'ytd-rich-item-renderer:has(ytd-display-ad-renderer),' +
|
||||
'ytd-rich-item-renderer:has(ytd-ad-slot-renderer),' +
|
||||
'ytd-rich-item-renderer:has(ytd-promoted-sparkles-web-renderer),' +
|
||||
'ytd-rich-item-renderer:has(ytd-statement-banner-renderer),' +
|
||||
'ytd-rich-item-renderer:has(ytd-banner-promo-renderer),' +
|
||||
// Ads can also occupy a whole grid row, or a section above/between rows, not
|
||||
// just a single cell -- same empty-hole problem, one level up each time.
|
||||
'ytd-rich-grid-row:has(ytd-display-ad-renderer),' +
|
||||
'ytd-rich-grid-row:has(ytd-ad-slot-renderer),' +
|
||||
'ytd-rich-grid-row:has(ytd-promoted-sparkles-web-renderer),' +
|
||||
'ytd-rich-grid-row:has(ytd-statement-banner-renderer),' +
|
||||
'ytd-rich-grid-row:has(ytd-banner-promo-renderer),' +
|
||||
'ytd-rich-section-renderer:has(ytd-ad-slot-renderer),' +
|
||||
'ytd-rich-section-renderer:has(ytd-display-ad-renderer),' +
|
||||
'ytd-rich-section-renderer:has(ytd-statement-banner-renderer),' +
|
||||
'ytd-rich-section-renderer:has(ytd-banner-promo-renderer),' +
|
||||
'ytd-ad-slot-renderer,ytd-primetime-promo-renderer' +
|
||||
'{display:none!important}';
|
||||
|
||||
// Sweep for anything still playing: press skip, or seek to the end of the ad.
|
||||
var adTicks = 0;
|
||||
var sweep = function () {
|
||||
try {
|
||||
var skip = document.querySelector(
|
||||
'.ytp-ad-skip-button,.ytp-ad-skip-button-modern,.ytp-skip-ad-button,' +
|
||||
'.ytp-ad-skip-button-container button'
|
||||
);
|
||||
if (skip) { skip.click(); }
|
||||
var player = document.querySelector('.html5-video-player');
|
||||
if (player && player.classList.contains('ad-showing')) {
|
||||
// Only seek once the ad state has held for two ticks. Seeking during the
|
||||
// player's own transition makes it re-buffer, which looks like a stall.
|
||||
adTicks++;
|
||||
var video = player.querySelector('video');
|
||||
if (adTicks >= 2 && video && isFinite(video.duration) && video.duration > 0) {
|
||||
video.currentTime = video.duration;
|
||||
}
|
||||
} else {
|
||||
adTicks = 0;
|
||||
}
|
||||
} catch (e) {}
|
||||
};
|
||||
setInterval(sweep, 700);
|
||||
}
|
||||
|
||||
if (tweaksOn) {
|
||||
styleRules +=
|
||||
// The mobile site's fixed Home/Shorts/You bar and the "desktop site" grid's
|
||||
// reel shelf section it sits inside of.
|
||||
'ytm-pivot-bar-renderer,' +
|
||||
'ytd-rich-section-renderer:has(ytd-reel-shelf-renderer),' +
|
||||
// Shorts: a different, faster-cut format than the rest of the feed, and its
|
||||
// vertical-video shelf doesn't fit a landscape car display well either way.
|
||||
'ytm-reel-shelf-renderer,ytm-rich-shelf-renderer:has(ytm-reel-item-renderer),' +
|
||||
'ytm-reel-item-renderer,ytd-reel-shelf-renderer,' +
|
||||
// The "All / Podcasts / News / ..." filter chip row above the feed.
|
||||
'ytm-chip-bar-renderer,ytm-feed-filter-chip-bar-renderer' +
|
||||
'{display:none!important}';
|
||||
}
|
||||
|
||||
if (styleRules) {
|
||||
var style = document.createElement('style');
|
||||
style.setAttribute('data-gearslip', 'youtube');
|
||||
style.textContent = styleRules;
|
||||
var root = document.head || document.documentElement;
|
||||
if (root) { root.appendChild(style); }
|
||||
}
|
||||
} catch (e) {}
|
||||
})();
|
||||
""".trimIndent()
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import app.seb3thehacker.gearslip.browser.Bookmark
|
||||
import app.seb3thehacker.gearslip.browser.BrowserSettings
|
||||
import app.seb3thehacker.gearslip.browser.SiteIcons
|
||||
import app.seb3thehacker.gearslip.car.theme.GsIconBox
|
||||
import app.seb3thehacker.gearslip.car.theme.GsTone
|
||||
import app.seb3thehacker.gearslip.car.theme.gsColors
|
||||
import app.seb3thehacker.gearslip.car.theme.gsShape
|
||||
|
||||
/** How wide a tile is, icon and label together; the grid fits as many as the screen allows. */
|
||||
private val TILE_WIDTH = 112.dp
|
||||
private val ICON_SIZE = 64.dp
|
||||
|
||||
/**
|
||||
* What the browser shows before a page is open: a search bar, and the bookmarks as big tiles.
|
||||
* Holding a tile shows a remove button on every tile, as a launcher does; tapping anywhere
|
||||
* else, or Done, puts them away. Adding happens from the browser's star, or on the phone.
|
||||
*/
|
||||
@Composable
|
||||
internal fun BrowserStartPage(onOpen: (String) -> Unit, onSearch: () -> Unit, onAdd: () -> Unit) {
|
||||
val bookmarks by BrowserSettings.bookmarks.collectAsState()
|
||||
var editing by remember { mutableStateOf(false) }
|
||||
if (bookmarks.isEmpty()) editing = false
|
||||
|
||||
Column(Modifier.fillMaxSize().padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
SearchBar(onSearch)
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(TILE_WIDTH),
|
||||
contentPadding = PaddingValues(bottom = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
items(bookmarks, key = { it.url }) { bookmark ->
|
||||
BookmarkTile(
|
||||
bookmark,
|
||||
editing = editing,
|
||||
onClick = { if (editing) editing = false else onOpen(bookmark.url) },
|
||||
onHold = { editing = true },
|
||||
onRemove = { BrowserSettings.removeBookmark(bookmark.url) },
|
||||
)
|
||||
}
|
||||
item(key = "add") {
|
||||
if (editing) {
|
||||
Tile(stringResource(R.string.web_done), onClick = { editing = false }) {
|
||||
Icon(Icons.Filled.Close, null, Modifier.size(28.dp))
|
||||
}
|
||||
} else {
|
||||
Tile(stringResource(R.string.web_add_bookmark), onClick = onAdd) {
|
||||
Icon(Icons.Filled.Add, null, Modifier.size(28.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Looks like a text field, but opens the car keyboard: the same as the header's Search. */
|
||||
@Composable
|
||||
private fun SearchBar(onSearch: () -> Unit) {
|
||||
val colors = gsColors(GsTone.Neutral)
|
||||
GsIconBox(onSearch, Modifier.fillMaxWidth().height(56.dp), colors, shape = CircleShape) {
|
||||
Row(Modifier.fillMaxWidth().padding(horizontal = 20.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Filled.Search, null, Modifier.size(24.dp))
|
||||
Text(
|
||||
stringResource(R.string.web_search),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(start = 12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BookmarkTile(bookmark: Bookmark, editing: Boolean, onClick: () -> Unit, onHold: () -> Unit, onRemove: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val host = bookmark.host
|
||||
val version by SiteIcons.version.collectAsState()
|
||||
LaunchedEffect(host) { SiteIcons.fetch(context, host) }
|
||||
val icon = remember(host, version) { SiteIcons.icon(context, host) }
|
||||
Box {
|
||||
Tile(bookmark.title, onClick = onClick, onHold = onHold) {
|
||||
if (icon != null) {
|
||||
Image(icon.asImageBitmap(), null, Modifier.size(40.dp).clip(gsShape()))
|
||||
} else {
|
||||
Text(
|
||||
bookmark.title.firstOrNull()?.uppercase() ?: "?",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (editing) {
|
||||
GsIconBox(
|
||||
onRemove,
|
||||
Modifier.align(Alignment.TopEnd).offset(x = (-14).dp, y = (-6).dp).size(32.dp),
|
||||
gsColors(GsTone.Danger),
|
||||
shape = CircleShape,
|
||||
) {
|
||||
Icon(Icons.Filled.Close, stringResource(R.string.web_remove_bookmark, bookmark.title), Modifier.size(18.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** An icon box with its label under it, the start page's one shape. */
|
||||
@Composable
|
||||
private fun Tile(label: String, onClick: () -> Unit, onHold: (() -> Unit)? = null, icon: @Composable () -> Unit) {
|
||||
Column(Modifier.width(TILE_WIDTH), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
GsIconBox(onClick, Modifier.size(ICON_SIZE), gsColors(GsTone.Tonal), onLongClick = onHold) { icon() }
|
||||
Text(
|
||||
label,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,11 @@ import android.webkit.JavascriptInterface
|
||||
import android.webkit.WebChromeClient
|
||||
import android.widget.FrameLayout
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.size
|
||||
import app.seb3thehacker.gearslip.car.theme.GsIconBox
|
||||
import app.seb3thehacker.gearslip.car.theme.GsTone
|
||||
import app.seb3thehacker.gearslip.car.theme.gsColors
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import app.seb3thehacker.gearslip.car.theme.GsIconButton
|
||||
import android.webkit.WebResourceError
|
||||
@@ -33,14 +38,29 @@ import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import app.seb3thehacker.gearslip.GearslipLog
|
||||
import org.json.JSONObject
|
||||
import android.graphics.Bitmap
|
||||
import androidx.car.app.CarToast
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import app.seb3thehacker.gearslip.browser.BrowserSettings
|
||||
import app.seb3thehacker.gearslip.browser.SiteIcons
|
||||
import app.seb3thehacker.gearslip.browser.adblock.AdBlockManager
|
||||
import app.seb3thehacker.gearslip.browser.adblock.CosmeticFilterBridge
|
||||
import app.seb3thehacker.gearslip.browser.adblock.YouTubeAdBlock
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import androidx.compose.material.icons.filled.Star
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Home
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
||||
|
||||
/**
|
||||
* A browser inside the car UI. [content] is a URL, or raw HTML when it does not look like one.
|
||||
* The bottom bar's Back walks the page history first and only leaves the app at the start.
|
||||
* A browser inside the car UI. [content] is a URL, or raw HTML when it does not look like one;
|
||||
* blank opens [BrowserStartPage], the search bar and bookmark tiles. The bottom bar's Back walks
|
||||
* the page history first, then returns to the start page, and only then leaves the app.
|
||||
*
|
||||
* Every request a page makes passes [AdBlockManager] first, and [CosmeticFilterBridge] hides the
|
||||
* ad slots that are left (issue #85). Both are switched from the phone's browser settings.
|
||||
*
|
||||
* A page's own text inputs can't call up the phone's IME: the car UI lives in a Presentation on
|
||||
* a virtual display, and the platform's soft keyboard has nowhere sensible to appear there (see
|
||||
@@ -67,6 +87,29 @@ fun WebApp(content: String) {
|
||||
var canGoForward by remember { mutableStateOf(false) }
|
||||
// The address being typed, or null while the header shows the page title.
|
||||
var address by remember { mutableStateOf<String?>(null) }
|
||||
// An address being typed to save as a bookmark, from the start page's Add tile.
|
||||
var newBookmark by remember { mutableStateOf<String?>(null) }
|
||||
// The start page covers the WebView until something is opened.
|
||||
var atStart by remember { mutableStateOf(content.isBlank()) }
|
||||
var pageUrl by remember { mutableStateOf<String?>(null) }
|
||||
val bookmarks by BrowserSettings.bookmarks.collectAsState()
|
||||
val context = LocalContext.current
|
||||
LaunchedEffect(Unit) {
|
||||
AdBlockManager.ensureInitialized(context)
|
||||
AdBlockManager.refreshIfDue(context)
|
||||
}
|
||||
val open: (String) -> Unit = { url ->
|
||||
atStart = false
|
||||
web?.loadUrl(url)
|
||||
}
|
||||
val goHome: () -> Unit = {
|
||||
if (content.isBlank()) {
|
||||
atStart = true
|
||||
pageTitle = ""
|
||||
} else {
|
||||
web?.let { view -> loadContent(view, content) }
|
||||
}
|
||||
}
|
||||
|
||||
// Leaving the browser ends full screen, whoever started it.
|
||||
DisposableEffect(Unit) { onDispose { CarFullscreen.exit() } }
|
||||
@@ -78,15 +121,18 @@ fun WebApp(content: String) {
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(web) {
|
||||
DisposableEffect(web, atStart) {
|
||||
navigator.backInterceptor = {
|
||||
val view = web
|
||||
if (CarFullscreen.active) {
|
||||
CarFullscreen.exit()
|
||||
true
|
||||
} else if (view != null && view.canGoBack()) {
|
||||
} else if (!atStart && view != null && view.canGoBack()) {
|
||||
view.goBack()
|
||||
true
|
||||
} else if (!atStart && content.isBlank()) {
|
||||
goHome()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
@@ -97,19 +143,39 @@ fun WebApp(content: String) {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
// Back steps through the page's history first (the interceptor above), then leaves.
|
||||
if (!fullscreen) {
|
||||
ScreenHeader(pageTitle.ifEmpty { stringResource(R.string.app_browser) }, Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
|
||||
ScreenHeader(pageTitle.takeUnless { atStart }.orEmpty().ifEmpty { stringResource(R.string.app_browser) }, Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
|
||||
val url = pageUrl
|
||||
if (!atStart && url != null && url.startsWith("http")) {
|
||||
val saved = remember(bookmarks, url) { BrowserSettings.isBookmarked(url) }
|
||||
GsIconBox(
|
||||
{
|
||||
if (saved) BrowserSettings.removeBookmark(url) else BrowserSettings.addBookmark(pageTitle, url)
|
||||
CarToasts.show(BrowserSettings, context.getString(if (saved) R.string.web_bookmark_removed else R.string.web_bookmark_added), CarToast.LENGTH_SHORT)
|
||||
},
|
||||
Modifier.size(48.dp),
|
||||
gsColors(if (saved) GsTone.Tonal else GsTone.Neutral),
|
||||
latched = saved,
|
||||
) {
|
||||
androidx.compose.material3.Icon(
|
||||
androidx.compose.material.icons.Icons.Filled.Star,
|
||||
stringResource(if (saved) R.string.web_remove_this_bookmark else R.string.web_bookmark_this),
|
||||
Modifier.size(24.dp),
|
||||
tint = if (saved) MaterialTheme.colorScheme.primary else androidx.compose.material3.LocalContentColor.current.copy(alpha = 0.6f),
|
||||
)
|
||||
}
|
||||
}
|
||||
GsIconButton(androidx.compose.material.icons.Icons.Filled.Search, stringResource(R.string.web_search), {
|
||||
fieldText = null
|
||||
address = ""
|
||||
})
|
||||
if (canGoForward) {
|
||||
if (canGoForward && !atStart) {
|
||||
GsIconButton(androidx.compose.material.icons.Icons.AutoMirrored.Filled.ArrowForward, stringResource(R.string.web_forward), { web?.goForward() })
|
||||
}
|
||||
GsIconButton(androidx.compose.material.icons.Icons.Filled.Refresh, stringResource(R.string.web_reload), { web?.reload() })
|
||||
GsIconButton(androidx.compose.material.icons.Icons.Filled.Home, stringResource(R.string.web_start_page), {
|
||||
web?.let { view -> loadContent(view, content) }
|
||||
})
|
||||
GsIconButton(MediaIcons.Fullscreen, stringResource(R.string.web_fullscreen), CarFullscreen::enter)
|
||||
if (!atStart) {
|
||||
GsIconButton(androidx.compose.material.icons.Icons.Filled.Refresh, stringResource(R.string.web_reload), { web?.reload() })
|
||||
GsIconButton(androidx.compose.material.icons.Icons.Filled.Home, stringResource(R.string.web_start_page), goHome)
|
||||
GsIconButton(MediaIcons.Fullscreen, stringResource(R.string.web_fullscreen), CarFullscreen::enter)
|
||||
}
|
||||
}
|
||||
}
|
||||
Box(Modifier.weight(1f).fillMaxSize()) {
|
||||
@@ -117,12 +183,18 @@ fun WebApp(content: String) {
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
factory = { context ->
|
||||
WebView(context).apply {
|
||||
// The page a request belongs to, for telling first-party requests from
|
||||
// third-party ones: shouldInterceptRequest runs off the UI thread, where a
|
||||
// WebView's own url can't be read.
|
||||
val documentUrl = AtomicReference<String?>(null)
|
||||
CosmeticFilterBridge.attach(this)
|
||||
settings.javaScriptEnabled = true
|
||||
settings.domStorageEnabled = true
|
||||
settings.loadWithOverviewMode = true
|
||||
settings.useWideViewPort = true
|
||||
webViewClient = LoggingClient(ring) { view ->
|
||||
webViewClient = LoggingClient(ring, documentUrl, onStarted = { pageUrl = it }) { view ->
|
||||
canGoForward = view.canGoForward()
|
||||
pageUrl = view.url
|
||||
pageTitle = view.title.orEmpty().takeUnless { it.startsWith("http") || it == "about:blank" }.orEmpty()
|
||||
}
|
||||
webChromeClient = object : WebChromeClient() {
|
||||
@@ -130,6 +202,12 @@ fun WebApp(content: String) {
|
||||
pageTitle = title.orEmpty().takeUnless { it.startsWith("http") || it == "about:blank" }.orEmpty()
|
||||
}
|
||||
|
||||
// Kept for the start page's tiles, so a bookmark shows its own icon.
|
||||
override fun onReceivedIcon(view: WebView?, icon: Bitmap?) {
|
||||
val host = view?.url?.let { runCatching { android.net.Uri.parse(it).host }.getOrNull() }?.removePrefix("www.")
|
||||
if (icon != null && host != null) SiteIcons.save(view.context, host, icon)
|
||||
}
|
||||
|
||||
override fun onShowCustomView(view: View, callback: CustomViewCallback) {
|
||||
videoMadeFullscreen = !CarFullscreen.active
|
||||
pageFullscreen = view to callback
|
||||
@@ -146,7 +224,7 @@ fun WebApp(content: String) {
|
||||
FieldFocusBridge(onFocus = { fieldText = it }, onBlur = { fieldText = null }),
|
||||
"GearslipField",
|
||||
)
|
||||
loadContent(this, content)
|
||||
if (content.isNotBlank()) loadContent(this, content)
|
||||
web = this
|
||||
}
|
||||
},
|
||||
@@ -154,6 +232,18 @@ fun WebApp(content: String) {
|
||||
update = { it.settings.isAlgorithmicDarkeningAllowed = darkOutside },
|
||||
onRelease = { it.destroy() },
|
||||
)
|
||||
if (atStart) {
|
||||
androidx.compose.foundation.layout.Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surface)) {
|
||||
BrowserStartPage(
|
||||
onOpen = open,
|
||||
onSearch = {
|
||||
fieldText = null
|
||||
address = ""
|
||||
},
|
||||
onAdd = { newBookmark = "https://" },
|
||||
)
|
||||
}
|
||||
}
|
||||
pageFullscreen?.let { (view, _) ->
|
||||
androidx.compose.runtime.key(view) {
|
||||
AndroidView(
|
||||
@@ -175,12 +265,32 @@ fun WebApp(content: String) {
|
||||
text = typed,
|
||||
onTextChange = { address = it },
|
||||
onSubmit = {
|
||||
web?.loadUrl(addressToUrl(typed))
|
||||
open(BrowserSettings.addressToUrl(typed))
|
||||
address = null
|
||||
},
|
||||
onDismiss = { address = null },
|
||||
)
|
||||
}
|
||||
newBookmark?.let { typed ->
|
||||
CarKeyboard(
|
||||
text = typed,
|
||||
onTextChange = { newBookmark = it },
|
||||
onSubmit = {
|
||||
// Only an address: words typed here would otherwise save a search as a bookmark.
|
||||
val text = typed.trim()
|
||||
val url = when {
|
||||
text.startsWith("http://") || text.startsWith("https://") -> text
|
||||
' ' !in text && '.' in text -> "https://$text"
|
||||
else -> null
|
||||
}
|
||||
if (url != null && BrowserSettings.addBookmark("", url)) {
|
||||
CarToasts.show(BrowserSettings, context.getString(R.string.web_bookmark_added), CarToast.LENGTH_SHORT)
|
||||
}
|
||||
newBookmark = null
|
||||
},
|
||||
onDismiss = { newBookmark = null },
|
||||
)
|
||||
}
|
||||
fieldText?.let { text ->
|
||||
CarKeyboard(
|
||||
text = text,
|
||||
@@ -207,18 +317,27 @@ private fun loadContent(view: WebView, content: String) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Something that looks like an address opens as one; anything else is searched for. */
|
||||
private fun addressToUrl(typed: String): String {
|
||||
val text = typed.trim()
|
||||
return when {
|
||||
text.startsWith("http://") || text.startsWith("https://") -> text
|
||||
' ' !in text && '.' in text -> "https://$text"
|
||||
else -> "https://duckduckgo.com/?q=" + java.net.URLEncoder.encode(text, "UTF-8")
|
||||
private class LoggingClient(
|
||||
private val ring: String,
|
||||
private val documentUrl: AtomicReference<String?>,
|
||||
private val onStarted: (String?) -> Unit,
|
||||
private val onLoaded: (WebView) -> Unit,
|
||||
) : WebViewClient() {
|
||||
override fun shouldInterceptRequest(view: WebView?, request: WebResourceRequest?): WebResourceResponse? =
|
||||
request?.let { AdBlockManager.response(it, documentUrl.get()) }
|
||||
|
||||
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
|
||||
documentUrl.set(url)
|
||||
onStarted(url)
|
||||
// Without document-start scripts, inject as early as this WebView allows.
|
||||
if (CosmeticFilterBridge.needsPageStartFallback) {
|
||||
view?.evaluateJavascript(CosmeticFilterBridge.INJECTION_JS, null)
|
||||
view?.evaluateJavascript(YouTubeAdBlock.INJECTION_JS, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class LoggingClient(private val ring: String, private val onLoaded: (WebView) -> Unit) : WebViewClient() {
|
||||
override fun onPageFinished(view: WebView?, url: String?) {
|
||||
documentUrl.set(url)
|
||||
GearslipLog.i("car web page loaded: $url")
|
||||
view?.let(onLoaded)
|
||||
view?.evaluateJavascript(FOCUS_TRACKING_JS, null)
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
package app.seb3thehacker.gearslip.ui
|
||||
|
||||
import android.text.format.DateUtils
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import app.seb3thehacker.gearslip.browser.BrowserSettings
|
||||
import app.seb3thehacker.gearslip.browser.SearchEngine
|
||||
import app.seb3thehacker.gearslip.browser.adblock.AdBlockManager
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* The phone's settings for the car's browser (issue #85): its bookmarks, where searches go, and
|
||||
* the ad blocker with its filter lists. Typing addresses is easier here than on the car screen.
|
||||
*/
|
||||
@Composable
|
||||
internal fun BrowserSettingsRows(webPageRow: @Composable () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
LaunchedEffect(Unit) { AdBlockManager.ensureInitialized(context) }
|
||||
val bookmarks by BrowserSettings.bookmarks.collectAsState()
|
||||
val engine by BrowserSettings.searchEngine.collectAsState()
|
||||
val adBlock by BrowserSettings.adBlock.collectAsState()
|
||||
val youTube by BrowserSettings.youTubeAdBlock.collectAsState()
|
||||
val lists by BrowserSettings.filterLists.collectAsState()
|
||||
var addingBookmark by remember { mutableStateOf(false) }
|
||||
var addingList by remember { mutableStateOf(false) }
|
||||
|
||||
webPageRow()
|
||||
SettingsDivider()
|
||||
SearchEngineRow(engine)
|
||||
|
||||
SettingsDivider()
|
||||
Text(
|
||||
stringResource(R.string.browser_bookmarks),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 12.dp),
|
||||
)
|
||||
bookmarks.forEach { bookmark ->
|
||||
SettingsRow(
|
||||
bookmark.title,
|
||||
subtitle = bookmark.host,
|
||||
trailing = {
|
||||
IconButton({ BrowserSettings.removeBookmark(bookmark.url) }) {
|
||||
Icon(Icons.Filled.Delete, stringResource(R.string.web_remove_bookmark, bookmark.title))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
SettingsRow(
|
||||
stringResource(R.string.browser_add_bookmark),
|
||||
Modifier.clickable { addingBookmark = true },
|
||||
subtitle = stringResource(R.string.browser_bookmarks_note),
|
||||
icon = Icons.Filled.Add,
|
||||
)
|
||||
|
||||
SettingsDivider()
|
||||
SwitchRow(stringResource(R.string.browser_adblock), adBlock, BrowserSettings::setAdBlock, stringResource(R.string.browser_adblock_note))
|
||||
if (adBlock) {
|
||||
SettingsDivider()
|
||||
SwitchRow(stringResource(R.string.browser_youtube), youTube, BrowserSettings::setYouTubeAdBlock, stringResource(R.string.browser_youtube_note))
|
||||
SettingsDivider()
|
||||
Text(
|
||||
stringResource(R.string.browser_filter_lists),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 12.dp),
|
||||
)
|
||||
lists.forEach { list ->
|
||||
SettingsRow(
|
||||
list.title,
|
||||
Modifier.clickable {
|
||||
BrowserSettings.setListEnabled(list.id, !list.enabled)
|
||||
AdBlockManager.reload(context)
|
||||
},
|
||||
subtitle = if (list.builtIn) null else list.url,
|
||||
leading = { Checkbox(list.enabled, onCheckedChange = null) },
|
||||
trailing = if (list.builtIn) null else {
|
||||
{
|
||||
IconButton({
|
||||
AdBlockManager.forget(context, list)
|
||||
BrowserSettings.removeCustomList(list.id)
|
||||
AdBlockManager.reload(context)
|
||||
}) { Icon(Icons.Filled.Delete, stringResource(R.string.browser_remove)) }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
SettingsRow(stringResource(R.string.browser_add_list), Modifier.clickable { addingList = true }, icon = Icons.Filled.Add)
|
||||
UpdateListsRow()
|
||||
}
|
||||
|
||||
if (addingBookmark) {
|
||||
BookmarkDialog(onDismiss = { addingBookmark = false }) { title, url ->
|
||||
BrowserSettings.addBookmark(title, url).also { if (it) addingBookmark = false }
|
||||
}
|
||||
}
|
||||
if (addingList) {
|
||||
AddressDialog(
|
||||
title = stringResource(R.string.browser_add_list),
|
||||
label = stringResource(R.string.browser_list_address),
|
||||
note = stringResource(R.string.browser_list_note),
|
||||
onDismiss = { addingList = false },
|
||||
) { url ->
|
||||
BrowserSettings.addCustomList(url).also { if (it) addingList = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SearchEngineRow(engine: SearchEngine) {
|
||||
var menu by remember { mutableStateOf(false) }
|
||||
SettingsRow(
|
||||
stringResource(R.string.browser_search_engine),
|
||||
Modifier.clickable { menu = true },
|
||||
trailing = {
|
||||
Box {
|
||||
Text(engine.label, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.primary)
|
||||
DropdownMenu(menu, onDismissRequest = { menu = false }) {
|
||||
SearchEngine.entries.forEach { choice ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(choice.label, fontWeight = if (choice == engine) FontWeight.Bold else FontWeight.Normal) },
|
||||
onClick = {
|
||||
BrowserSettings.setSearchEngine(choice)
|
||||
menu = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** How many rules are loaded and when the lists last came down, with a button to fetch them now. */
|
||||
@Composable
|
||||
private fun UpdateListsRow() {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
var updating by remember { mutableStateOf(false) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
// Bumped after an update, so the counts and the date are read again.
|
||||
var refreshed by remember { mutableIntStateOf(0) }
|
||||
val rules = remember(refreshed, updating) { AdBlockManager.networkRuleCount + AdBlockManager.cosmeticRuleCount }
|
||||
val updatedAt = remember(refreshed, updating) { AdBlockManager.lastUpdatedAt(context) }
|
||||
val status = when {
|
||||
updating -> stringResource(R.string.browser_updating)
|
||||
error != null -> stringResource(R.string.browser_update_failed, error.orEmpty())
|
||||
updatedAt > 0 -> stringResource(R.string.browser_lists_status, rules, DateUtils.getRelativeTimeSpanString(updatedAt).toString())
|
||||
else -> stringResource(R.string.browser_lists_never, rules)
|
||||
}
|
||||
SettingsRow(
|
||||
stringResource(R.string.browser_update_lists),
|
||||
Modifier.clickable(enabled = !updating) {
|
||||
updating = true
|
||||
error = null
|
||||
scope.launch {
|
||||
val result = withContext(Dispatchers.IO) { AdBlockManager.refreshLists(context) }
|
||||
error = (result as? AdBlockManager.RefreshResult.Failure)?.message
|
||||
updating = false
|
||||
refreshed++
|
||||
}
|
||||
},
|
||||
subtitle = status,
|
||||
icon = Icons.Filled.Refresh,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SwitchRow(title: String, on: Boolean, onChange: (Boolean) -> Unit, note: String) {
|
||||
SettingsRow(title, Modifier.clickable { onChange(!on) }, subtitle = note, trailing = { Switch(checked = on, onCheckedChange = null) })
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BookmarkDialog(onDismiss: () -> Unit, onSave: (String, String) -> Boolean) {
|
||||
var title by remember { mutableStateOf("") }
|
||||
var url by remember { mutableStateOf("https://") }
|
||||
var invalid by remember { mutableStateOf(false) }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.browser_add_bookmark)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
OutlinedTextField(title, { title = it }, label = { Text(stringResource(R.string.browser_bookmark_name)) }, singleLine = true)
|
||||
OutlinedTextField(
|
||||
url, { url = it; invalid = false },
|
||||
label = { Text(stringResource(R.string.browser_bookmark_address)) },
|
||||
singleLine = true,
|
||||
isError = invalid,
|
||||
supportingText = if (invalid) ({ Text(stringResource(R.string.browser_shared_not_url)) }) else null,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton({ invalid = !onSave(title, withScheme(url)) }) { Text(stringResource(R.string.save)) } },
|
||||
dismissButton = { TextButton(onDismiss) { Text(stringResource(R.string.cancel)) } },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AddressDialog(title: String, label: String, note: String, onDismiss: () -> Unit, onSave: (String) -> Boolean) {
|
||||
var url by remember { mutableStateOf("https://") }
|
||||
var invalid by remember { mutableStateOf(false) }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(title) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(note)
|
||||
OutlinedTextField(
|
||||
url, { url = it; invalid = false },
|
||||
label = { Text(label) },
|
||||
singleLine = true,
|
||||
isError = invalid,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton({ invalid = !onSave(withScheme(url)) }) { Text(stringResource(R.string.save)) } },
|
||||
dismissButton = { TextButton(onDismiss) { Text(stringResource(R.string.cancel)) } },
|
||||
)
|
||||
}
|
||||
|
||||
/** "example.com" is what someone typing an address on a phone meant. */
|
||||
private fun withScheme(text: String): String {
|
||||
val t = text.trim()
|
||||
return if ("://" in t || t.isEmpty()) t else "https://$t"
|
||||
}
|
||||
@@ -64,6 +64,7 @@ enum class CarGroup(@param:StringRes val title: Int) {
|
||||
NAV_BAR(R.string.car_group_nav_bar),
|
||||
SOUND(R.string.car_group_sound),
|
||||
PLACES(R.string.places_title),
|
||||
BROWSER(R.string.app_browser),
|
||||
WARNINGS(R.string.car_group_warnings),
|
||||
MORE(R.string.car_group_more),
|
||||
}
|
||||
@@ -151,6 +152,8 @@ internal fun HomeCarSettings(onOpen: (CarGroup) -> Unit, modifier: Modifier = Mo
|
||||
val playerLeft by CarSettings.playerLeft.collectAsState()
|
||||
val theme by CarSettings.appTheme.collectAsState()
|
||||
val experimental by CarSettings.experimentalFeaturesEnabled.collectAsState()
|
||||
val browserEngine by app.seb3thehacker.gearslip.browser.BrowserSettings.searchEngine.collectAsState()
|
||||
val browserAdBlock by app.seb3thehacker.gearslip.browser.BrowserSettings.adBlock.collectAsState()
|
||||
|
||||
val opens = openChoices()
|
||||
val sizes = sizeChoices()
|
||||
@@ -180,8 +183,11 @@ internal fun HomeCarSettings(onOpen: (CarGroup) -> Unit, modifier: Modifier = Mo
|
||||
str(if (bluetoothWarning && !pipeAudio) R.string.summary_bluetooth_on else R.string.summary_bluetooth_off),
|
||||
str(if (locationWarning) R.string.summary_location_on else R.string.summary_location_off),
|
||||
)
|
||||
CarGroup.BROWSER -> listOf(
|
||||
browserEngine.label,
|
||||
str(if (browserAdBlock) R.string.summary_adblock_on else R.string.summary_adblock_off),
|
||||
)
|
||||
CarGroup.MORE -> listOf(
|
||||
str(R.string.web_page),
|
||||
str(if (experimental) R.string.summary_experimental_shown else R.string.summary_experimental_hidden),
|
||||
)
|
||||
}.filter { it.isNotEmpty() }.joinToString(" · ")
|
||||
@@ -350,9 +356,8 @@ private fun GroupRows(group: CarGroup) {
|
||||
}
|
||||
Toggle(stringResource(R.string.car_warn_location), locationWarning, CarSettings::setLocationWarning)
|
||||
}
|
||||
CarGroup.BROWSER -> BrowserSettingsRows { WebPageRow() }
|
||||
CarGroup.MORE -> {
|
||||
WebPageRow()
|
||||
SettingsDivider()
|
||||
Toggle(stringResource(R.string.car_experimental), experimental, CarSettings::setExperimentalFeaturesEnabled, note = stringResource(R.string.car_experimental_note))
|
||||
if (experimental) {
|
||||
SettingsDivider()
|
||||
|
||||
Reference in new issue
Block a user