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

+2
View File
@@ -196,6 +196,8 @@ dependencies {
implementation(libs.androidx.car.app)
implementation(libs.androidx.car.app.projected)
implementation(libs.androidx.media)
// Document-start scripts, so the ad blocker hides ad slots before a page first paints.
implementation(libs.androidx.webkit)
testImplementation(libs.junit)
}
+15
View File
@@ -189,6 +189,21 @@
the head unit to Android Auto, without disabling the screen the switch is on.
-->
<!-- App info › Storage › Manage space: clears lyrics, voice models and logs one at a time. -->
<!-- "Share" a page from any browser to add it to the car browser's start page (issue #85). -->
<activity
android:name=".browser.ShareToBrowserActivity"
android:exported="true"
android:excludeFromRecents="true"
android:label="@string/browser_share_label"
android:noHistory="true"
android:theme="@android:style/Theme.Translucent.NoTitleBar">
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
</activity>
<activity
android:name=".ui.ManageSpaceActivity"
android:exported="true"
File diff suppressed because it is too large. Load diff
@@ -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()
+34 -2
View File
@@ -211,7 +211,7 @@
<string name="car_vehicle_data_note">يضيف مربع «بيانات المركبة»: عرض مباشر لحساسات السيارة. أسماء الحقول تخمين إلى أن ترى أحدها يتغير.</string>
<string name="car_adaptive_video">جودة فيديو متكيفة</string>
<string name="car_adaptive_video_note">يخفض جودة الصورة عندما لا تواكب السيارة أو شبكة Wi-Fi فيها، ويرفعها مجددًا حين تستطيع. يسري عند الاتصال التالي.</string>
<string name="web_page_note">يعرضها تطبيق المتصفح في السيارة. إذا تُركت فارغة يُفتح DuckDuckGo. يسري عند الاتصال التالي.</string>
<string name="web_page_note">يعرضها تطبيق المتصفح في السيارة. إذا تُركت فارغة تُفتح صفحة البدء مع إشاراتك المرجعية. تُطبَّق عند الاتصال التالي.</string>
<string name="car_audio">صوت السيارة</string>
<string name="car_audio_bt">بلوتوث (موصى به)</string>
<string name="car_audio_bt_sub">لا سؤال في كل رحلة، ويصل صوت تطبيق الخرائط إلى السيارة</string>
@@ -440,7 +440,7 @@
<string name="carset_build">البنية</string>
<string name="carset_whats_new_after">الجديد بعد التحديثات</string>
<string name="carset_whats_new_after_sub">عرض قائمة التغييرات مرة واحدة، في أول اتصال للسيارة بعد التحديث.</string>
<string name="carset_web_default">DuckDuckGo (افتراضي) - انقر لتعيين صفحة أخرى</string>
<string name="carset_web_default">صفحة البدء (افتراضي) - انقر لتعيين صفحة</string>
<string name="carset_web_note">يعرضها تطبيق المتصفح في المشغّل. يسري عند الاتصال التالي.</string>
<string name="carset_screen_fit">ملاءمة الشاشة</string>
<string name="carset_top">الأعلى</string>
@@ -948,4 +948,36 @@
<string name="density_custom_title">كثافة مخصصة</string>
<string name="density_custom_label">DPI</string>
<string name="density_custom_note">أي قيمة من %1$d إلى %2$d. القيمة الأقل تُظهر محتوى أكثر على الشاشة، والأعلى تُكبّر كل شيء.</string>
<string name="web_add_bookmark">إضافة</string>
<string name="web_done">تم</string>
<string name="web_remove_bookmark">إزالة %1$s</string>
<string name="web_bookmark_added">أُضيفت إلى صفحة البدء</string>
<string name="web_bookmark_removed">أُزيلت من صفحة البدء</string>
<string name="web_bookmark_this">إضافة إلى صفحة البدء</string>
<string name="web_remove_this_bookmark">إزالة من صفحة البدء</string>
<string name="browser_search_engine">محرك البحث</string>
<string name="browser_bookmarks">الإشارات المرجعية</string>
<string name="browser_bookmarks_note">تظهر كمربعات في صفحة البدء بالمتصفح. يمكنك أيضًا مشاركة صفحة مع Gearslip من أي متصفح.</string>
<string name="browser_add_bookmark">إضافة إشارة مرجعية</string>
<string name="browser_bookmark_name">الاسم</string>
<string name="browser_bookmark_address">عنوان الويب</string>
<string name="browser_remove">إزالة</string>
<string name="browser_adblock">حظر الإعلانات وأدوات التتبع</string>
<string name="browser_adblock_note">يستخدم قوائم فلترة تُحدَّث أسبوعيًا عبر Wi-Fi من المواقع التي تنشرها.</string>
<string name="browser_youtube">حظر إعلانات فيديوهات YouTube</string>
<string name="browser_youtube_note">يتخطى الإعلانات داخل الفيديوهات. يتغير YouTube كثيرًا، لذا أوقف هذا الخيار إذا توقفت الفيديوهات عن التشغيل.</string>
<string name="browser_filter_lists">قوائم الفلترة</string>
<string name="browser_add_list">إضافة قائمة فلترة</string>
<string name="browser_list_address">عنوان القائمة</string>
<string name="browser_list_note">أي قائمة بتنسيق Adblock Plus، مثل قائمة للغتك أو منطقتك.</string>
<string name="browser_update_lists">تحديث القوائم الآن</string>
<string name="browser_lists_status">%1$d قاعدة، حُدّثت %2$s</string>
<string name="browser_lists_never">%1$d قاعدة، من النسخة المضمّنة في Gearslip</string>
<string name="browser_updating">جارٍ التحديث…</string>
<string name="browser_update_failed">تعذّر التحديث. %1$s</string>
<string name="browser_shared_added">أُضيفت إلى صفحة البدء في Gearslip</string>
<string name="browser_shared_not_url">هذا ليس عنوان ويب</string>
<string name="browser_share_label">صفحة البدء في Gearslip</string>
<string name="summary_adblock_on">الإعلانات محظورة</string>
<string name="summary_adblock_off">الإعلانات غير محظورة</string>
</resources>
+34 -2
View File
@@ -211,7 +211,7 @@
<string name="car_vehicle_data_note">Přidá dlaždici Data vozidla: živý výpis senzorů auta. Názvy polí jsou odhady, dokud neuvidíte, jak se některé mění.</string>
<string name="car_adaptive_video">Adaptivní kvalita obrazu</string>
<string name="car_adaptive_video_note">Sníží kvalitu obrazu, když auto nebo jeho Wi-Fi nestíhá, a zase ji zvýší, jakmile to jde. Platí od příštího připojení.</string>
<string name="web_page_note">Zobrazuje ji aplikace Prohlížeč v autě. Prázdné pole otevře DuckDuckGo. Platí od příštího připojení.</string>
<string name="web_page_note">Zobrazuje ji aplikace Prohlížeč v autě. Prázdné otevře úvodní stránku se záložkami. Platí od dalšího připojení.</string>
<string name="car_audio">Zvuk v autě</string>
<string name="car_audio_bt">Bluetooth (doporučeno)</string>
<string name="car_audio_bt_sub">Žádný dotaz při každé jízdě a hlas mapové aplikace se dostane do auta</string>
@@ -438,7 +438,7 @@
<string name="carset_build">Sestavení</string>
<string name="carset_whats_new_after">Novinky po aktualizacích</string>
<string name="carset_whats_new_after_sub">Zobrazit seznam změn jednou, při prvním připojení auta po aktualizaci.</string>
<string name="carset_web_default">DuckDuckGo (výchozí) - klepnutím nastavíte jinou stránku</string>
<string name="carset_web_default">Úvodní stránka (výchozí) - klepnutím nastavíte stránku</string>
<string name="carset_web_note">Zobrazuje ji aplikace Prohlížeč v launcheru. Platí od příštího připojení.</string>
<string name="carset_screen_fit">Přizpůsobení obrazovky</string>
<string name="carset_top">Nahoře</string>
@@ -928,4 +928,36 @@
<string name="density_custom_title">Vlastní hustota</string>
<string name="density_custom_label">DPI</string>
<string name="density_custom_note">Libovolná hodnota od %1$d do %2$d. Nižší vejde víc na obrazovku, vyšší vše zvětší.</string>
<string name="web_add_bookmark">Přidat</string>
<string name="web_done">Hotovo</string>
<string name="web_remove_bookmark">Odebrat %1$s</string>
<string name="web_bookmark_added">Přidáno na úvodní stránku</string>
<string name="web_bookmark_removed">Odebráno z úvodní stránky</string>
<string name="web_bookmark_this">Přidat na úvodní stránku</string>
<string name="web_remove_this_bookmark">Odebrat z úvodní stránky</string>
<string name="browser_search_engine">Vyhledávač</string>
<string name="browser_bookmarks">Záložky</string>
<string name="browser_bookmarks_note">Zobrazují se jako dlaždice na úvodní stránce prohlížeče. Stránku můžete do Gearslip sdílet i z libovolného prohlížeče.</string>
<string name="browser_add_bookmark">Přidat záložku</string>
<string name="browser_bookmark_name">Název</string>
<string name="browser_bookmark_address">Webová adresa</string>
<string name="browser_remove">Odebrat</string>
<string name="browser_adblock">Blokovat reklamy a sledovací prvky</string>
<string name="browser_adblock_note">Používá seznamy filtrů, které se jednou týdně přes Wi-Fi aktualizují ze stránek, kde vycházejí.</string>
<string name="browser_youtube">Blokovat reklamy ve videích YouTube</string>
<string name="browser_youtube_note">Přeskakuje reklamy ve videích. YouTube se často mění, takže to vypněte, pokud se videa přestanou přehrávat.</string>
<string name="browser_filter_lists">Seznamy filtrů</string>
<string name="browser_add_list">Přidat seznam filtrů</string>
<string name="browser_list_address">Adresa seznamu</string>
<string name="browser_list_note">Jakýkoli seznam ve formátu Adblock Plus, třeba pro váš jazyk nebo region.</string>
<string name="browser_update_lists">Aktualizovat seznamy</string>
<string name="browser_lists_status">Pravidel: %1$d, aktualizováno %2$s</string>
<string name="browser_lists_never">Pravidel: %1$d, z kopie v Gearslip</string>
<string name="browser_updating">Aktualizace…</string>
<string name="browser_update_failed">Aktualizace se nezdařila. %1$s</string>
<string name="browser_shared_added">Přidáno na úvodní stránku Gearslip</string>
<string name="browser_shared_not_url">To není webová adresa</string>
<string name="browser_share_label">Úvodní stránka Gearslip</string>
<string name="summary_adblock_on">Reklamy blokovány</string>
<string name="summary_adblock_off">Reklamy neblokovány</string>
</resources>
+34 -2
View File
@@ -20,7 +20,7 @@
<string name="carset_build">Build</string>
<string name="carset_whats_new_after">Neuigkeiten nach Updates</string>
<string name="carset_whats_new_after_sub">Zeigt die Liste der Änderungen einmal an, wenn sich das Auto nach einem Update zum ersten Mal verbindet.</string>
<string name="carset_web_default">DuckDuckGo (Standard) – tippe, um eine andere Seite festzulegen</string>
<string name="carset_web_default">Startseite (Standard) – tippe, um eine Seite festzulegen</string>
<string name="carset_web_note">Wird von der Browser-App im Launcher angezeigt. Gilt ab der nächsten Verbindung.</string>
<string name="carset_screen_fit">Bildschirmanpassung</string>
<string name="carset_top">Oben</string>
@@ -518,7 +518,7 @@
<string name="car_vehicle_data_note">Fügt eine Kachel „Fahrzeugdaten“ hinzu: ein Live-Mitschnitt der Sensoren des Autos. Die Feldnamen sind Vermutungen, bis du selbst gesehen hast, wie sich ein Wert ändert.</string>
<string name="car_adaptive_video">Adaptive Videoqualität</string>
<string name="car_adaptive_video_note">Senkt die Bildqualität, wenn das Auto oder sein WLAN nicht mithalten kann und erhöht sie wieder, sobald es geht. Gilt ab der nächsten Verbindung.</string>
<string name="web_page_note">Wird von der Browser-App im Auto angezeigt. Wenn leer, öffnet sich DuckDuckGo. Gilt ab der nächsten Verbindung.</string>
<string name="web_page_note">Wird von der Browser-App im Auto angezeigt. Wenn leer, öffnet sich die Startseite mit deinen Lesezeichen. Gilt ab der nächsten Verbindung.</string>
<string name="car_audio">Auto-Audio</string>
<string name="car_audio_bt">Bluetooth (empfohlen)</string>
<string name="car_audio_bt_sub">Keine Abfrage bei jeder Fahrt, und die Sprachansage deiner Karten-App kommt im Auto an</string>
@@ -790,4 +790,36 @@
<string name="density_custom_title">Eigene Dichte</string>
<string name="density_custom_label">DPI</string>
<string name="density_custom_note">Ein Wert von %1$d bis %2$d. Kleiner passt mehr auf den Bildschirm, größer macht alles größer.</string>
<string name="web_add_bookmark">Hinzufügen</string>
<string name="web_done">Fertig</string>
<string name="web_remove_bookmark">%1$s entfernen</string>
<string name="web_bookmark_added">Zur Startseite hinzugefügt</string>
<string name="web_bookmark_removed">Von der Startseite entfernt</string>
<string name="web_bookmark_this">Zur Startseite hinzufügen</string>
<string name="web_remove_this_bookmark">Von der Startseite entfernen</string>
<string name="browser_search_engine">Suchmaschine</string>
<string name="browser_bookmarks">Lesezeichen</string>
<string name="browser_bookmarks_note">Werden als Kacheln auf der Startseite des Browsers angezeigt. Du kannst eine Seite auch aus jedem Browser an Gearslip teilen.</string>
<string name="browser_add_bookmark">Lesezeichen hinzufügen</string>
<string name="browser_bookmark_name">Name</string>
<string name="browser_bookmark_address">Webadresse</string>
<string name="browser_remove">Entfernen</string>
<string name="browser_adblock">Werbung und Tracker blockieren</string>
<string name="browser_adblock_note">Nutzt Filterlisten, die wöchentlich über WLAN von ihren Herausgebern aktualisiert werden.</string>
<string name="browser_youtube">YouTube-Videowerbung blockieren</string>
<string name="browser_youtube_note">Überspringt Werbung in Videos. YouTube ändert sich oft, also schalte das aus, wenn Videos nicht mehr abspielen.</string>
<string name="browser_filter_lists">Filterlisten</string>
<string name="browser_add_list">Filterliste hinzufügen</string>
<string name="browser_list_address">Adresse der Liste</string>
<string name="browser_list_note">Jede Liste im Adblock-Plus-Format, etwa eine für deine Sprache oder Region.</string>
<string name="browser_update_lists">Listen jetzt aktualisieren</string>
<string name="browser_lists_status">%1$d Regeln, aktualisiert %2$s</string>
<string name="browser_lists_never">%1$d Regeln, aus der in Gearslip enthaltenen Kopie</string>
<string name="browser_updating">Wird aktualisiert…</string>
<string name="browser_update_failed">Aktualisierung fehlgeschlagen. %1$s</string>
<string name="browser_shared_added">Zur Startseite von Gearslip hinzugefügt</string>
<string name="browser_shared_not_url">Das ist keine Webadresse</string>
<string name="browser_share_label">Gearslip-Startseite</string>
<string name="summary_adblock_on">Werbung blockiert</string>
<string name="summary_adblock_off">Werbung nicht blockiert</string>
</resources>
+34 -2
View File
@@ -120,7 +120,7 @@
<string name="car_vehicle_data_note">Añade una ficha de datos del vehículo: un vertedero en vivo de los sensores del coche. Los nombres de campo son adivinanzas hasta que hayas visto un cambio.</string>
<string name="car_adaptive_video">Calidad de vídeo adaptativa</string>
<string name="car_adaptive_video_note">Reduce la calidad de la imagen cuando el coche o su Wi-Fi no pueden mantenerse, y la levanta de nuevo una vez que pueda. Se aplica en la siguiente conexión.</string>
<string name="web_page_note">Mostrada por la aplicación Browser en el coche. Empty abre DuckDuckGo. Se aplica en la siguiente conexión.</string>
<string name="web_page_note">La muestra la app Navegador en el coche. Si está vacía, abre la página de inicio con tus marcadores. Se aplica en la próxima conexión.</string>
<string name="car_audio">Audio de coches</string>
<string name="car_audio_bt">Bluetooth (recomendado)</string>
<string name="car_audio_bt_sub">Sin aviso cada viaje, y la voz de su aplicación del mapa llega al coche</string>
@@ -669,7 +669,7 @@
<string name="carset_build">Build</string>
<string name="carset_whats_new_after">Lo nuevo después de las actualizaciones</string>
<string name="carset_whats_new_after_sub">Mostrar la lista de cambios una vez, la primera vez que el coche se conecta después de una actualización.</string>
<string name="carset_web_default">DuckDuckGo (default) - pulsar para configurar una página diferente</string>
<string name="carset_web_default">Página de inicio (predeterminada): toca para elegir una página</string>
<string name="carset_web_note">Mostrada por la aplicación Browser en el lanzador. Se aplica en la siguiente conexión.</string>
<string name="carset_screen_fit">Ajuste de la pantalla</string>
<string name="carset_top">Top</string>
@@ -792,4 +792,36 @@
<string name="density_custom_title">Densidad personalizada</string>
<string name="density_custom_label">DPI</string>
<string name="density_custom_note">Cualquier valor de %1$d a %2$d. Un valor menor muestra más en pantalla; uno mayor lo agranda todo.</string>
<string name="web_add_bookmark">Añadir</string>
<string name="web_done">Listo</string>
<string name="web_remove_bookmark">Quitar %1$s</string>
<string name="web_bookmark_added">Añadido a la página de inicio</string>
<string name="web_bookmark_removed">Quitado de la página de inicio</string>
<string name="web_bookmark_this">Añadir a la página de inicio</string>
<string name="web_remove_this_bookmark">Quitar de la página de inicio</string>
<string name="browser_search_engine">Buscador</string>
<string name="browser_bookmarks">Marcadores</string>
<string name="browser_bookmarks_note">Se muestran como mosaicos en la página de inicio del navegador. También puedes compartir una página con Gearslip desde cualquier navegador.</string>
<string name="browser_add_bookmark">Añadir marcador</string>
<string name="browser_bookmark_name">Nombre</string>
<string name="browser_bookmark_address">Dirección web</string>
<string name="browser_remove">Quitar</string>
<string name="browser_adblock">Bloquear anuncios y rastreadores</string>
<string name="browser_adblock_note">Usa listas de filtros que se actualizan cada semana por Wi-Fi desde los sitios que las publican.</string>
<string name="browser_youtube">Bloquear anuncios de vídeos de YouTube</string>
<string name="browser_youtube_note">Salta los anuncios dentro de los vídeos. YouTube cambia a menudo, así que desactívalo si los vídeos dejan de reproducirse.</string>
<string name="browser_filter_lists">Listas de filtros</string>
<string name="browser_add_list">Añadir lista de filtros</string>
<string name="browser_list_address">Dirección de la lista</string>
<string name="browser_list_note">Cualquier lista en formato Adblock Plus, como una para tu idioma o región.</string>
<string name="browser_update_lists">Actualizar listas ahora</string>
<string name="browser_lists_status">%1$d reglas, actualizadas %2$s</string>
<string name="browser_lists_never">%1$d reglas, de la copia incluida en Gearslip</string>
<string name="browser_updating">Actualizando…</string>
<string name="browser_update_failed">No se pudo actualizar. %1$s</string>
<string name="browser_shared_added">Añadido a la página de inicio de Gearslip</string>
<string name="browser_shared_not_url">Eso no es una dirección web</string>
<string name="browser_share_label">Inicio de Gearslip</string>
<string name="summary_adblock_on">Anuncios bloqueados</string>
<string name="summary_adblock_off">Anuncios sin bloquear</string>
</resources>
+34 -2
View File
@@ -211,7 +211,7 @@
<string name="car_vehicle_data_note">Ajoute une tuile Données du véhicule : un relevé en direct des capteurs de la voiture. Les noms des champs sont des suppositions tant que vous n’en avez pas vu un changer.</string>
<string name="car_adaptive_video">Qualité vidéo adaptative</string>
<string name="car_adaptive_video_note">Baisse la qualité de l’image quand la voiture ou son Wi-Fi ne suit pas, et la remonte dès que possible. S’applique à la prochaine connexion.</string>
<string name="web_page_note">Affichée par l’appli Navigateur dans la voiture. Vide, elle ouvre DuckDuckGo. S’applique à la prochaine connexion.</string>
<string name="web_page_note">Affichée par l’appli Navigateur dans la voiture. Vide, elle ouvre la page d’accueil avec vos favoris. S’applique à la prochaine connexion.</string>
<string name="car_audio">Audio de la voiture</string>
<string name="car_audio_bt">Bluetooth (recommandé)</string>
<string name="car_audio_bt_sub">Aucune demande à chaque trajet, et la voix de votre appli de navigation arrive dans la voiture</string>
@@ -437,7 +437,7 @@
<string name="carset_build">Build</string>
<string name="carset_whats_new_after">Nouveautés après les mises à jour</string>
<string name="carset_whats_new_after_sub">Afficher la liste des changements une fois, la première fois que la voiture se connecte après une mise à jour.</string>
<string name="carset_web_default">DuckDuckGo (par défaut) - touchez pour choisir une autre page</string>
<string name="carset_web_default">Page d’accueil (par défaut) - touchez pour choisir une page</string>
<string name="carset_web_note">Affichée par l’appli Navigateur du lanceur. S’applique à la prochaine connexion.</string>
<string name="carset_screen_fit">Ajustement de l’écran</string>
<string name="carset_top">Haut</string>
@@ -918,4 +918,36 @@
<string name="density_custom_title">Densité personnalisée</string>
<string name="density_custom_label">DPI</string>
<string name="density_custom_note">Toute valeur de %1$d à %2$d. Plus bas affiche plus de choses à l’écran, plus haut agrandit tout.</string>
<string name="web_add_bookmark">Ajouter</string>
<string name="web_done">OK</string>
<string name="web_remove_bookmark">Retirer %1$s</string>
<string name="web_bookmark_added">Ajouté à la page d’accueil</string>
<string name="web_bookmark_removed">Retiré de la page d’accueil</string>
<string name="web_bookmark_this">Ajouter à la page d’accueil</string>
<string name="web_remove_this_bookmark">Retirer de la page d’accueil</string>
<string name="browser_search_engine">Moteur de recherche</string>
<string name="browser_bookmarks">Favoris</string>
<string name="browser_bookmarks_note">Affichés en tuiles sur la page d’accueil du navigateur. Vous pouvez aussi partager une page vers Gearslip depuis n’importe quel navigateur.</string>
<string name="browser_add_bookmark">Ajouter un favori</string>
<string name="browser_bookmark_name">Nom</string>
<string name="browser_bookmark_address">Adresse web</string>
<string name="browser_remove">Retirer</string>
<string name="browser_adblock">Bloquer les pubs et les traqueurs</string>
<string name="browser_adblock_note">Utilise des listes de filtres, mises à jour chaque semaine en Wi-Fi depuis les sites qui les publient.</string>
<string name="browser_youtube">Bloquer les pubs des vidéos YouTube</string>
<string name="browser_youtube_note">Passe les pubs dans les vidéos. YouTube change souvent : désactivez cette option si les vidéos ne se lancent plus.</string>
<string name="browser_filter_lists">Listes de filtres</string>
<string name="browser_add_list">Ajouter une liste de filtres</string>
<string name="browser_list_address">Adresse de la liste</string>
<string name="browser_list_note">Toute liste au format Adblock Plus, par exemple pour votre langue ou votre région.</string>
<string name="browser_update_lists">Mettre à jour les listes</string>
<string name="browser_lists_status">%1$d règles, mises à jour %2$s</string>
<string name="browser_lists_never">%1$d règles, depuis la copie intégrée à Gearslip</string>
<string name="browser_updating">Mise à jour…</string>
<string name="browser_update_failed">Échec de la mise à jour. %1$s</string>
<string name="browser_shared_added">Ajouté à la page d’accueil de Gearslip</string>
<string name="browser_shared_not_url">Ce n’est pas une adresse web</string>
<string name="browser_share_label">Page d’accueil Gearslip</string>
<string name="summary_adblock_on">Pubs bloquées</string>
<string name="summary_adblock_off">Pubs non bloquées</string>
</resources>
+34 -2
View File
@@ -211,7 +211,7 @@
<string name="car_vehicle_data_note">Aggiunge un riquadro Dati del veicolo: un elenco in diretta dei sensori dell’auto. I nomi dei campi sono ipotesi finché non ne vedi cambiare uno.</string>
<string name="car_adaptive_video">Qualità video adattiva</string>
<string name="car_adaptive_video_note">Abbassa la qualità dell’immagine quando l’auto o il suo Wi-Fi non riescono a stare al passo, e la rialza appena possibile. Vale dalla prossima connessione.</string>
<string name="web_page_note">Mostrata dall’app Browser nell’auto. Se vuota apre DuckDuckGo. Vale dalla prossima connessione.</string>
<string name="web_page_note">Mostrata dall’app Browser in auto. Se vuota, apre la pagina iniziale con i tuoi segnalibri. Si applica alla prossima connessione.</string>
<string name="car_audio">Audio dell’auto</string>
<string name="car_audio_bt">Bluetooth (consigliato)</string>
<string name="car_audio_bt_sub">Nessuna richiesta a ogni viaggio, e la voce dell’app di navigazione arriva all’auto</string>
@@ -437,7 +437,7 @@
<string name="carset_build">Build</string>
<string name="carset_whats_new_after">Novità dopo gli aggiornamenti</string>
<string name="carset_whats_new_after_sub">Mostra l’elenco delle modifiche una volta, la prima volta che l’auto si connette dopo un aggiornamento.</string>
<string name="carset_web_default">DuckDuckGo (predefinito) - tocca per impostare un’altra pagina</string>
<string name="carset_web_default">Pagina iniziale (predefinita): tocca per impostare una pagina</string>
<string name="carset_web_note">Mostrata dall’app Browser nel launcher. Vale dalla prossima connessione.</string>
<string name="carset_screen_fit">Adattamento schermo</string>
<string name="carset_top">Alto</string>
@@ -918,4 +918,36 @@
<string name="density_custom_title">Densità personalizzata</string>
<string name="density_custom_label">DPI</string>
<string name="density_custom_note">Qualsiasi valore da %1$d a %2$d. Più basso mostra più cose sullo schermo, più alto ingrandisce tutto.</string>
<string name="web_add_bookmark">Aggiungi</string>
<string name="web_done">Fine</string>
<string name="web_remove_bookmark">Rimuovi %1$s</string>
<string name="web_bookmark_added">Aggiunto alla pagina iniziale</string>
<string name="web_bookmark_removed">Rimosso dalla pagina iniziale</string>
<string name="web_bookmark_this">Aggiungi alla pagina iniziale</string>
<string name="web_remove_this_bookmark">Rimuovi dalla pagina iniziale</string>
<string name="browser_search_engine">Motore di ricerca</string>
<string name="browser_bookmarks">Segnalibri</string>
<string name="browser_bookmarks_note">Mostrati come riquadri nella pagina iniziale del browser. Puoi anche condividere una pagina con Gearslip da qualsiasi browser.</string>
<string name="browser_add_bookmark">Aggiungi segnalibro</string>
<string name="browser_bookmark_name">Nome</string>
<string name="browser_bookmark_address">Indirizzo web</string>
<string name="browser_remove">Rimuovi</string>
<string name="browser_adblock">Blocca pubblicità e tracker</string>
<string name="browser_adblock_note">Usa elenchi di filtri, aggiornati ogni settimana via Wi-Fi dai siti che li pubblicano.</string>
<string name="browser_youtube">Blocca le pubblicità dei video YouTube</string>
<string name="browser_youtube_note">Salta le pubblicità nei video. YouTube cambia spesso, quindi disattivalo se i video smettono di partire.</string>
<string name="browser_filter_lists">Elenchi di filtri</string>
<string name="browser_add_list">Aggiungi elenco di filtri</string>
<string name="browser_list_address">Indirizzo dell’elenco</string>
<string name="browser_list_note">Qualsiasi elenco in formato Adblock Plus, per esempio uno per la tua lingua o regione.</string>
<string name="browser_update_lists">Aggiorna ora gli elenchi</string>
<string name="browser_lists_status">%1$d regole, aggiornate %2$s</string>
<string name="browser_lists_never">%1$d regole, dalla copia inclusa in Gearslip</string>
<string name="browser_updating">Aggiornamento…</string>
<string name="browser_update_failed">Aggiornamento non riuscito. %1$s</string>
<string name="browser_shared_added">Aggiunto alla pagina iniziale di Gearslip</string>
<string name="browser_shared_not_url">Non è un indirizzo web</string>
<string name="browser_share_label">Pagina iniziale Gearslip</string>
<string name="summary_adblock_on">Pubblicità bloccata</string>
<string name="summary_adblock_off">Pubblicità non bloccata</string>
</resources>
+34 -2
View File
@@ -211,7 +211,7 @@
<string name="car_vehicle_data_note">車両データのタイルを追加します。車のセンサーの値をリアルタイムで表示します。値が変わるのを確認するまで、項目名は推測にすぎません。</string>
<string name="car_adaptive_video">映像品質の自動調整</string>
<string name="car_adaptive_video_note">車や車の Wi-Fi が追いつかないときは画質を下げ、余裕ができたら戻します。次回の接続から有効です。</string>
<string name="web_page_note">車のブラウザアプリで表示します。空欄の場合は DuckDuckGo を開きます。次回の接続から有効です。</string>
<string name="web_page_note">車のブラウザアプリで表示されます。空欄ならブックマーク付きのスタートページが開きます。次の接続から反映されます。</string>
<string name="car_audio">車のオーディオ</string>
<string name="car_audio_bt">Bluetooth(おすすめ)</string>
<string name="car_audio_bt_sub">運転のたびの確認がなく、地図アプリの音声も車から聞こえます</string>
@@ -435,7 +435,7 @@
<string name="carset_build">ビルド</string>
<string name="carset_whats_new_after">アップデート後の新機能</string>
<string name="carset_whats_new_after_sub">アップデート後に初めて車が接続したとき、変更点の一覧を一度表示します。</string>
<string name="carset_web_default">DuckDuckGo(デフォルト)- タップして別のページを設定</string>
<string name="carset_web_default">スタートページ(デフォルト)- タップしてページを設定</string>
<string name="carset_web_note">ランチャーのブラウザアプリで表示します。次回の接続から有効です。</string>
<string name="carset_screen_fit">画面の合わせ方</string>
<string name="carset_top">上</string>
@@ -898,4 +898,36 @@
<string name="density_custom_title">カスタム密度</string>
<string name="density_custom_label">DPI</string>
<string name="density_custom_note">%1$d~%2$d の値を入力します。小さいほど画面に多く表示され、大きいほどすべてが大きくなります。</string>
<string name="web_add_bookmark">追加</string>
<string name="web_done">完了</string>
<string name="web_remove_bookmark">%1$s を削除</string>
<string name="web_bookmark_added">スタートページに追加しました</string>
<string name="web_bookmark_removed">スタートページから削除しました</string>
<string name="web_bookmark_this">スタートページに追加</string>
<string name="web_remove_this_bookmark">スタートページから削除</string>
<string name="browser_search_engine">検索エンジン</string>
<string name="browser_bookmarks">ブックマーク</string>
<string name="browser_bookmarks_note">ブラウザのスタートページにタイルで表示されます。どのブラウザからでもページを Gearslip に共有して追加できます。</string>
<string name="browser_add_bookmark">ブックマークを追加</string>
<string name="browser_bookmark_name">名前</string>
<string name="browser_bookmark_address">ウェブアドレス</string>
<string name="browser_remove">削除</string>
<string name="browser_adblock">広告とトラッカーをブロック</string>
<string name="browser_adblock_note">フィルタリストを使います。リストは毎週 Wi-Fi で公開元のサイトから更新されます。</string>
<string name="browser_youtube">YouTube の動画広告をブロック</string>
<string name="browser_youtube_note">動画内の広告をスキップします。YouTube はよく変わるので、動画が再生されなくなったらオフにしてください。</string>
<string name="browser_filter_lists">フィルタリスト</string>
<string name="browser_add_list">フィルタリストを追加</string>
<string name="browser_list_address">リストのアドレス</string>
<string name="browser_list_note">Adblock Plus 形式のリストなら何でも使えます。言語や地域向けのリストなど。</string>
<string name="browser_update_lists">今すぐリストを更新</string>
<string name="browser_lists_status">%1$d 件のルール、%2$s に更新</string>
<string name="browser_lists_never">%1$d 件のルール、Gearslip 内蔵のコピー</string>
<string name="browser_updating">更新しています…</string>
<string name="browser_update_failed">更新できませんでした。%1$s</string>
<string name="browser_shared_added">Gearslip のスタートページに追加しました</string>
<string name="browser_shared_not_url">ウェブアドレスではありません</string>
<string name="browser_share_label">Gearslip スタートページ</string>
<string name="summary_adblock_on">広告をブロック中</string>
<string name="summary_adblock_off">広告をブロックしない</string>
</resources>
+34 -2
View File
@@ -211,7 +211,7 @@
<string name="car_vehicle_data_note">차량 데이터 타일을 추가합니다. 차량 센서 값을 실시간으로 보여 줍니다. 값이 바뀌는 것을 직접 보기 전까지 항목 이름은 추측입니다.</string>
<string name="car_adaptive_video">적응형 영상 품질</string>
<string name="car_adaptive_video_note">차량이나 차량의 Wi-Fi가 따라가지 못하면 화질을 낮추고, 여유가 생기면 다시 높입니다. 다음 연결부터 적용됩니다.</string>
<string name="web_page_note">차량의 브라우저 앱에서 표시됩니다. 비워 두면 DuckDuckGo가 열립니다. 다음 연결부터 적용됩니다.</string>
<string name="web_page_note">차량의 브라우저 앱에 표시됩니다. 비워 두면 북마크가 있는 시작 페이지가 열립니다. 다음 연결부터 적용됩니다.</string>
<string name="car_audio">차량 오디오</string>
<string name="car_audio_bt">블루투스(권장)</string>
<string name="car_audio_bt_sub">운전할 때마다 묻지 않으며, 지도 앱의 음성도 차량에서 들립니다</string>
@@ -435,7 +435,7 @@
<string name="carset_build">빌드</string>
<string name="carset_whats_new_after">업데이트 후 새로운 기능</string>
<string name="carset_whats_new_after_sub">업데이트 후 차량이 처음 연결될 때 변경 사항 목록을 한 번 보여 줍니다.</string>
<string name="carset_web_default">DuckDuckGo(기본값) - 탭하여 다른 페이지 설정</string>
<string name="carset_web_default">시작 페이지(기본값) - 탭하여 페이지 설정</string>
<string name="carset_web_note">런처의 브라우저 앱에서 표시됩니다. 다음 연결부터 적용됩니다.</string>
<string name="carset_screen_fit">화면 맞춤</string>
<string name="carset_top">위</string>
@@ -898,4 +898,36 @@
<string name="density_custom_title">사용자 지정 밀도</string>
<string name="density_custom_label">DPI</string>
<string name="density_custom_note">%1$d~%2$d 사이의 값을 입력하세요. 낮을수록 화면에 더 많이 표시되고, 높을수록 모든 것이 커집니다.</string>
<string name="web_add_bookmark">추가</string>
<string name="web_done">완료</string>
<string name="web_remove_bookmark">%1$s 삭제</string>
<string name="web_bookmark_added">시작 페이지에 추가됨</string>
<string name="web_bookmark_removed">시작 페이지에서 삭제됨</string>
<string name="web_bookmark_this">시작 페이지에 추가</string>
<string name="web_remove_this_bookmark">시작 페이지에서 삭제</string>
<string name="browser_search_engine">검색 엔진</string>
<string name="browser_bookmarks">북마크</string>
<string name="browser_bookmarks_note">브라우저 시작 페이지에 타일로 표시됩니다. 어떤 브라우저에서든 페이지를 Gearslip으로 공유해 추가할 수도 있습니다.</string>
<string name="browser_add_bookmark">북마크 추가</string>
<string name="browser_bookmark_name">이름</string>
<string name="browser_bookmark_address">웹 주소</string>
<string name="browser_remove">삭제</string>
<string name="browser_adblock">광고 및 추적기 차단</string>
<string name="browser_adblock_note">필터 목록을 사용하며, 목록은 매주 Wi-Fi로 게시하는 사이트에서 업데이트됩니다.</string>
<string name="browser_youtube">YouTube 동영상 광고 차단</string>
<string name="browser_youtube_note">동영상 속 광고를 건너뜁니다. YouTube는 자주 바뀌므로 동영상이 재생되지 않으면 끄세요.</string>
<string name="browser_filter_lists">필터 목록</string>
<string name="browser_add_list">필터 목록 추가</string>
<string name="browser_list_address">목록 주소</string>
<string name="browser_list_note">Adblock Plus 형식의 목록이면 무엇이든 됩니다. 사용하는 언어나 지역용 목록 등.</string>
<string name="browser_update_lists">지금 목록 업데이트</string>
<string name="browser_lists_status">규칙 %1$d개, %2$s 업데이트</string>
<string name="browser_lists_never">규칙 %1$d개, Gearslip에 포함된 사본</string>
<string name="browser_updating">업데이트 중…</string>
<string name="browser_update_failed">업데이트하지 못했습니다. %1$s</string>
<string name="browser_shared_added">Gearslip 시작 페이지에 추가됨</string>
<string name="browser_shared_not_url">웹 주소가 아닙니다</string>
<string name="browser_share_label">Gearslip 시작 페이지</string>
<string name="summary_adblock_on">광고 차단 중</string>
<string name="summary_adblock_off">광고 차단 안 함</string>
</resources>
+34 -2
View File
@@ -211,7 +211,7 @@
<string name="car_vehicle_data_note">Voegt een tegel Voertuiggegevens toe: een live overzicht van de sensoren van de auto. De veldnamen zijn gissingen tot je er een hebt zien veranderen.</string>
<string name="car_adaptive_video">Adaptieve videokwaliteit</string>
<string name="car_adaptive_video_note">Verlaagt de beeldkwaliteit als de auto of zijn wifi het niet bijhoudt, en verhoogt die weer zodra het kan. Geldt vanaf de volgende verbinding.</string>
<string name="web_page_note">Getoond door de Browser-app in de auto. Leeg opent DuckDuckGo. Geldt vanaf de volgende verbinding.</string>
<string name="web_page_note">Getoond door de Browser-app in de auto. Leeg opent de startpagina met je bladwijzers. Geldt vanaf de volgende verbinding.</string>
<string name="car_audio">Autogeluid</string>
<string name="car_audio_bt">Bluetooth (aanbevolen)</string>
<string name="car_audio_bt_sub">Geen vraag bij elke rit, en de stem van je kaarten-app komt in de auto</string>
@@ -436,7 +436,7 @@
<string name="carset_build">Build</string>
<string name="carset_whats_new_after">Nieuw na updates</string>
<string name="carset_whats_new_after_sub">Toon de lijst met wijzigingen één keer, de eerste keer dat de auto na een update verbindt.</string>
<string name="carset_web_default">DuckDuckGo (standaard) - tik om een andere pagina in te stellen</string>
<string name="carset_web_default">Startpagina (standaard) - tik om een pagina in te stellen</string>
<string name="carset_web_note">Getoond door de Browser-app in de launcher. Geldt vanaf de volgende verbinding.</string>
<string name="carset_screen_fit">Schermpassing</string>
<string name="carset_top">Boven</string>
@@ -908,4 +908,36 @@
<string name="density_custom_title">Aangepaste dichtheid</string>
<string name="density_custom_label">DPI</string>
<string name="density_custom_note">Elke waarde van %1$d tot %2$d. Lager past meer op het scherm, hoger maakt alles groter.</string>
<string name="web_add_bookmark">Toevoegen</string>
<string name="web_done">Klaar</string>
<string name="web_remove_bookmark">%1$s verwijderen</string>
<string name="web_bookmark_added">Toegevoegd aan de startpagina</string>
<string name="web_bookmark_removed">Verwijderd van de startpagina</string>
<string name="web_bookmark_this">Toevoegen aan de startpagina</string>
<string name="web_remove_this_bookmark">Verwijderen van de startpagina</string>
<string name="browser_search_engine">Zoekmachine</string>
<string name="browser_bookmarks">Bladwijzers</string>
<string name="browser_bookmarks_note">Verschijnen als tegels op de startpagina van de browser. Je kunt ook vanuit elke browser een pagina met Gearslip delen.</string>
<string name="browser_add_bookmark">Bladwijzer toevoegen</string>
<string name="browser_bookmark_name">Naam</string>
<string name="browser_bookmark_address">Webadres</string>
<string name="browser_remove">Verwijderen</string>
<string name="browser_adblock">Advertenties en trackers blokkeren</string>
<string name="browser_adblock_note">Gebruikt filterlijsten, wekelijks via wifi bijgewerkt vanaf de sites die ze publiceren.</string>
<string name="browser_youtube">Videoadvertenties op YouTube blokkeren</string>
<string name="browser_youtube_note">Slaat advertenties in video’s over. YouTube verandert vaak, dus zet dit uit als video’s niet meer afspelen.</string>
<string name="browser_filter_lists">Filterlijsten</string>
<string name="browser_add_list">Filterlijst toevoegen</string>
<string name="browser_list_address">Adres van de lijst</string>
<string name="browser_list_note">Elke lijst in Adblock Plus-formaat, zoals een voor je taal of regio.</string>
<string name="browser_update_lists">Lijsten nu bijwerken</string>
<string name="browser_lists_status">%1$d regels, bijgewerkt %2$s</string>
<string name="browser_lists_never">%1$d regels, uit de kopie in Gearslip</string>
<string name="browser_updating">Bijwerken…</string>
<string name="browser_update_failed">Bijwerken mislukt. %1$s</string>
<string name="browser_shared_added">Toegevoegd aan de startpagina van Gearslip</string>
<string name="browser_shared_not_url">Dat is geen webadres</string>
<string name="browser_share_label">Gearslip-startpagina</string>
<string name="summary_adblock_on">Advertenties geblokkeerd</string>
<string name="summary_adblock_off">Advertenties niet geblokkeerd</string>
</resources>
+34 -2
View File
@@ -211,7 +211,7 @@
<string name="car_vehicle_data_note">Dodaje kafelek Dane pojazdu: odczyt na żywo z czujników samochodu. Nazwy pól to domysły, dopóki nie zobaczysz, jak któreś się zmienia.</string>
<string name="car_adaptive_video">Adaptacyjna jakość obrazu</string>
<string name="car_adaptive_video_note">Obniża jakość obrazu, gdy samochód lub jego Wi-Fi nie nadąża, i podnosi ją, gdy znów może. Działa od następnego połączenia.</string>
<string name="web_page_note">Pokazywana przez aplikację Przeglądarka w samochodzie. Puste otwiera DuckDuckGo. Działa od następnego połączenia.</string>
<string name="web_page_note">Wyświetlana przez aplikację Przeglądarka w samochodzie. Puste otwiera stronę startową z zakładkami. Obowiązuje od następnego połączenia.</string>
<string name="car_audio">Dźwięk w samochodzie</string>
<string name="car_audio_bt">Bluetooth (zalecane)</string>
<string name="car_audio_bt_sub">Bez pytania przy każdej jeździe, a głos aplikacji z mapami trafia do samochodu</string>
@@ -438,7 +438,7 @@
<string name="carset_build">Kompilacja</string>
<string name="carset_whats_new_after">Co nowego po aktualizacjach</string>
<string name="carset_whats_new_after_sub">Pokaż listę zmian raz, przy pierwszym połączeniu samochodu po aktualizacji.</string>
<string name="carset_web_default">DuckDuckGo (domyślna) - kliknij, by ustawić inną stronę</string>
<string name="carset_web_default">Strona startowa (domyślna) - dotknij, aby ustawić stronę</string>
<string name="carset_web_note">Pokazywana przez aplikację Przeglądarka w launcherze. Działa od następnego połączenia.</string>
<string name="carset_screen_fit">Dopasowanie ekranu</string>
<string name="carset_top">Góra</string>
@@ -928,4 +928,36 @@
<string name="density_custom_title">Własna gęstość</string>
<string name="density_custom_label">DPI</string>
<string name="density_custom_note">Dowolna wartość od %1$d do %2$d. Niższa mieści więcej na ekranie, wyższa wszystko powiększa.</string>
<string name="web_add_bookmark">Dodaj</string>
<string name="web_done">Gotowe</string>
<string name="web_remove_bookmark">Usuń %1$s</string>
<string name="web_bookmark_added">Dodano do strony startowej</string>
<string name="web_bookmark_removed">Usunięto ze strony startowej</string>
<string name="web_bookmark_this">Dodaj do strony startowej</string>
<string name="web_remove_this_bookmark">Usuń ze strony startowej</string>
<string name="browser_search_engine">Wyszukiwarka</string>
<string name="browser_bookmarks">Zakładki</string>
<string name="browser_bookmarks_note">Widoczne jako kafelki na stronie startowej przeglądarki. Możesz też udostępnić stronę do Gearslip z dowolnej przeglądarki.</string>
<string name="browser_add_bookmark">Dodaj zakładkę</string>
<string name="browser_bookmark_name">Nazwa</string>
<string name="browser_bookmark_address">Adres strony</string>
<string name="browser_remove">Usuń</string>
<string name="browser_adblock">Blokuj reklamy i śledzenie</string>
<string name="browser_adblock_note">Korzysta z list filtrów, odświeżanych co tydzień przez Wi-Fi ze stron, które je publikują.</string>
<string name="browser_youtube">Blokuj reklamy w filmach YouTube</string>
<string name="browser_youtube_note">Pomija reklamy w filmach. YouTube często się zmienia, więc wyłącz to, jeśli filmy przestaną się odtwarzać.</string>
<string name="browser_filter_lists">Listy filtrów</string>
<string name="browser_add_list">Dodaj listę filtrów</string>
<string name="browser_list_address">Adres listy</string>
<string name="browser_list_note">Dowolna lista w formacie Adblock Plus, na przykład dla Twojego języka lub regionu.</string>
<string name="browser_update_lists">Zaktualizuj listy teraz</string>
<string name="browser_lists_status">Reguły: %1$d, zaktualizowano %2$s</string>
<string name="browser_lists_never">Reguły: %1$d, z kopii wbudowanej w Gearslip</string>
<string name="browser_updating">Aktualizowanie…</string>
<string name="browser_update_failed">Nie udało się zaktualizować. %1$s</string>
<string name="browser_shared_added">Dodano do strony startowej Gearslip</string>
<string name="browser_shared_not_url">To nie jest adres strony</string>
<string name="browser_share_label">Strona startowa Gearslip</string>
<string name="summary_adblock_on">Reklamy blokowane</string>
<string name="summary_adblock_off">Reklamy nieblokowane</string>
</resources>
@@ -211,7 +211,7 @@
<string name="car_vehicle_data_note">Adiciona um bloco Dados do veículo: uma leitura ao vivo dos sensores do carro. Os nomes dos campos são palpites até você ver um deles mudar.</string>
<string name="car_adaptive_video">Qualidade de vídeo adaptável</string>
<string name="car_adaptive_video_note">Reduz a qualidade da imagem quando o carro ou o Wi-Fi dele não acompanha, e aumenta de novo quando consegue. Vale na próxima conexão.</string>
<string name="web_page_note">Mostrada pelo app Navegador no carro. Em branco, abre o DuckDuckGo. Vale na próxima conexão.</string>
<string name="web_page_note">Mostrada pelo app Navegador no carro. Vazio abre a página inicial com seus favoritos. Vale na próxima conexão.</string>
<string name="car_audio">Áudio do carro</string>
<string name="car_audio_bt">Bluetooth (recomendado)</string>
<string name="car_audio_bt_sub">Nenhum pedido a cada viagem, e a voz do seu app de mapas chega ao carro</string>
@@ -437,7 +437,7 @@
<string name="carset_build">Build</string>
<string name="carset_whats_new_after">Novidades após atualizações</string>
<string name="carset_whats_new_after_sub">Mostrar a lista de mudanças uma vez, na primeira conexão do carro após uma atualização.</string>
<string name="carset_web_default">DuckDuckGo (padrão) - toque para definir outra página</string>
<string name="carset_web_default">Página inicial (padrão) - toque para definir uma página</string>
<string name="carset_web_note">Mostrada pelo app Navegador no launcher. Vale na próxima conexão.</string>
<string name="carset_screen_fit">Ajuste da tela</string>
<string name="carset_top">Topo</string>
@@ -918,4 +918,36 @@
<string name="density_custom_title">Densidade personalizada</string>
<string name="density_custom_label">DPI</string>
<string name="density_custom_note">Qualquer valor de %1$d a %2$d. Menor cabe mais na tela; maior deixa tudo maior.</string>
<string name="web_add_bookmark">Adicionar</string>
<string name="web_done">Concluir</string>
<string name="web_remove_bookmark">Remover %1$s</string>
<string name="web_bookmark_added">Adicionado à página inicial</string>
<string name="web_bookmark_removed">Removido da página inicial</string>
<string name="web_bookmark_this">Adicionar à página inicial</string>
<string name="web_remove_this_bookmark">Remover da página inicial</string>
<string name="browser_search_engine">Mecanismo de busca</string>
<string name="browser_bookmarks">Favoritos</string>
<string name="browser_bookmarks_note">Aparecem como blocos na página inicial do navegador. Você também pode compartilhar uma página com o Gearslip de qualquer navegador.</string>
<string name="browser_add_bookmark">Adicionar favorito</string>
<string name="browser_bookmark_name">Nome</string>
<string name="browser_bookmark_address">Endereço da web</string>
<string name="browser_remove">Remover</string>
<string name="browser_adblock">Bloquear anúncios e rastreadores</string>
<string name="browser_adblock_note">Usa listas de filtros, atualizadas toda semana pelo Wi-Fi a partir dos sites que as publicam.</string>
<string name="browser_youtube">Bloquear anúncios de vídeos do YouTube</string>
<string name="browser_youtube_note">Pula os anúncios dentro dos vídeos. O YouTube muda com frequência, então desative se os vídeos pararem de tocar.</string>
<string name="browser_filter_lists">Listas de filtros</string>
<string name="browser_add_list">Adicionar lista de filtros</string>
<string name="browser_list_address">Endereço da lista</string>
<string name="browser_list_note">Qualquer lista no formato Adblock Plus, como uma para o seu idioma ou região.</string>
<string name="browser_update_lists">Atualizar listas agora</string>
<string name="browser_lists_status">%1$d regras, atualizadas %2$s</string>
<string name="browser_lists_never">%1$d regras, da cópia incluída no Gearslip</string>
<string name="browser_updating">Atualizando…</string>
<string name="browser_update_failed">Não foi possível atualizar. %1$s</string>
<string name="browser_shared_added">Adicionado à página inicial do Gearslip</string>
<string name="browser_shared_not_url">Isso não é um endereço da web</string>
<string name="browser_share_label">Página inicial do Gearslip</string>
<string name="summary_adblock_on">Anúncios bloqueados</string>
<string name="summary_adblock_off">Anúncios não bloqueados</string>
</resources>
+34 -2
View File
@@ -199,7 +199,7 @@
<string name="car_vehicle_data_note">Adaugă o casetă cu date despre vehicul: o afișare în timp real a datelor de la senzorii mașinii. Numele câmpurilor sunt doar presupuneri până când observi o schimbare a unuia dintre ele.</string>
<string name="car_adaptive_video">Calitate video adaptivă</string>
<string name="car_adaptive_video_note">Reduce calitatea imaginii atunci când mașina sau rețeaua Wi-Fi a acesteia nu pot face față, o îmbunătățește din nou odată ce acestea revin la normal. Se aplică la următoarea conexiune.</string>
<string name="web_page_note">Afișat de aplicația Browser din mașină. Dacă este gol, se deschide DuckDuckGo. Se aplică la următoarea conexiune.</string>
<string name="web_page_note">Afișată de aplicația Browser din mașină. Gol deschide pagina de start cu marcajele tale. Se aplică la următoarea conectare.</string>
<string name="car_audio">Sunet în automobil</string>
<string name="car_audio_bt">Bluetooth (recomandat)</string>
<string name="car_audio_bt_sub">Nu mai primești nicio indicație la fiecare schimbare de direcție, iar vocea aplicației de hărți se aude direct în mașină</string>
@@ -385,7 +385,7 @@
<string name="carset_build">Asamblare</string>
<string name="carset_whats_new_after">Afișează noutățile după actualizări</string>
<string name="carset_whats_new_after_sub">Afișează lista modificărilor o singură dată, la prima conectare a mașinii după o actualizare.</string>
<string name="carset_web_default">DuckDuckGo (implicit) – atinge pentru a seta o altă pagină</string>
<string name="carset_web_default">Pagina de start (implicită) - atinge pentru a seta o pagină</string>
<string name="carset_web_note">Se afișează în aplicația Browser din meniul de lansare. Se aplică la următoarea conexiune.</string>
<string name="carset_screen_fit">Potrivire ecran</string>
<string name="carset_top">Sus</string>
@@ -800,4 +800,36 @@
<string name="density_custom_title">Densitate personalizată</string>
<string name="density_custom_label">DPI</string>
<string name="density_custom_note">Orice valoare între %1$d și %2$d. Mai mică încape mai mult pe ecran, mai mare mărește totul.</string>
<string name="web_add_bookmark">Adaugă</string>
<string name="web_done">Gata</string>
<string name="web_remove_bookmark">Elimină %1$s</string>
<string name="web_bookmark_added">Adăugat pe pagina de start</string>
<string name="web_bookmark_removed">Eliminat de pe pagina de start</string>
<string name="web_bookmark_this">Adaugă pe pagina de start</string>
<string name="web_remove_this_bookmark">Elimină de pe pagina de start</string>
<string name="browser_search_engine">Motor de căutare</string>
<string name="browser_bookmarks">Marcaje</string>
<string name="browser_bookmarks_note">Apar ca dale pe pagina de start a browserului. Poți și să trimiți o pagină către Gearslip din orice browser.</string>
<string name="browser_add_bookmark">Adaugă marcaj</string>
<string name="browser_bookmark_name">Nume</string>
<string name="browser_bookmark_address">Adresă web</string>
<string name="browser_remove">Elimină</string>
<string name="browser_adblock">Blochează reclamele și urmăritorii</string>
<string name="browser_adblock_note">Folosește liste de filtre, actualizate săptămânal prin Wi-Fi de pe site-urile care le publică.</string>
<string name="browser_youtube">Blochează reclamele din videoclipurile YouTube</string>
<string name="browser_youtube_note">Sare peste reclamele din videoclipuri. YouTube se schimbă des, așa că dezactivează opțiunea dacă videoclipurile nu mai pornesc.</string>
<string name="browser_filter_lists">Liste de filtre</string>
<string name="browser_add_list">Adaugă listă de filtre</string>
<string name="browser_list_address">Adresa listei</string>
<string name="browser_list_note">Orice listă în format Adblock Plus, de exemplu una pentru limba sau regiunea ta.</string>
<string name="browser_update_lists">Actualizează listele acum</string>
<string name="browser_lists_status">%1$d reguli, actualizate %2$s</string>
<string name="browser_lists_never">%1$d reguli, din copia inclusă în Gearslip</string>
<string name="browser_updating">Se actualizează…</string>
<string name="browser_update_failed">Actualizarea nu a reușit. %1$s</string>
<string name="browser_shared_added">Adăugat pe pagina de start Gearslip</string>
<string name="browser_shared_not_url">Aceasta nu este o adresă web</string>
<string name="browser_share_label">Pagina de start Gearslip</string>
<string name="summary_adblock_on">Reclame blocate</string>
<string name="summary_adblock_off">Reclame neblocate</string>
</resources>
+34 -2
View File
@@ -211,7 +211,7 @@
<string name="car_vehicle_data_note">Добавляет плитку «Данные автомобиля»: живой вывод датчиков машины. Названия полей остаются догадками, пока вы не увидите, как одно из них меняется.</string>
<string name="car_adaptive_video">Адаптивное качество видео</string>
<string name="car_adaptive_video_note">Снижает качество картинки, когда машина или её Wi-Fi не успевают, и снова повышает, когда могут. Действует со следующего подключения.</string>
<string name="web_page_note">Показывается приложением «Браузер» в машине. Если пусто, открывается DuckDuckGo. Действует со следующего подключения.</string>
<string name="web_page_note">Показывается приложением «Браузер» в машине. Если пусто, открывается стартовая страница с закладками. Применяется при следующем подключении.</string>
<string name="car_audio">Звук в машине</string>
<string name="car_audio_bt">Bluetooth (рекомендуется)</string>
<string name="car_audio_bt_sub">Никаких запросов в каждой поездке, а голос навигатора звучит в машине</string>
@@ -438,7 +438,7 @@
<string name="carset_build">Сборка</string>
<string name="carset_whats_new_after">Что нового после обновлений</string>
<string name="carset_whats_new_after_sub">Показать список изменений один раз, при первом подключении машины после обновления.</string>
<string name="carset_web_default">DuckDuckGo (по умолчанию) - нажмите, чтобы задать другую страницу</string>
<string name="carset_web_default">Стартовая страница (по умолчанию) — нажмите, чтобы указать страницу</string>
<string name="carset_web_note">Показывается приложением «Браузер» в лаунчере. Действует со следующего подключения.</string>
<string name="carset_screen_fit">Подгонка экрана</string>
<string name="carset_top">Сверху</string>
@@ -928,4 +928,36 @@
<string name="density_custom_title">Своя плотность</string>
<string name="density_custom_label">DPI</string>
<string name="density_custom_note">Любое значение от %1$d до %2$d. Меньше — больше помещается на экране, больше — всё крупнее.</string>
<string name="web_add_bookmark">Добавить</string>
<string name="web_done">Готово</string>
<string name="web_remove_bookmark">Удалить %1$s</string>
<string name="web_bookmark_added">Добавлено на стартовую страницу</string>
<string name="web_bookmark_removed">Удалено со стартовой страницы</string>
<string name="web_bookmark_this">Добавить на стартовую страницу</string>
<string name="web_remove_this_bookmark">Удалить со стартовой страницы</string>
<string name="browser_search_engine">Поисковая система</string>
<string name="browser_bookmarks">Закладки</string>
<string name="browser_bookmarks_note">Показываются плитками на стартовой странице браузера. Страницу можно также отправить в Gearslip из любого браузера.</string>
<string name="browser_add_bookmark">Добавить закладку</string>
<string name="browser_bookmark_name">Название</string>
<string name="browser_bookmark_address">Веб-адрес</string>
<string name="browser_remove">Удалить</string>
<string name="browser_adblock">Блокировать рекламу и трекеры</string>
<string name="browser_adblock_note">Использует списки фильтров, которые раз в неделю обновляются по Wi-Fi с сайтов, где их публикуют.</string>
<string name="browser_youtube">Блокировать рекламу в видео YouTube</string>
<string name="browser_youtube_note">Пропускает рекламу внутри видео. YouTube часто меняется, поэтому выключите это, если видео перестанут воспроизводиться.</string>
<string name="browser_filter_lists">Списки фильтров</string>
<string name="browser_add_list">Добавить список фильтров</string>
<string name="browser_list_address">Адрес списка</string>
<string name="browser_list_note">Любой список в формате Adblock Plus, например для вашего языка или региона.</string>
<string name="browser_update_lists">Обновить списки</string>
<string name="browser_lists_status">Правил: %1$d, обновлено %2$s</string>
<string name="browser_lists_never">Правил: %1$d, из встроенной в Gearslip копии</string>
<string name="browser_updating">Обновление…</string>
<string name="browser_update_failed">Не удалось обновить. %1$s</string>
<string name="browser_shared_added">Добавлено на стартовую страницу Gearslip</string>
<string name="browser_shared_not_url">Это не веб-адрес</string>
<string name="browser_share_label">Стартовая страница Gearslip</string>
<string name="summary_adblock_on">Реклама блокируется</string>
<string name="summary_adblock_off">Реклама не блокируется</string>
</resources>
+34 -2
View File
@@ -211,7 +211,7 @@
<string name="car_vehicle_data_note">Lägger till en ruta Fordonsdata: en live-dump av bilens sensorer. Fältnamnen är gissningar tills du har sett ett av dem ändras.</string>
<string name="car_adaptive_video">Anpassad videokvalitet</string>
<string name="car_adaptive_video_note">Sänker bildkvaliteten när bilen eller dess wifi inte hänger med, och höjer den igen när det går. Gäller från nästa anslutning.</string>
<string name="web_page_note">Visas av appen Webbläsare i bilen. Tomt öppnar DuckDuckGo. Gäller från nästa anslutning.</string>
<string name="web_page_note">Visas av appen Webbläsare i bilen. Tomt öppnar startsidan med dina bokmärken. Gäller från nästa anslutning.</string>
<string name="car_audio">Bilens ljud</string>
<string name="car_audio_bt">Bluetooth (rekommenderas)</string>
<string name="car_audio_bt_sub">Ingen fråga varje resa, och kartappens röst når bilen</string>
@@ -436,7 +436,7 @@
<string name="carset_build">Bygge</string>
<string name="carset_whats_new_after">Nyheter efter uppdateringar</string>
<string name="carset_whats_new_after_sub">Visa listan över ändringar en gång, första gången bilen ansluter efter en uppdatering.</string>
<string name="carset_web_default">DuckDuckGo (standard) - tryck för att välja en annan sida</string>
<string name="carset_web_default">Startsida (standard) - tryck för att välja en sida</string>
<string name="carset_web_note">Visas av appen Webbläsare i startprogrammet. Gäller från nästa anslutning.</string>
<string name="carset_screen_fit">Skärmanpassning</string>
<string name="carset_top">Överst</string>
@@ -908,4 +908,36 @@
<string name="density_custom_title">Anpassad täthet</string>
<string name="density_custom_label">DPI</string>
<string name="density_custom_note">Valfritt värde från %1$d till %2$d. Lägre får plats med mer på skärmen, högre gör allt större.</string>
<string name="web_add_bookmark">Lägg till</string>
<string name="web_done">Klar</string>
<string name="web_remove_bookmark">Ta bort %1$s</string>
<string name="web_bookmark_added">Tillagd på startsidan</string>
<string name="web_bookmark_removed">Borttagen från startsidan</string>
<string name="web_bookmark_this">Lägg till på startsidan</string>
<string name="web_remove_this_bookmark">Ta bort från startsidan</string>
<string name="browser_search_engine">Sökmotor</string>
<string name="browser_bookmarks">Bokmärken</string>
<string name="browser_bookmarks_note">Visas som rutor på webbläsarens startsida. Du kan också dela en sida till Gearslip från vilken webbläsare som helst.</string>
<string name="browser_add_bookmark">Lägg till bokmärke</string>
<string name="browser_bookmark_name">Namn</string>
<string name="browser_bookmark_address">Webbadress</string>
<string name="browser_remove">Ta bort</string>
<string name="browser_adblock">Blockera annonser och spårare</string>
<string name="browser_adblock_note">Använder filterlistor som uppdateras varje vecka via wifi från de sajter som ger ut dem.</string>
<string name="browser_youtube">Blockera videoannonser på YouTube</string>
<string name="browser_youtube_note">Hoppar över annonser i videor. YouTube ändras ofta, så stäng av detta om videor slutar spelas.</string>
<string name="browser_filter_lists">Filterlistor</string>
<string name="browser_add_list">Lägg till filterlista</string>
<string name="browser_list_address">Listans adress</string>
<string name="browser_list_note">Valfri lista i Adblock Plus-format, till exempel en för ditt språk eller din region.</string>
<string name="browser_update_lists">Uppdatera listor nu</string>
<string name="browser_lists_status">%1$d regler, uppdaterade %2$s</string>
<string name="browser_lists_never">%1$d regler, från kopian i Gearslip</string>
<string name="browser_updating">Uppdaterar…</string>
<string name="browser_update_failed">Det gick inte att uppdatera. %1$s</string>
<string name="browser_shared_added">Tillagd på Gearslips startsida</string>
<string name="browser_shared_not_url">Det är ingen webbadress</string>
<string name="browser_share_label">Gearslips startsida</string>
<string name="summary_adblock_on">Annonser blockeras</string>
<string name="summary_adblock_off">Annonser blockeras inte</string>
</resources>
+34 -2
View File
@@ -211,7 +211,7 @@
<string name="car_vehicle_data_note">Araç verileri kutucuğu ekler: aracın sensörlerinin canlı dökümü. Birinin değiştiğini görene kadar alan adları tahmindir.</string>
<string name="car_adaptive_video">Uyarlanabilir video kalitesi</string>
<string name="car_adaptive_video_note">Araç veya Wi-Fi’ı yetişemediğinde görüntü kalitesini düşürür, yetişebildiğinde yeniden yükseltir. Bir sonraki bağlantıda geçerli olur.</string>
<string name="web_page_note">Araçtaki Tarayıcı uygulaması tarafından gösterilir. Boş bırakılırsa DuckDuckGo açılır. Bir sonraki bağlantıda geçerli olur.</string>
<string name="web_page_note">Araçtaki Tarayıcı uygulamasında gösterilir. Boş bırakılırsa yer işaretlerinizle başlangıç sayfası açılır. Bir sonraki bağlantıda geçerli olur.</string>
<string name="car_audio">Araç sesi</string>
<string name="car_audio_bt">Bluetooth (önerilen)</string>
<string name="car_audio_bt_sub">Her yolculukta soru yok ve harita uygulamanızın sesi araca ulaşır</string>
@@ -436,7 +436,7 @@
<string name="carset_build">Derleme</string>
<string name="carset_whats_new_after">Güncellemelerden sonra yenilikler</string>
<string name="carset_whats_new_after_sub">Bir güncellemeden sonra araç ilk bağlandığında değişiklik listesini bir kez göster.</string>
<string name="carset_web_default">DuckDuckGo (varsayılan) - başka bir sayfa ayarlamak için dokunun</string>
<string name="carset_web_default">Başlangıç sayfası (varsayılan) - bir sayfa ayarlamak için dokunun</string>
<string name="carset_web_note">Başlatıcıdaki Tarayıcı uygulaması tarafından gösterilir. Bir sonraki bağlantıda geçerli olur.</string>
<string name="carset_screen_fit">Ekran sığdırma</string>
<string name="carset_top">Üst</string>
@@ -908,4 +908,36 @@
<string name="density_custom_title">Özel yoğunluk</string>
<string name="density_custom_label">DPI</string>
<string name="density_custom_note">%1$d ile %2$d arasında herhangi bir değer. Düşük değer ekrana daha çok sığdırır, yüksek değer her şeyi büyütür.</string>
<string name="web_add_bookmark">Ekle</string>
<string name="web_done">Bitti</string>
<string name="web_remove_bookmark">%1$s kaldır</string>
<string name="web_bookmark_added">Başlangıç sayfasına eklendi</string>
<string name="web_bookmark_removed">Başlangıç sayfasından kaldırıldı</string>
<string name="web_bookmark_this">Başlangıç sayfasına ekle</string>
<string name="web_remove_this_bookmark">Başlangıç sayfasından kaldır</string>
<string name="browser_search_engine">Arama motoru</string>
<string name="browser_bookmarks">Yer işaretleri</string>
<string name="browser_bookmarks_note">Tarayıcının başlangıç sayfasında kutucuk olarak gösterilir. Herhangi bir tarayıcıdan bir sayfayı Gearslip ile de paylaşabilirsiniz.</string>
<string name="browser_add_bookmark">Yer işareti ekle</string>
<string name="browser_bookmark_name">Ad</string>
<string name="browser_bookmark_address">Web adresi</string>
<string name="browser_remove">Kaldır</string>
<string name="browser_adblock">Reklamları ve izleyicileri engelle</string>
<string name="browser_adblock_note">Filtre listeleri kullanır; listeler her hafta Wi-Fi üzerinden yayımlandıkları sitelerden yenilenir.</string>
<string name="browser_youtube">YouTube video reklamlarını engelle</string>
<string name="browser_youtube_note">Videoların içindeki reklamları atlar. YouTube sık değişir; videolar oynatılmazsa bunu kapatın.</string>
<string name="browser_filter_lists">Filtre listeleri</string>
<string name="browser_add_list">Filtre listesi ekle</string>
<string name="browser_list_address">Liste adresi</string>
<string name="browser_list_note">Adblock Plus biçimindeki herhangi bir liste, örneğin dilinize veya bölgenize özel bir liste.</string>
<string name="browser_update_lists">Listeleri şimdi güncelle</string>
<string name="browser_lists_status">%1$d kural, güncellenme: %2$s</string>
<string name="browser_lists_never">%1$d kural, Gearslip içindeki kopyadan</string>
<string name="browser_updating">Güncelleniyor…</string>
<string name="browser_update_failed">Güncellenemedi. %1$s</string>
<string name="browser_shared_added">Gearslip başlangıç sayfasına eklendi</string>
<string name="browser_shared_not_url">Bu bir web adresi değil</string>
<string name="browser_share_label">Gearslip başlangıç sayfası</string>
<string name="summary_adblock_on">Reklamlar engelleniyor</string>
<string name="summary_adblock_off">Reklamlar engellenmiyor</string>
</resources>
+34 -2
View File
@@ -211,7 +211,7 @@
<string name="car_vehicle_data_note">Додає плитку «Дані автомобіля»: живий вивід датчиків авто. Назви полів лишаються здогадками, поки ви не побачите, як одне з них змінюється.</string>
<string name="car_adaptive_video">Адаптивна якість відео</string>
<string name="car_adaptive_video_note">Знижує якість зображення, коли авто чи його Wi-Fi не встигає, і знову підвищує, коли може. Діє з наступного підключення.</string>
<string name="web_page_note">Показується застосунком «Браузер» в авто. Якщо порожньо, відкривається DuckDuckGo. Діє з наступного підключення.</string>
<string name="web_page_note">Показується застосунком «Браузер» в авто. Якщо порожньо, відкривається стартова сторінка із закладками. Діє з наступного підключення.</string>
<string name="car_audio">Звук в авто</string>
<string name="car_audio_bt">Bluetooth (рекомендовано)</string>
<string name="car_audio_bt_sub">Жодних запитів у кожній поїздці, а голос навігатора звучить в авто</string>
@@ -438,7 +438,7 @@
<string name="carset_build">Збірка</string>
<string name="carset_whats_new_after">Що нового після оновлень</string>
<string name="carset_whats_new_after_sub">Показати список змін один раз, під час першого підключення авто після оновлення.</string>
<string name="carset_web_default">DuckDuckGo (типово) - торкніться, щоб задати іншу сторінку</string>
<string name="carset_web_default">Стартова сторінка (типово) — торкніться, щоб указати сторінку</string>
<string name="carset_web_note">Показується застосунком «Браузер» у лаунчері. Діє з наступного підключення.</string>
<string name="carset_screen_fit">Припасування екрана</string>
<string name="carset_top">Згори</string>
@@ -928,4 +928,36 @@
<string name="density_custom_title">Своя щільність</string>
<string name="density_custom_label">DPI</string>
<string name="density_custom_note">Будь-яке значення від %1$d до %2$d. Менше — більше вміщується на екрані, більше — усе крупніше.</string>
<string name="web_add_bookmark">Додати</string>
<string name="web_done">Готово</string>
<string name="web_remove_bookmark">Видалити %1$s</string>
<string name="web_bookmark_added">Додано на стартову сторінку</string>
<string name="web_bookmark_removed">Видалено зі стартової сторінки</string>
<string name="web_bookmark_this">Додати на стартову сторінку</string>
<string name="web_remove_this_bookmark">Видалити зі стартової сторінки</string>
<string name="browser_search_engine">Пошукова система</string>
<string name="browser_bookmarks">Закладки</string>
<string name="browser_bookmarks_note">Показуються плитками на стартовій сторінці браузера. Сторінкою можна також поділитися з Gearslip з будь-якого браузера.</string>
<string name="browser_add_bookmark">Додати закладку</string>
<string name="browser_bookmark_name">Назва</string>
<string name="browser_bookmark_address">Вебадреса</string>
<string name="browser_remove">Видалити</string>
<string name="browser_adblock">Блокувати рекламу й трекери</string>
<string name="browser_adblock_note">Використовує списки фільтрів, які щотижня оновлюються через Wi-Fi із сайтів, де їх публікують.</string>
<string name="browser_youtube">Блокувати рекламу у відео YouTube</string>
<string name="browser_youtube_note">Пропускає рекламу у відео. YouTube часто змінюється, тож вимкніть це, якщо відео перестануть відтворюватися.</string>
<string name="browser_filter_lists">Списки фільтрів</string>
<string name="browser_add_list">Додати список фільтрів</string>
<string name="browser_list_address">Адреса списку</string>
<string name="browser_list_note">Будь-який список у форматі Adblock Plus, наприклад для вашої мови чи регіону.</string>
<string name="browser_update_lists">Оновити списки</string>
<string name="browser_lists_status">Правил: %1$d, оновлено %2$s</string>
<string name="browser_lists_never">Правил: %1$d, із вбудованої в Gearslip копії</string>
<string name="browser_updating">Оновлення…</string>
<string name="browser_update_failed">Не вдалося оновити. %1$s</string>
<string name="browser_shared_added">Додано на стартову сторінку Gearslip</string>
<string name="browser_shared_not_url">Це не вебадреса</string>
<string name="browser_share_label">Стартова сторінка Gearslip</string>
<string name="summary_adblock_on">Реклама блокується</string>
<string name="summary_adblock_off">Реклама не блокується</string>
</resources>
@@ -211,7 +211,7 @@
<string name="car_vehicle_data_note">添加一个“车辆数据”磁贴:实时显示车辆传感器的原始数据。在您亲眼看到某个值变化之前,字段名称都只是猜测。</string>
<string name="car_adaptive_video">自适应画质</string>
<string name="car_adaptive_video_note">车机或其 Wi-Fi 跟不上时降低画质,跟得上时再提高。下次连接时生效。</string>
<string name="web_page_note">由车机上的浏览器应用显示。留空则打开 DuckDuckGo。下次连接时生效。</string>
<string name="web_page_note">由车内的浏览器应用显示。留空则打开带有你书签的起始页。下次连接时生效。</string>
<string name="car_audio">车载音频</string>
<string name="car_audio_bt">蓝牙(推荐)</string>
<string name="car_audio_bt_sub">每次开车都不会询问,地图应用的语音也能在车上播放</string>
@@ -435,7 +435,7 @@
<string name="carset_build">版本号</string>
<string name="carset_whats_new_after">更新后显示新功能</string>
<string name="carset_whats_new_after_sub">更新后车辆第一次连接时,显示一次更新内容列表。</string>
<string name="carset_web_default">DuckDuckGo(默认)- 点按以设置其他页面</string>
<string name="carset_web_default">起始页(默认)- 点按以设置网页</string>
<string name="carset_web_note">由启动器中的浏览器应用显示。下次连接时生效。</string>
<string name="carset_screen_fit">屏幕适配</string>
<string name="carset_top">顶部</string>
@@ -898,4 +898,36 @@
<string name="density_custom_title">自定义密度</string>
<string name="density_custom_label">DPI</string>
<string name="density_custom_note">输入 %1$d 到 %2$d 之间的任意值。数值越小,屏幕上显示的内容越多;越大,所有内容越大。</string>
<string name="web_add_bookmark">添加</string>
<string name="web_done">完成</string>
<string name="web_remove_bookmark">移除 %1$s</string>
<string name="web_bookmark_added">已添加到起始页</string>
<string name="web_bookmark_removed">已从起始页移除</string>
<string name="web_bookmark_this">添加到起始页</string>
<string name="web_remove_this_bookmark">从起始页移除</string>
<string name="browser_search_engine">搜索引擎</string>
<string name="browser_bookmarks">书签</string>
<string name="browser_bookmarks_note">以图块形式显示在浏览器的起始页上。你也可以从任何浏览器把网页分享到 Gearslip。</string>
<string name="browser_add_bookmark">添加书签</string>
<string name="browser_bookmark_name">名称</string>
<string name="browser_bookmark_address">网址</string>
<string name="browser_remove">移除</string>
<string name="browser_adblock">拦截广告和跟踪器</string>
<string name="browser_adblock_note">使用过滤列表,每周通过 Wi-Fi 从发布它们的网站更新。</string>
<string name="browser_youtube">拦截 YouTube 视频广告</string>
<string name="browser_youtube_note">跳过视频中的广告。YouTube 经常变化,如果视频无法播放,请关闭此项。</string>
<string name="browser_filter_lists">过滤列表</string>
<string name="browser_add_list">添加过滤列表</string>
<string name="browser_list_address">列表网址</string>
<string name="browser_list_note">任何 Adblock Plus 格式的列表,例如针对你的语言或地区的列表。</string>
<string name="browser_update_lists">立即更新列表</string>
<string name="browser_lists_status">%1$d 条规则,%2$s更新</string>
<string name="browser_lists_never">%1$d 条规则,来自 Gearslip 内置的副本</string>
<string name="browser_updating">正在更新…</string>
<string name="browser_update_failed">无法更新。%1$s</string>
<string name="browser_shared_added">已添加到 Gearslip 起始页</string>
<string name="browser_shared_not_url">这不是网址</string>
<string name="browser_share_label">Gearslip 起始页</string>
<string name="summary_adblock_on">拦截广告</string>
<string name="summary_adblock_off">不拦截广告</string>
</resources>
+34 -2
View File
@@ -227,7 +227,7 @@
<string name="car_vehicle_data_note">Adds a Vehicle data tile: a live dump of the car\'s sensors. The field names are guesses until you\'ve watched one change.</string>
<string name="car_adaptive_video">Adaptive video quality</string>
<string name="car_adaptive_video_note">Lowers the picture\'s quality when the car or its Wi-Fi can\'t keep up, and raises it again once it can. Applies on the next connection.</string>
<string name="web_page_note">Shown by the Browser app in the car. Empty opens DuckDuckGo. Applies on the next connection.</string>
<string name="web_page_note">Shown by the Browser app in the car. Empty opens the start page with your bookmarks. Applies on the next connection.</string>
<string name="car_audio">Car audio</string>
<string name="car_audio_bt">Bluetooth (Recommended)</string>
<string name="car_audio_bt_sub">No prompt each drive, and your map app\'s voice reaches the car</string>
@@ -476,7 +476,7 @@
<string name="carset_build">Build</string>
<string name="carset_whats_new_after">What\'s new after updates</string>
<string name="carset_whats_new_after_sub">Show the list of changes once, the first time the car connects after an update.</string>
<string name="carset_web_default">DuckDuckGo (default) - tap to set a different page</string>
<string name="carset_web_default">Start page (default) - tap to set a page</string>
<string name="carset_web_note">Shown by the Browser app on the launcher. Applies on the next connection.</string>
<string name="carset_screen_fit">Screen fit</string>
<string name="carset_top">Top</string>
@@ -957,4 +957,36 @@
<string name="density_custom_title">Custom density</string>
<string name="density_custom_label">DPI</string>
<string name="density_custom_note">Any value from %1$d to %2$d. Lower fits more on the screen; higher makes everything bigger.</string>
<string name="web_add_bookmark">Add</string>
<string name="web_done">Done</string>
<string name="web_remove_bookmark">Remove %1$s</string>
<string name="web_bookmark_added">Added to the start page</string>
<string name="web_bookmark_removed">Removed from the start page</string>
<string name="web_bookmark_this">Add to the start page</string>
<string name="web_remove_this_bookmark">Remove from the start page</string>
<string name="browser_search_engine">Search engine</string>
<string name="browser_bookmarks">Bookmarks</string>
<string name="browser_bookmarks_note">Shown as tiles on the browser\'s start page. You can also share a page to Gearslip from any browser.</string>
<string name="browser_add_bookmark">Add bookmark</string>
<string name="browser_bookmark_name">Name</string>
<string name="browser_bookmark_address">Web address</string>
<string name="browser_remove">Remove</string>
<string name="browser_adblock">Block ads and trackers</string>
<string name="browser_adblock_note">Uses filter lists, refreshed weekly over Wi-Fi from the sites that publish them.</string>
<string name="browser_youtube">Block YouTube video ads</string>
<string name="browser_youtube_note">Skips the ads inside videos. YouTube changes often, so turn this off if videos stop playing.</string>
<string name="browser_filter_lists">Filter lists</string>
<string name="browser_add_list">Add filter list</string>
<string name="browser_list_address">List address</string>
<string name="browser_list_note">Any list in Adblock Plus format, such as one for your language or region.</string>
<string name="browser_update_lists">Update lists now</string>
<string name="browser_lists_status">%1$d rules, updated %2$s</string>
<string name="browser_lists_never">%1$d rules, from the copy built into Gearslip</string>
<string name="browser_updating">Updating…</string>
<string name="browser_update_failed">Couldn\'t update. %1$s</string>
<string name="browser_shared_added">Added to Gearslip\'s start page</string>
<string name="browser_shared_not_url">That isn\'t a web address</string>
<string name="browser_share_label">Gearslip start page</string>
<string name="summary_adblock_on">Ads blocked</string>
<string name="summary_adblock_off">Ads not blocked</string>
</resources>