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:
Seb3thehacker committed 2026-11-06 13:55:19 -08:00
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
}
}
}
@@ -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
}
}
@@ -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()