mirror of
https://github.com/twinksoftware/Gearslip.git
synced 2026-11-10 16:02:25 +01:00
The car browser gets a start page with bookmark tiles, a search engine choice, and ad blocking
Bookmarks are added from the star in the car, from the phone's settings, or by sharing a page to Gearslip from any browser. Ads and trackers are blocked with EasyList and EasyPrivacy, or any list added by its address, refreshed weekly on Wi-Fi, and YouTube's video ads are skipped.
This commit is contained in:
1 parent
f139ac48c4
commit
43f9259803
37 files changed
+136249
-67
No files matched your search
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+336
@@ -0,0 +1,336 @@
|
||||
package app.seb3thehacker.gearslip.browser.adblock
|
||||
|
||||
import android.content.Context
|
||||
import android.webkit.JavascriptInterface
|
||||
import android.webkit.WebView
|
||||
import androidx.webkit.WebViewCompat
|
||||
import androidx.webkit.WebViewFeature
|
||||
|
||||
/**
|
||||
* Applies element-hiding rules to a page.
|
||||
*
|
||||
* Which rules apply depends on the page's hostname and on what is actually in its DOM, neither
|
||||
* of which is known until the document starts loading, so the page fetches its own stylesheet
|
||||
* through a JavaScript bridge. The bridge exposes two pure functions that return CSS: they read
|
||||
* no page state and perform no action, so handing them to untrusted page content costs nothing.
|
||||
*
|
||||
* Hiding happens in two passes. At document start the page gets the rules written for its own
|
||||
* host, which must land before anything renders — injecting on page finish would let ads appear
|
||||
* and then vanish, which is worse than not hiding them. The generic rules follow once the DOM
|
||||
* exists, keyed on the classes and ids actually present, because shipping all of them up front
|
||||
* would mean roughly 400 KB of CSS per page.
|
||||
*/
|
||||
object CosmeticFilterBridge {
|
||||
|
||||
const val BRIDGE_OBJECT_NAME = "GearslipContentBlocker"
|
||||
|
||||
/** Stop watching for new elements after this long, to spare the CPU on a head unit. */
|
||||
private const val OBSERVE_DURATION_MS = 30000
|
||||
|
||||
/** Upper bound on how many new class/id names are sent to the bridge in one pass. */
|
||||
private const val MAX_TOKENS_PER_PASS = 4000
|
||||
|
||||
/** True when document-start injection is unavailable and the client must inject later. */
|
||||
val needsPageStartFallback: Boolean
|
||||
get() = !WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)
|
||||
|
||||
val INJECTION_JS: String = """
|
||||
(function () {
|
||||
try {
|
||||
if (window.__gearslipCosmetic) { return; }
|
||||
var bridge = window.$BRIDGE_OBJECT_NAME;
|
||||
if (!bridge || typeof bridge.cssForHost !== 'function') { return; }
|
||||
|
||||
var host = location.hostname;
|
||||
var seenClass = Object.create(null);
|
||||
var seenId = Object.create(null);
|
||||
window.__gearslipCosmetic = true;
|
||||
|
||||
function addStyle(css) {
|
||||
if (!css) { return; }
|
||||
var style = document.createElement('style');
|
||||
style.setAttribute('data-gearslip', 'content-blocker');
|
||||
style.textContent = css;
|
||||
var root = document.head || document.documentElement;
|
||||
if (root) { root.appendChild(style); }
|
||||
}
|
||||
|
||||
// The engine loads from disk in the background and may not be ready when the
|
||||
// first page starts. Keep asking until it answers, so a page that loses the race
|
||||
// still gets its hiding rules rather than none at all.
|
||||
var hostCssApplied = false;
|
||||
function applyHostCss() {
|
||||
if (hostCssApplied) { return true; }
|
||||
var css = bridge.cssForHost(host);
|
||||
if (!css) { return false; }
|
||||
hostCssApplied = true;
|
||||
addStyle(css);
|
||||
return true;
|
||||
}
|
||||
applyHostCss();
|
||||
var retries = [250, 750, 1500, 3000, 5000, 8000, 12000];
|
||||
for (var r = 0; r < retries.length; r++) {
|
||||
setTimeout(function () {
|
||||
try {
|
||||
if (!hostCssApplied && applyHostCss()) { collect(); }
|
||||
} catch (e) {}
|
||||
}, retries[r]);
|
||||
}
|
||||
|
||||
var classes = [];
|
||||
var ids = [];
|
||||
|
||||
function scan(root, budget) {
|
||||
if (!root || root.nodeType !== 1) { return budget; }
|
||||
var elements = root.getElementsByTagName('*');
|
||||
var total = elements.length + 1;
|
||||
for (var i = 0; i < total && budget > 0; i++) {
|
||||
var element = (i === 0) ? root : elements[i - 1];
|
||||
var id = element.id;
|
||||
if (id && !seenId[id]) { seenId[id] = true; ids.push(id); budget--; }
|
||||
// SVG elements expose className as an SVGAnimatedString, not a string.
|
||||
var names = element.className;
|
||||
if (names && typeof names === 'string') {
|
||||
var parts = names.split(/\s+/);
|
||||
for (var j = 0; j < parts.length && budget > 0; j++) {
|
||||
var name = parts[j];
|
||||
if (name && !seenClass[name]) {
|
||||
seenClass[name] = true;
|
||||
classes.push(name);
|
||||
budget--;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return budget;
|
||||
}
|
||||
|
||||
function collect() {
|
||||
classes.length = 0;
|
||||
ids.length = 0;
|
||||
var budget = $MAX_TOKENS_PER_PASS;
|
||||
if (!scannedOnce) {
|
||||
scannedOnce = true;
|
||||
budget = scan(document.documentElement, budget);
|
||||
} else {
|
||||
// Only look at what the page actually added. Re-walking the whole document
|
||||
// on every mutation costs far more than it finds: on a large single-page app
|
||||
// it means a full DOM traversal every 50ms while the page is starting up.
|
||||
for (var i = 0; i < pending.length && budget > 0; i++) {
|
||||
budget = scan(pending[i], budget);
|
||||
}
|
||||
}
|
||||
pending.length = 0;
|
||||
if (classes.length || ids.length) {
|
||||
addStyle(bridge.cssForTokens(host, classes.join(' '), ids.join(' ')));
|
||||
}
|
||||
}
|
||||
|
||||
var scannedOnce = false;
|
||||
var pending = [];
|
||||
var scheduled = false;
|
||||
function schedule() {
|
||||
if (scheduled) { return; }
|
||||
scheduled = true;
|
||||
setTimeout(function () {
|
||||
scheduled = false;
|
||||
try { collect(); } catch (e) {}
|
||||
}, 50);
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', schedule);
|
||||
} else {
|
||||
schedule();
|
||||
}
|
||||
|
||||
if (typeof MutationObserver === 'function' && document.documentElement) {
|
||||
var observer = new MutationObserver(function (records) {
|
||||
for (var i = 0; i < records.length; i++) {
|
||||
var added = records[i].addedNodes;
|
||||
for (var j = 0; j < added.length; j++) {
|
||||
if (added[j].nodeType === 1) { pending.push(added[j]); }
|
||||
}
|
||||
}
|
||||
// A burst larger than this is a page rebuilding itself; the tail is enough.
|
||||
if (pending.length > 300) { pending.splice(0, pending.length - 300); }
|
||||
if (pending.length) { schedule(); }
|
||||
});
|
||||
observer.observe(document.documentElement, { childList: true, subtree: true });
|
||||
setTimeout(function () {
|
||||
try { observer.disconnect(); } catch (e) {}
|
||||
}, $OBSERVE_DURATION_MS);
|
||||
}
|
||||
} catch (e) {}
|
||||
})();
|
||||
(function () {
|
||||
// Hides a site's own fixed bottom nav bar, which duplicates navigation the app
|
||||
// already provides and costs a sixth of the screen on a ~480px-tall car display.
|
||||
// Found by measuring rather than by a list of known selectors, so it works on any
|
||||
// site. Toggled from the drawer.
|
||||
//
|
||||
// Hiding rather than repositioning is deliberate: an earlier version moved these
|
||||
// bars instead, which fought sites that animate the bar's own position (Twitch
|
||||
// slides its nav bar on scroll) and left it stranded mid-page. display:none has
|
||||
// nothing to fight.
|
||||
try {
|
||||
if (window.__gearslipFooterHiding) { return; }
|
||||
window.__gearslipFooterHiding = true;
|
||||
var bridge = window.$BRIDGE_OBJECT_NAME;
|
||||
if (!bridge || typeof bridge.hideSiteFootersEnabled !== 'function') { return; }
|
||||
if (!bridge.hideSiteFootersEnabled()) { return; }
|
||||
|
||||
var MAX_BAR_HEIGHT = 140;
|
||||
|
||||
function hideBottomBars(root, depth) {
|
||||
if (!root || depth < 0 || !root.children) { return; }
|
||||
for (var i = 0; i < root.children.length; i++) {
|
||||
var el = root.children[i];
|
||||
if (el.__gearslipFooterHidden) { continue; }
|
||||
var cs;
|
||||
try { cs = getComputedStyle(el); } catch (e) { continue; }
|
||||
if (cs.display === 'none') { continue; }
|
||||
if (cs.position === 'fixed' || cs.position === 'sticky') {
|
||||
var rect;
|
||||
try { rect = el.getBoundingClientRect(); } catch (e) { rect = null; }
|
||||
if (rect && rect.width >= window.innerWidth * 0.6 &&
|
||||
rect.height > 0 && rect.height <= MAX_BAR_HEIGHT &&
|
||||
Math.abs(window.innerHeight - rect.bottom) < 6) {
|
||||
el.__gearslipFooterHidden = true;
|
||||
el.style.setProperty('display', 'none', 'important');
|
||||
continue;
|
||||
}
|
||||
}
|
||||
hideBottomBars(el, depth - 1);
|
||||
}
|
||||
}
|
||||
|
||||
function run() {
|
||||
try {
|
||||
hideBottomBars(document.body, 3);
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', run);
|
||||
} else {
|
||||
run();
|
||||
}
|
||||
// Single-page apps mount their nav bar well after first paint, and swap it out
|
||||
// again on navigation, so one pass at load isn't enough.
|
||||
var passes = 0;
|
||||
var timer = setInterval(function () {
|
||||
run();
|
||||
if (++passes > 40) { clearInterval(timer); }
|
||||
}, 500);
|
||||
} catch (e) {}
|
||||
})();
|
||||
(function () {
|
||||
// ":has-text(pattern)" cosmetic rules: hides elements matching a base selector whose
|
||||
// own text also matches a pattern. There is no CSS equivalent for the text half, so
|
||||
// this runs the check in JavaScript instead of the plain <style> the rest of the
|
||||
// cosmetic filters use.
|
||||
try {
|
||||
if (window.__gearslipHasText) { return; }
|
||||
var bridge = window.$BRIDGE_OBJECT_NAME;
|
||||
if (!bridge || typeof bridge.hasTextRulesForHost !== 'function') { return; }
|
||||
|
||||
var rules;
|
||||
try {
|
||||
rules = JSON.parse(bridge.hasTextRulesForHost(location.hostname) || '[]');
|
||||
} catch (e) {
|
||||
rules = [];
|
||||
}
|
||||
if (!rules.length) { return; }
|
||||
window.__gearslipHasText = true;
|
||||
|
||||
var compiled = [];
|
||||
for (var i = 0; i < rules.length; i++) {
|
||||
try {
|
||||
compiled.push({ selector: rules[i][0], regex: new RegExp(rules[i][1], 'i') });
|
||||
} catch (e) {}
|
||||
}
|
||||
if (!compiled.length) { return; }
|
||||
|
||||
function run() {
|
||||
for (var i = 0; i < compiled.length; i++) {
|
||||
var rule = compiled[i];
|
||||
var elements;
|
||||
try { elements = document.querySelectorAll(rule.selector); } catch (e) { continue; }
|
||||
for (var j = 0; j < elements.length; j++) {
|
||||
var el = elements[j];
|
||||
if (el.__gearslipHasTextHidden) { continue; }
|
||||
var text = el.textContent;
|
||||
if (text && rule.regex.test(text)) {
|
||||
el.__gearslipHasTextHidden = true;
|
||||
el.style.setProperty('display', 'none', 'important');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', run);
|
||||
} else {
|
||||
run();
|
||||
}
|
||||
// Matches uBlock Origin's own behaviour for this rule type: it re-evaluates on a
|
||||
// timer rather than per-mutation, since a text match can only be judged once an
|
||||
// element's subtree has actually finished changing.
|
||||
var passes = 0;
|
||||
var timer = setInterval(function () {
|
||||
run();
|
||||
if (++passes > 40) { clearInterval(timer); }
|
||||
}, 500);
|
||||
} catch (e) {}
|
||||
})();
|
||||
""".trimIndent()
|
||||
|
||||
fun attach(webView: WebView) {
|
||||
webView.addJavascriptInterface(Bridge(webView.context.applicationContext), BRIDGE_OBJECT_NAME)
|
||||
if (!needsPageStartFallback) {
|
||||
runCatching {
|
||||
WebViewCompat.addDocumentStartJavaScript(webView, INJECTION_JS, setOf("*"))
|
||||
}
|
||||
runCatching {
|
||||
WebViewCompat.addDocumentStartJavaScript(
|
||||
webView,
|
||||
YouTubeAdBlock.INJECTION_JS,
|
||||
setOf("*")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Bridge(private val context: Context) {
|
||||
@JavascriptInterface
|
||||
fun cssForHost(host: String?): String = runCatching {
|
||||
AdBlockManager.cssForHost(host)
|
||||
}.getOrDefault("")
|
||||
|
||||
@JavascriptInterface
|
||||
fun youtubeBlockingEnabled(host: String?): Boolean = runCatching {
|
||||
AdBlockManager.isYouTubeBlockingEnabled(host)
|
||||
}.getOrDefault(false)
|
||||
|
||||
@JavascriptInterface
|
||||
fun customSiteTweaksEnabled(): Boolean = runCatching {
|
||||
AdBlockManager.isCustomSiteTweaksEnabled()
|
||||
}.getOrDefault(false)
|
||||
|
||||
@JavascriptInterface
|
||||
fun hideSiteFootersEnabled(): Boolean = runCatching {
|
||||
AdBlockManager.isHideSiteFootersEnabled()
|
||||
}.getOrDefault(false)
|
||||
|
||||
@JavascriptInterface
|
||||
fun hasTextRulesForHost(host: String?): String = runCatching {
|
||||
AdBlockManager.hasTextRulesForHost(host)
|
||||
}.getOrDefault("[]")
|
||||
|
||||
@JavascriptInterface
|
||||
fun cssForTokens(host: String?, classNames: String?, ids: String?): String = runCatching {
|
||||
AdBlockManager.cssForTokens(host, classNames.orEmpty(), ids.orEmpty())
|
||||
}.getOrDefault("")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,610 @@
|
||||
package app.seb3thehacker.gearslip.browser.adblock
|
||||
|
||||
import android.util.LruCache
|
||||
|
||||
/**
|
||||
* An immutable, queryable index over a set of parsed filter rules.
|
||||
*
|
||||
* Network rules are bucketed so that a request only ever tests a handful of them:
|
||||
* - `||host^` rules are indexed by host and found by walking the request host's suffixes;
|
||||
* - every other rule is indexed under one literal token from its pattern, and a request only
|
||||
* tests rules whose token appears in the URL;
|
||||
* - the few rules with no usable token are tested on every request.
|
||||
*
|
||||
* Element-hiding rules are split the same way. Nearly all generic hiding rules are anchored on
|
||||
* a single class or id (`.ad-banner`, `#sponsored`), so they are indexed by that name and only
|
||||
* handed to a page once that class or id actually appears in its DOM. Injecting all of them
|
||||
* unconditionally would mean roughly 400 KB of CSS on every page load.
|
||||
*/
|
||||
internal class FilterEngine private constructor(
|
||||
private val blockRules: RuleSet,
|
||||
private val allowRules: RuleSet,
|
||||
private val genericComplexSelectors: List<String>,
|
||||
private val genericByClass: HashMap<String, MutableList<String>>,
|
||||
private val genericById: HashMap<String, MutableList<String>>,
|
||||
private val specificSelectors: HashMap<String, MutableList<String>>,
|
||||
private val cosmeticExceptions: HashMap<String, MutableList<String>>,
|
||||
private val genericHasText: List<Pair<String, String>>,
|
||||
private val specificHasText: HashMap<String, MutableList<Pair<String, String>>>,
|
||||
val networkRuleCount: Int,
|
||||
val cosmeticRuleCount: Int
|
||||
) {
|
||||
|
||||
private val hostCssCache = LruCache<String, String>(32)
|
||||
|
||||
/** True when the request should be blocked at all, regardless of which rule matched. */
|
||||
fun shouldBlock(
|
||||
url: String,
|
||||
requestHost: String?,
|
||||
documentHost: String?,
|
||||
type: Int,
|
||||
isThirdParty: Boolean
|
||||
): Boolean = matchBlockingRule(url, requestHost, documentHost, type, isThirdParty) != null
|
||||
|
||||
/**
|
||||
* The block match for this request, or null when it is not blocked. Split out from
|
||||
* [shouldBlock] so the caller can also read the matched rule's `$redirect=` resource name
|
||||
* without a second, separate walk over the same rule sets. A plain `||host^` match (the
|
||||
* common case) carries no [NetworkRule] object at all, hence [BlockMatch] rather than
|
||||
* returning the rule directly.
|
||||
*/
|
||||
fun matchBlockingRule(
|
||||
url: String,
|
||||
requestHost: String?,
|
||||
documentHost: String?,
|
||||
type: Int,
|
||||
isThirdParty: Boolean
|
||||
): BlockMatch? {
|
||||
val lowerUrl = url.lowercase()
|
||||
val hostStart = hostStart(lowerUrl)
|
||||
val hostEnd = hostEnd(lowerUrl, hostStart)
|
||||
val tokens = ArrayList<String>(16)
|
||||
tokenize(lowerUrl, tokens)
|
||||
|
||||
val blockingMatch = matchingRule(blockRules, lowerUrl, hostStart, hostEnd, requestHost, documentHost, type, isThirdParty, tokens)
|
||||
?: return null
|
||||
if (matchesAny(allowRules, lowerUrl, hostStart, hostEnd, requestHost, documentHost, type, isThirdParty, tokens)) {
|
||||
return null
|
||||
}
|
||||
return blockingMatch
|
||||
}
|
||||
|
||||
class BlockMatch(val redirectResource: String?) {
|
||||
companion object {
|
||||
val PLAIN = BlockMatch(null)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The stylesheet injected at document start: rules written for this site, plus the handful
|
||||
* of generic rules that are not anchored on a class or id.
|
||||
*/
|
||||
fun cssForHost(host: String?): String {
|
||||
val normalized = host?.lowercase().orEmpty()
|
||||
hostCssCache.get(normalized)?.let { return it }
|
||||
|
||||
val exceptions = exceptionsFor(normalized)
|
||||
val selectors = ArrayList<String>(genericComplexSelectors.size + 32)
|
||||
if (normalized.isNotEmpty()) {
|
||||
DomainUtils.forEachHostSuffix(normalized) { suffix ->
|
||||
specificSelectors[suffix]?.let { selectors.addAll(it) }
|
||||
}
|
||||
}
|
||||
selectors.addAll(genericComplexSelectors)
|
||||
|
||||
val css = buildHidingCss(selectors, exceptions)
|
||||
hostCssCache.put(normalized, css)
|
||||
return css
|
||||
}
|
||||
|
||||
private val hasTextCache = LruCache<String, List<Pair<String, String>>>(32)
|
||||
|
||||
/**
|
||||
* The `:has-text()` rules (base selector, JS regex source) that apply to [host]. Delivered
|
||||
* separately from [cssForHost] because there is no CSS equivalent for a text match -- the
|
||||
* caller runs these with JavaScript instead of a `<style>` element.
|
||||
*/
|
||||
fun hasTextRulesForHost(host: String?): List<Pair<String, String>> {
|
||||
val normalized = host?.lowercase().orEmpty()
|
||||
hasTextCache.get(normalized)?.let { return it }
|
||||
|
||||
val rules = ArrayList<Pair<String, String>>(genericHasText.size + 4)
|
||||
if (normalized.isNotEmpty()) {
|
||||
DomainUtils.forEachHostSuffix(normalized) { suffix ->
|
||||
specificHasText[suffix]?.let { rules.addAll(it) }
|
||||
}
|
||||
}
|
||||
rules.addAll(genericHasText)
|
||||
|
||||
hasTextCache.put(normalized, rules)
|
||||
return rules
|
||||
}
|
||||
|
||||
/**
|
||||
* The stylesheet for generic rules whose class or id has turned up in the page. [classNames]
|
||||
* and [ids] are whitespace-separated, as collected from the live DOM.
|
||||
*/
|
||||
fun cssForTokens(host: String?, classNames: String, ids: String): String {
|
||||
val normalized = host?.lowercase().orEmpty()
|
||||
val exceptions = exceptionsFor(normalized)
|
||||
val selectors = ArrayList<String>()
|
||||
|
||||
collectByToken(classNames, genericByClass, selectors)
|
||||
collectByToken(ids, genericById, selectors)
|
||||
if (selectors.isEmpty()) return ""
|
||||
|
||||
return buildHidingCss(selectors, exceptions)
|
||||
}
|
||||
|
||||
private fun collectByToken(
|
||||
tokens: String,
|
||||
index: HashMap<String, MutableList<String>>,
|
||||
out: MutableList<String>
|
||||
) {
|
||||
if (tokens.isEmpty() || index.isEmpty()) return
|
||||
var start = 0
|
||||
val length = tokens.length
|
||||
while (start < length) {
|
||||
while (start < length && tokens[start].isWhitespace()) start++
|
||||
if (start >= length) break
|
||||
var end = start
|
||||
while (end < length && !tokens[end].isWhitespace()) end++
|
||||
index[tokens.substring(start, end)]?.let { out.addAll(it) }
|
||||
start = end
|
||||
}
|
||||
}
|
||||
|
||||
private fun exceptionsFor(host: String): Set<String> {
|
||||
if (host.isEmpty() || cosmeticExceptions.isEmpty()) return emptySet()
|
||||
var result: MutableSet<String>? = null
|
||||
DomainUtils.forEachHostSuffix(host) { suffix ->
|
||||
cosmeticExceptions[suffix]?.let { exceptions ->
|
||||
val target = result ?: HashSet<String>().also { result = it }
|
||||
target.addAll(exceptions)
|
||||
}
|
||||
}
|
||||
return result ?: emptySet()
|
||||
}
|
||||
|
||||
private fun buildHidingCss(selectors: List<String>, exceptions: Set<String>): String {
|
||||
val usable = if (exceptions.isEmpty()) {
|
||||
selectors
|
||||
} else {
|
||||
selectors.filterNot { it in exceptions }
|
||||
}
|
||||
if (usable.isEmpty()) return ""
|
||||
|
||||
val builder = StringBuilder(usable.size * 24)
|
||||
// One rule per chunk rather than one rule for everything: a browser drops an entire
|
||||
// rule if any selector in it fails to parse, so a chunk limits that blast radius.
|
||||
for (chunk in usable.chunked(SELECTORS_PER_RULE)) {
|
||||
builder.append(chunk.joinToString(","))
|
||||
builder.append("{display:none!important}")
|
||||
}
|
||||
return builder.toString()
|
||||
}
|
||||
|
||||
private fun matchesAny(
|
||||
ruleSet: RuleSet,
|
||||
url: String,
|
||||
hostStart: Int,
|
||||
hostEnd: Int,
|
||||
requestHost: String?,
|
||||
documentHost: String?,
|
||||
type: Int,
|
||||
isThirdParty: Boolean,
|
||||
tokens: List<String>
|
||||
): Boolean {
|
||||
if (!requestHost.isNullOrEmpty()) {
|
||||
var found = false
|
||||
DomainUtils.forEachHostSuffix(requestHost) { suffix ->
|
||||
if (!found) {
|
||||
if (suffix in ruleSet.plainHosts) {
|
||||
found = true
|
||||
} else {
|
||||
val candidates = ruleSet.hostIndex[suffix]
|
||||
if (candidates != null) {
|
||||
for (rule in candidates) {
|
||||
if (optionsMatch(rule, type, isThirdParty, documentHost)) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (found) return true
|
||||
}
|
||||
|
||||
for (token in tokens) {
|
||||
val candidates = ruleSet.tokenIndex[token] ?: continue
|
||||
for (rule in candidates) {
|
||||
if (optionsMatch(rule, type, isThirdParty, documentHost) &&
|
||||
matchesUrl(rule, url, hostStart, hostEnd)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (rule in ruleSet.catchAll) {
|
||||
if (optionsMatch(rule, type, isThirdParty, documentHost) &&
|
||||
matchesUrl(rule, url, hostStart, hostEnd)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Same walk as [matchesAny], but returns the match itself instead of a plain boolean. */
|
||||
private fun matchingRule(
|
||||
ruleSet: RuleSet,
|
||||
url: String,
|
||||
hostStart: Int,
|
||||
hostEnd: Int,
|
||||
requestHost: String?,
|
||||
documentHost: String?,
|
||||
type: Int,
|
||||
isThirdParty: Boolean,
|
||||
tokens: List<String>
|
||||
): BlockMatch? {
|
||||
if (!requestHost.isNullOrEmpty()) {
|
||||
var result: BlockMatch? = null
|
||||
DomainUtils.forEachHostSuffix(requestHost) { suffix ->
|
||||
if (result == null) {
|
||||
if (suffix in ruleSet.plainHosts) {
|
||||
result = BlockMatch.PLAIN
|
||||
} else {
|
||||
val candidates = ruleSet.hostIndex[suffix]
|
||||
if (candidates != null) {
|
||||
for (rule in candidates) {
|
||||
if (optionsMatch(rule, type, isThirdParty, documentHost)) {
|
||||
result = BlockMatch(rule.redirectResource)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (result != null) return result
|
||||
}
|
||||
|
||||
for (token in tokens) {
|
||||
val candidates = ruleSet.tokenIndex[token] ?: continue
|
||||
for (rule in candidates) {
|
||||
if (optionsMatch(rule, type, isThirdParty, documentHost) &&
|
||||
matchesUrl(rule, url, hostStart, hostEnd)
|
||||
) {
|
||||
return BlockMatch(rule.redirectResource)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (rule in ruleSet.catchAll) {
|
||||
if (optionsMatch(rule, type, isThirdParty, documentHost) &&
|
||||
matchesUrl(rule, url, hostStart, hostEnd)
|
||||
) {
|
||||
return BlockMatch(rule.redirectResource)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun optionsMatch(
|
||||
rule: NetworkRule,
|
||||
type: Int,
|
||||
isThirdParty: Boolean,
|
||||
documentHost: String?
|
||||
): Boolean {
|
||||
if (rule.typeMask != 0 && (rule.typeMask and type) == 0) return false
|
||||
if (rule.excludedTypeMask != 0 && (rule.excludedTypeMask and type) != 0) return false
|
||||
|
||||
val thirdParty = rule.thirdParty
|
||||
if (thirdParty != null && thirdParty != isThirdParty) return false
|
||||
|
||||
val include = rule.includeDomains
|
||||
val exclude = rule.excludeDomains
|
||||
if (include != null || exclude != null) {
|
||||
val host = documentHost ?: return false
|
||||
if (exclude != null && exclude.any { DomainUtils.hostMatchesDomain(host, it) }) return false
|
||||
if (include != null && !include.any { DomainUtils.hostMatchesDomain(host, it) }) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun matchesUrl(rule: NetworkRule, url: String, hostStart: Int, hostEnd: Int): Boolean {
|
||||
if (rule.anchorDomain) {
|
||||
if (matchFrom(url, hostStart, rule)) return true
|
||||
var index = hostStart
|
||||
while (index < hostEnd) {
|
||||
if (url[index] == '.' && matchFrom(url, index + 1, rule)) return true
|
||||
index++
|
||||
}
|
||||
return false
|
||||
}
|
||||
if (rule.anchorStart) return matchFrom(url, 0, rule)
|
||||
|
||||
val first = rule.segments[0]
|
||||
if (first.isNotEmpty() && first.indexOf('^') < 0) {
|
||||
var index = url.indexOf(first)
|
||||
while (index >= 0) {
|
||||
if (matchFrom(url, index, rule)) return true
|
||||
index = url.indexOf(first, index + 1)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var index = 0
|
||||
while (index <= url.length) {
|
||||
if (matchFrom(url, index, rule)) return true
|
||||
index++
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun matchFrom(url: String, start: Int, rule: NetworkRule): Boolean {
|
||||
val segments = rule.segments
|
||||
var position = matchSegmentAt(url, start, segments[0])
|
||||
if (position < 0) return false
|
||||
|
||||
for (i in 1 until segments.size) {
|
||||
val segment = segments[i]
|
||||
if (segment.isEmpty()) continue
|
||||
var probe = position
|
||||
var matchedEnd = -1
|
||||
while (probe <= url.length) {
|
||||
val end = matchSegmentAt(url, probe, segment)
|
||||
if (end >= 0) {
|
||||
matchedEnd = end
|
||||
break
|
||||
}
|
||||
probe++
|
||||
}
|
||||
if (matchedEnd < 0) return false
|
||||
position = matchedEnd
|
||||
}
|
||||
|
||||
if (rule.anchorEnd && segments[segments.size - 1].isNotEmpty() && position != url.length) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** Returns the index just past the match, or -1. `^` matches one separator or end-of-URL. */
|
||||
private fun matchSegmentAt(url: String, start: Int, segment: String): Int {
|
||||
var position = start
|
||||
for (i in segment.indices) {
|
||||
val expected = segment[i]
|
||||
if (expected == '^') {
|
||||
if (position >= url.length) {
|
||||
// A separator may stand for the end of the address, but only as the last
|
||||
// character of the segment.
|
||||
return if (i == segment.length - 1) position else -1
|
||||
}
|
||||
if (!isSeparator(url[position])) return -1
|
||||
position++
|
||||
} else {
|
||||
if (position >= url.length || url[position] != expected) return -1
|
||||
position++
|
||||
}
|
||||
}
|
||||
return position
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val MIN_TOKEN_LENGTH = 3
|
||||
private const val SELECTORS_PER_RULE = 100
|
||||
|
||||
private fun isSeparator(c: Char): Boolean = when {
|
||||
c in 'a'..'z' -> false
|
||||
c in 'A'..'Z' -> false
|
||||
c in '0'..'9' -> false
|
||||
c == '_' || c == '-' || c == '.' || c == '%' -> false
|
||||
else -> true
|
||||
}
|
||||
|
||||
private fun hostStart(url: String): Int {
|
||||
val scheme = url.indexOf("://")
|
||||
return if (scheme < 0) 0 else scheme + 3
|
||||
}
|
||||
|
||||
private fun hostEnd(url: String, hostStart: Int): Int {
|
||||
var index = hostStart
|
||||
while (index < url.length) {
|
||||
val c = url[index]
|
||||
if (c == '/' || c == '?' || c == '#' || c == ':') return index
|
||||
index++
|
||||
}
|
||||
return url.length
|
||||
}
|
||||
|
||||
private fun tokenize(text: String, out: MutableList<String>) {
|
||||
var index = 0
|
||||
val length = text.length
|
||||
while (index < length) {
|
||||
val c = text[index]
|
||||
if (c.isLetterOrDigit()) {
|
||||
var end = index + 1
|
||||
while (end < length && text[end].isLetterOrDigit()) end++
|
||||
if (end - index >= MIN_TOKEN_LENGTH) out.add(text.substring(index, end))
|
||||
index = end
|
||||
} else {
|
||||
index++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The longest literal token in the pattern, used as the rule's index key. */
|
||||
private fun bestToken(rule: NetworkRule): String? {
|
||||
var best: String? = null
|
||||
val tokens = ArrayList<String>(4)
|
||||
for (segment in rule.segments) {
|
||||
tokens.clear()
|
||||
tokenize(segment, tokens)
|
||||
for (token in tokens) {
|
||||
val current = best
|
||||
if (current == null || token.length > current.length) best = token
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
/**
|
||||
* The leading class or id a selector is anchored on, as (`.` or `#`) to name, or null
|
||||
* when the selector does not start with a plain class or id.
|
||||
*/
|
||||
private fun anchorToken(selector: String): Pair<Char, String>? {
|
||||
if (selector.length < 2) return null
|
||||
val kind = selector[0]
|
||||
if (kind != '.' && kind != '#') return null
|
||||
var end = 1
|
||||
while (end < selector.length) {
|
||||
val c = selector[end]
|
||||
if (c.isLetterOrDigit() || c == '-' || c == '_') end++ else break
|
||||
}
|
||||
if (end == 1) return null
|
||||
return kind to selector.substring(1, end)
|
||||
}
|
||||
|
||||
fun build(lines: Sequence<String>): FilterEngine {
|
||||
val builder = Builder()
|
||||
for (line in lines) builder.addLine(line)
|
||||
return builder.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Accumulates rules one line at a time, fed straight from a reader so that the whole list
|
||||
* never exists in memory as a collection of lines. Parsing EasyList and EasyPrivacy
|
||||
* together takes roughly 250ms on a Pixel 6 in a release build. Be wary of profiling this
|
||||
* in a debug build: `android:debuggable` disables ART's inlining and made the same work
|
||||
* take over nine seconds, which looks like an algorithmic problem but is not one.
|
||||
*/
|
||||
internal class Builder {
|
||||
private val blockRules = RuleSet()
|
||||
private val allowRules = RuleSet()
|
||||
private val genericComplex = LinkedHashSet<String>()
|
||||
// Pre-sized: EasyList and EasyPrivacy together fill these to roughly these counts, and
|
||||
// rehashing a 90k-entry map on the way there is pure waste.
|
||||
private val genericByClass = HashMap<String, MutableList<String>>(16384)
|
||||
private val genericById = HashMap<String, MutableList<String>>(8192)
|
||||
private val specificSelectors = HashMap<String, MutableList<String>>(16384)
|
||||
private val cosmeticExceptions = HashMap<String, MutableList<String>>(1024)
|
||||
private val genericHasText = mutableListOf<Pair<String, String>>()
|
||||
private val specificHasText = HashMap<String, MutableList<Pair<String, String>>>()
|
||||
private var networkRuleCount = 0
|
||||
private var cosmeticRuleCount = 0
|
||||
|
||||
fun addLine(rawLine: String) {
|
||||
if (rawLine.isEmpty()) return
|
||||
// Only pay for trim() on the rare line that actually has surrounding whitespace.
|
||||
val needsTrim = rawLine[0] == ' ' || rawLine[0] == '\t' ||
|
||||
rawLine[rawLine.length - 1] == ' ' || rawLine[rawLine.length - 1] == '\t'
|
||||
val line = if (needsTrim) rawLine.trim() else rawLine
|
||||
if (line.isEmpty()) return
|
||||
if (line[0] == '!' || line[0] == '[') return
|
||||
|
||||
val separator = FilterParser.cosmeticSeparatorIndex(line)
|
||||
if (separator >= 0) {
|
||||
addCosmeticRule(line, separator)
|
||||
return
|
||||
}
|
||||
|
||||
val isException = line.startsWith("@@")
|
||||
val rule = FilterParser.parseNetworkRule(line) ?: return
|
||||
networkRuleCount++
|
||||
val target = if (isException) allowRules else blockRules
|
||||
|
||||
val hostAnchor = rule.hostAnchor
|
||||
if (hostAnchor != null) {
|
||||
if (rule.hasNoOptions) {
|
||||
target.plainHosts.add(hostAnchor)
|
||||
} else {
|
||||
target.hostIndex.getOrPut(hostAnchor) { mutableListOf() }.add(rule)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val token = bestToken(rule)
|
||||
if (token != null) {
|
||||
target.tokenIndex.getOrPut(token) { mutableListOf() }.add(rule)
|
||||
} else {
|
||||
target.catchAll.add(rule)
|
||||
}
|
||||
}
|
||||
|
||||
private fun addCosmeticRule(line: String, separator: Int) {
|
||||
val rule = FilterParser.parseCosmeticRule(line, separator) ?: return
|
||||
cosmeticRuleCount++
|
||||
|
||||
// :has-text() has no CSS equivalent, so it is matched by JavaScript from its own
|
||||
// list instead of joining the CSS selector strings above. Exceptions for it are not
|
||||
// supported -- rare enough in EasyList/EasyPrivacy that dropping them outright (by
|
||||
// not indexing this rule) is a safe simplification rather than a real gap.
|
||||
val textPattern = rule.hasTextPattern
|
||||
if (textPattern != null) {
|
||||
if (rule.isException) return
|
||||
val entry = rule.selector to textPattern
|
||||
if (rule.includeDomains != null) {
|
||||
rule.includeDomains.forEach { domain ->
|
||||
specificHasText.getOrPut(domain) { mutableListOf() }.add(entry)
|
||||
}
|
||||
} else {
|
||||
genericHasText.add(entry)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
when {
|
||||
rule.isException -> {
|
||||
rule.includeDomains?.forEach { domain ->
|
||||
cosmeticExceptions.getOrPut(domain) { mutableListOf() }.add(rule.selector)
|
||||
}
|
||||
}
|
||||
rule.includeDomains != null -> {
|
||||
rule.includeDomains.forEach { domain ->
|
||||
specificSelectors.getOrPut(domain) { mutableListOf() }.add(rule.selector)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
val anchor = anchorToken(rule.selector)
|
||||
if (anchor == null) {
|
||||
genericComplex.add(rule.selector)
|
||||
} else {
|
||||
val index = if (anchor.first == '.') genericByClass else genericById
|
||||
index.getOrPut(anchor.second) { mutableListOf() }.add(rule.selector)
|
||||
}
|
||||
// `~example.com##.ad` hides everywhere except the excluded domains.
|
||||
rule.excludeDomains?.forEach { domain ->
|
||||
cosmeticExceptions.getOrPut(domain) { mutableListOf() }.add(rule.selector)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun finish(): FilterEngine = FilterEngine(
|
||||
blockRules = blockRules,
|
||||
allowRules = allowRules,
|
||||
genericComplexSelectors = genericComplex.toList(),
|
||||
genericByClass = genericByClass,
|
||||
genericById = genericById,
|
||||
specificSelectors = specificSelectors,
|
||||
cosmeticExceptions = cosmeticExceptions,
|
||||
genericHasText = genericHasText,
|
||||
specificHasText = specificHasText,
|
||||
networkRuleCount = networkRuleCount,
|
||||
cosmeticRuleCount = cosmeticRuleCount
|
||||
)
|
||||
}
|
||||
|
||||
internal class RuleSet {
|
||||
/** Bare `||host^` blocks with no options: matching the host is the whole test. */
|
||||
val plainHosts = HashSet<String>()
|
||||
val hostIndex = HashMap<String, MutableList<NetworkRule>>()
|
||||
val tokenIndex = HashMap<String, MutableList<NetworkRule>>()
|
||||
val catchAll = ArrayList<NetworkRule>()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
package app.seb3thehacker.gearslip.browser.adblock
|
||||
|
||||
/**
|
||||
* A parsed network filter rule (Adblock Plus syntax subset).
|
||||
*
|
||||
* [segments] is the pattern split on `*`; each segment is matched literally except for `^`,
|
||||
* which stands for a single separator character (or the end of the URL).
|
||||
*/
|
||||
internal class NetworkRule(
|
||||
val segments: Array<String>,
|
||||
val anchorDomain: Boolean,
|
||||
val anchorStart: Boolean,
|
||||
val anchorEnd: Boolean,
|
||||
val typeMask: Int,
|
||||
val excludedTypeMask: Int,
|
||||
val thirdParty: Boolean?,
|
||||
val includeDomains: Array<String>?,
|
||||
val excludeDomains: Array<String>?,
|
||||
/** Set when the whole pattern is a bare `||host^`, which lets the engine index it by host. */
|
||||
val hostAnchor: String?,
|
||||
/** Set for `$redirect=name` / `$redirect-rule=name` rules with a resource we can serve. */
|
||||
val redirectResource: String?
|
||||
) {
|
||||
/**
|
||||
* True when the rule carries no options, so matching the host is the whole test. The great
|
||||
* majority of list entries are of this shape and the engine stores them as bare strings.
|
||||
*/
|
||||
val hasNoOptions: Boolean
|
||||
get() = typeMask == 0 && excludedTypeMask == 0 && thirdParty == null &&
|
||||
includeDomains == null && excludeDomains == null && redirectResource == null
|
||||
}
|
||||
|
||||
internal class CosmeticRule(
|
||||
val selector: String,
|
||||
val includeDomains: List<String>?,
|
||||
val excludeDomains: List<String>?,
|
||||
val isException: Boolean,
|
||||
/** Set for a `selector:has-text(pattern)` rule: the text [pattern] to match within
|
||||
* [selector]'s matches, applied by JavaScript rather than as plain CSS. */
|
||||
val hasTextPattern: String? = null
|
||||
)
|
||||
|
||||
internal object FilterParser {
|
||||
|
||||
/**
|
||||
* Options that change *how* a request is answered rather than whether it is blocked.
|
||||
* A rule carrying one of these is dropped: honouring only its matching half would block
|
||||
* requests the list author meant to rewrite or merely restrict.
|
||||
*/
|
||||
private val UNSUPPORTED_OPTIONS = hashSetOf(
|
||||
"csp", "removeparam", "rewrite", "replace", "empty", "mp4",
|
||||
"inline-script", "inline-font", "genericblock", "generichide", "specifichide", "elemhide",
|
||||
"badfilter", "denyallow", "method", "header", "to", "from", "ipaddress", "permissions",
|
||||
"uritransform", "urlskip", "urltransform", "strict1p", "strict3p", "popup", "popunder",
|
||||
"cname", "webrtc", "stealth", "cookie", "network", "app", "extension", "jsonprune",
|
||||
"hls", "referrerpolicy", "match-case-path"
|
||||
)
|
||||
|
||||
/**
|
||||
* Procedural cosmetic syntax that needs uBlock Origin's own runtime to evaluate. These
|
||||
* selectors cannot be expressed as plain CSS, so the rules are skipped.
|
||||
*
|
||||
* `:has()` is not in this list: modern WebView's Chromium supports it natively as plain CSS,
|
||||
* so those selectors are just passed straight through. `:has-text()` is handled separately in
|
||||
* [parseCosmeticRule] rather than being skipped outright -- the simple, common form (a plain
|
||||
* selector followed by one `:has-text(...)`) is split out and matched by JavaScript instead of
|
||||
* CSS, since there is no CSS equivalent for a text match.
|
||||
*/
|
||||
private val PROCEDURAL_MARKERS = arrayOf(
|
||||
// ":has-text(" stays here as a safety net: it's stripped out before this check runs
|
||||
// when it's the simple trailing form [extractHasText] understands, so a leftover
|
||||
// occurrence here means that extraction either wasn't attempted (not a cosmetic rule
|
||||
// that reached it) or failed (nested/combined usage), and the rule should be dropped
|
||||
// rather than shipped as literal, invalid CSS.
|
||||
":has-text(",
|
||||
":matches-css", ":matches-media", ":matches-path", ":matches-attr",
|
||||
":xpath(", ":upward(", ":nth-ancestor(", ":style(", ":remove(", ":remove-attr(",
|
||||
":remove-class(", ":watch-attr(", ":min-text-length(", ":others(", ":shadow(", ":if(",
|
||||
":if-not(", ":not(:has"
|
||||
)
|
||||
|
||||
private fun isHostCharacter(c: Char): Boolean =
|
||||
c in 'a'..'z' || c in '0'..'9' || c == '.' || c == '-' || c == '_'
|
||||
|
||||
fun parseNetworkRule(line: String): NetworkRule? {
|
||||
var body = line
|
||||
if (body.startsWith("@@")) body = body.substring(2)
|
||||
if (body.isEmpty()) return null
|
||||
|
||||
var pattern = body
|
||||
var optionText: String? = null
|
||||
val dollar = body.lastIndexOf('$')
|
||||
if (dollar >= 0) {
|
||||
pattern = body.substring(0, dollar)
|
||||
optionText = body.substring(dollar + 1)
|
||||
}
|
||||
|
||||
// Regular-expression rules (/pattern/) are not supported.
|
||||
if (pattern.length > 2 && pattern.startsWith("/") && pattern.endsWith("/")) return null
|
||||
if (pattern.isEmpty()) return null
|
||||
|
||||
var anchorDomain = false
|
||||
var anchorStart = false
|
||||
var anchorEnd = false
|
||||
if (pattern.startsWith("||")) {
|
||||
anchorDomain = true
|
||||
pattern = pattern.substring(2)
|
||||
} else if (pattern.startsWith("|")) {
|
||||
anchorStart = true
|
||||
pattern = pattern.substring(1)
|
||||
}
|
||||
if (pattern.endsWith("|")) {
|
||||
anchorEnd = true
|
||||
pattern = pattern.dropLast(1)
|
||||
}
|
||||
if (pattern.isEmpty()) return null
|
||||
pattern = pattern.lowercase()
|
||||
|
||||
var typeMask = 0
|
||||
var excludedTypeMask = 0
|
||||
var thirdParty: Boolean? = null
|
||||
var includeDomains: MutableList<String>? = null
|
||||
var excludeDomains: MutableList<String>? = null
|
||||
var redirectResource: String? = null
|
||||
|
||||
if (optionText != null) {
|
||||
for (rawOption in optionText.split(',')) {
|
||||
var option = rawOption.trim().lowercase()
|
||||
if (option.isEmpty()) continue
|
||||
|
||||
var negated = false
|
||||
if (option.startsWith("~")) {
|
||||
negated = true
|
||||
option = option.substring(1)
|
||||
}
|
||||
|
||||
val equals = option.indexOf('=')
|
||||
val key = if (equals >= 0) option.substring(0, equals) else option
|
||||
val value = if (equals >= 0) option.substring(equals + 1) else null
|
||||
|
||||
if (key.isEmpty()) return null
|
||||
if (key in UNSUPPORTED_OPTIONS) return null
|
||||
|
||||
when (key) {
|
||||
"domain" -> {
|
||||
if (value.isNullOrEmpty()) return null
|
||||
for (entry in value.split('|')) {
|
||||
val domain = entry.trim()
|
||||
if (domain.isEmpty()) continue
|
||||
if (domain.startsWith("~")) {
|
||||
val excluded = domain.substring(1)
|
||||
if (excluded.isNotEmpty()) {
|
||||
(excludeDomains ?: mutableListOf<String>()
|
||||
.also { excludeDomains = it }).add(excluded)
|
||||
}
|
||||
} else {
|
||||
(includeDomains ?: mutableListOf<String>()
|
||||
.also { includeDomains = it }).add(domain)
|
||||
}
|
||||
}
|
||||
}
|
||||
"third-party", "3p" -> thirdParty = !negated
|
||||
"first-party", "1p" -> thirdParty = negated
|
||||
// Patterns are lower-cased for matching, so $match-case degrades to
|
||||
// case-insensitive. It only ever widens the match.
|
||||
"match-case", "important" -> Unit
|
||||
"all" -> Unit
|
||||
// `redirect-rule` only fires when another rule would already block the
|
||||
// request; treating it the same as `redirect` is a harmless simplification
|
||||
// since we only reach here once a block decision is otherwise made.
|
||||
"redirect", "redirect-rule" -> {
|
||||
// AdGuard's priority suffix (`name:10`) doesn't apply to a single-engine
|
||||
// blocker with no other redirect rule to rank against.
|
||||
val name = value?.substringBefore(':')
|
||||
if (name.isNullOrEmpty()) return null
|
||||
redirectResource = name
|
||||
}
|
||||
else -> {
|
||||
val bit = ResourceType.fromOptionName(key)
|
||||
if (bit == ResourceType.NONE) return null
|
||||
if (negated) excludedTypeMask = excludedTypeMask or bit
|
||||
else typeMask = typeMask or bit
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val segments = splitOnWildcards(pattern)
|
||||
if (segments.isEmpty()) return null
|
||||
|
||||
var hostAnchor: String? = null
|
||||
if (anchorDomain && !anchorEnd && segments.size == 1) {
|
||||
val candidate = if (pattern.endsWith("^")) pattern.dropLast(1) else pattern
|
||||
if (candidate.isNotEmpty() && candidate.all { isHostCharacter(it) }) {
|
||||
hostAnchor = candidate
|
||||
}
|
||||
}
|
||||
|
||||
return NetworkRule(
|
||||
segments = segments,
|
||||
anchorDomain = anchorDomain,
|
||||
anchorStart = anchorStart,
|
||||
anchorEnd = anchorEnd,
|
||||
typeMask = typeMask,
|
||||
excludedTypeMask = excludedTypeMask,
|
||||
thirdParty = thirdParty,
|
||||
includeDomains = includeDomains?.toTypedArray(),
|
||||
excludeDomains = excludeDomains?.toTypedArray(),
|
||||
hostAnchor = hostAnchor,
|
||||
redirectResource = redirectResource
|
||||
)
|
||||
}
|
||||
|
||||
private fun splitOnWildcards(pattern: String): Array<String> {
|
||||
if (pattern.indexOf('*') < 0) return arrayOf(pattern)
|
||||
return pattern.split('*').toTypedArray()
|
||||
}
|
||||
|
||||
/** Returns the index of the cosmetic separator, or -1 when the line is not a cosmetic rule. */
|
||||
fun cosmeticSeparatorIndex(line: String): Int {
|
||||
var search = 0
|
||||
while (true) {
|
||||
val hash = line.indexOf('#', search)
|
||||
if (hash < 0) return -1
|
||||
if (hash + 1 < line.length) {
|
||||
val next = line[hash + 1]
|
||||
if (next == '#') return hash
|
||||
if (next == '@' && hash + 2 < line.length && line[hash + 2] == '#') return hash
|
||||
// #?#, #$#, #$?#, #%# and friends are procedural or scriptlet syntax.
|
||||
if (next == '?' || next == '$' || next == '%') return hash
|
||||
}
|
||||
search = hash + 1
|
||||
}
|
||||
}
|
||||
|
||||
fun parseCosmeticRule(line: String, separatorIndex: Int): CosmeticRule? {
|
||||
val isException: Boolean
|
||||
val selectorStart: Int
|
||||
when {
|
||||
line.startsWith("#@#", separatorIndex) -> {
|
||||
isException = true
|
||||
selectorStart = separatorIndex + 3
|
||||
}
|
||||
line.startsWith("##", separatorIndex) -> {
|
||||
isException = false
|
||||
selectorStart = separatorIndex + 2
|
||||
}
|
||||
// Procedural / scriptlet separators cannot be represented as CSS.
|
||||
else -> return null
|
||||
}
|
||||
|
||||
if (selectorStart >= line.length) return null
|
||||
var selector = line.substring(selectorStart).trim()
|
||||
if (selector.isEmpty()) return null
|
||||
if (selector.startsWith("+js(") || selector.startsWith("script:")) return null
|
||||
|
||||
var hasTextPattern: String? = null
|
||||
val hasTextSplit = extractHasText(selector)
|
||||
if (hasTextSplit != null) {
|
||||
selector = hasTextSplit.first
|
||||
hasTextPattern = hasTextSplit.second
|
||||
}
|
||||
|
||||
val lowerSelector = selector.lowercase()
|
||||
if (PROCEDURAL_MARKERS.any { lowerSelector.contains(it) }) return null
|
||||
// A stray brace would let a selector escape its own rule when concatenated.
|
||||
if (selector.contains('{') || selector.contains('}')) return null
|
||||
|
||||
var includeDomains: MutableList<String>? = null
|
||||
var excludeDomains: MutableList<String>? = null
|
||||
val domainText = line.substring(0, separatorIndex).trim()
|
||||
if (domainText.isNotEmpty()) {
|
||||
for (entry in domainText.split(',')) {
|
||||
val domain = entry.trim().lowercase()
|
||||
if (domain.isEmpty()) continue
|
||||
if (domain.startsWith("~")) {
|
||||
val excluded = domain.substring(1)
|
||||
if (excluded.isNotEmpty()) {
|
||||
(excludeDomains ?: mutableListOf<String>().also { excludeDomains = it })
|
||||
.add(excluded)
|
||||
}
|
||||
} else {
|
||||
(includeDomains ?: mutableListOf<String>().also { includeDomains = it })
|
||||
.add(domain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return CosmeticRule(selector, includeDomains, excludeDomains, isException, hasTextPattern)
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits `selector:has-text(pattern)` into (`selector`, JS regex source). Only the simple,
|
||||
* common form is handled -- one `:has-text(...)` as the very last thing in the selector, with
|
||||
* a non-empty selector before it. Anything else (nested inside `:has()`, combined with other
|
||||
* procedural syntax, an empty base selector matching every element) returns null and the
|
||||
* caller falls back to discarding the rule, same as before this existed.
|
||||
*/
|
||||
private fun extractHasText(selector: String): Pair<String, String>? {
|
||||
val markerIndex = selector.indexOf(":has-text(")
|
||||
if (markerIndex <= 0) return null
|
||||
|
||||
val argStart = markerIndex + ":has-text(".length
|
||||
var depth = 1
|
||||
var index = argStart
|
||||
while (index < selector.length && depth > 0) {
|
||||
when (selector[index]) {
|
||||
'(' -> depth++
|
||||
')' -> depth--
|
||||
}
|
||||
if (depth > 0) index++
|
||||
}
|
||||
// The closing paren must be the last character: a trailing combinator or pseudo after
|
||||
// it means this is a more complex selector than the simple form handled here.
|
||||
if (depth != 0 || index != selector.length - 1) return null
|
||||
|
||||
val base = selector.substring(0, markerIndex).trim()
|
||||
if (base.isEmpty() || base.contains(":has-text(")) return null
|
||||
|
||||
var arg = selector.substring(argStart, index).trim()
|
||||
if (arg.isEmpty()) return null
|
||||
|
||||
val pattern = if (arg.length > 1 && arg.startsWith("/") && arg.lastIndexOf('/') > 0) {
|
||||
arg.substring(1, arg.lastIndexOf('/'))
|
||||
} else {
|
||||
Regex.escape(arg)
|
||||
}
|
||||
return base to pattern
|
||||
}
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
package app.seb3thehacker.gearslip.browser.adblock
|
||||
|
||||
import android.webkit.WebResourceResponse
|
||||
import java.io.ByteArrayInputStream
|
||||
|
||||
/**
|
||||
* A small subset of uBlock Origin's / AdGuard's named `$redirect=` resources.
|
||||
*
|
||||
* Blocking most tracking scripts outright is safe -- the page never had a live reference to
|
||||
* them, so nothing calls into the empty response [AdBlockManager.blockedResponse] already
|
||||
* serves. But a handful of scripts are different: page code assumes the object they define
|
||||
* (`ga`, `dataLayer`, `googletag`, ...) exists and calls it immediately, so an empty response
|
||||
* leaves that call throwing a `ReferenceError` that can break unrelated script on the same page.
|
||||
* These stubs define just enough of that surface to no-op safely, which is what the named
|
||||
* `$redirect=` resource is for in the filter list that requested it.
|
||||
*
|
||||
* Anything not in this map still gets blocked -- just with the generic typed-empty response
|
||||
* instead of an exact stub, same as before this existed.
|
||||
*/
|
||||
internal object RedirectResources {
|
||||
|
||||
private val EMPTY_JS = "".toByteArray()
|
||||
|
||||
private val GOOGLE_ANALYTICS_GA = """
|
||||
(function(){function g(){}var t=function(){t.c[t.q.push(arguments)-1]=null};
|
||||
t.q=[];t.c=[];window.ga=window.ga||t;window._gaq=window._gaq||{push:g};
|
||||
window.GoogleAnalyticsObject=window.GoogleAnalyticsObject||'ga';})();
|
||||
""".trimIndent().toByteArray()
|
||||
|
||||
private val GOOGLE_ANALYTICS_ANALYTICS = """
|
||||
(function(){function s(){}var g={
|
||||
create:function(){return{get:s,set:s,send:s}},getAll:function(){return[]},
|
||||
getByName:function(){return null},remove:s};
|
||||
window.ga=window.ga||function(){(window.ga.q=window.ga.q||[]).push(arguments)};
|
||||
window.ga.create=g.create;window.ga.getAll=g.getAll;window.ga.getByName=g.getByName;
|
||||
window.ga.remove=g.remove;window.gaplugins={Linker:function(){this.decorate=s;this.autoLink=s}};
|
||||
})();
|
||||
""".trimIndent().toByteArray()
|
||||
|
||||
private val GOOGLETAGMANAGER_GTM = """
|
||||
(function(){window.dataLayer=window.dataLayer||[];
|
||||
window.dataLayer.push=function(){};
|
||||
window.google_tag_manager=window.google_tag_manager||{};})();
|
||||
""".trimIndent().toByteArray()
|
||||
|
||||
private val GOOGLETAGSERVICES_GPT = """
|
||||
(function(){function s(){}function u(){return{addService:function(){return this},
|
||||
setTargeting:s,setCollapseEmptyDiv:s,addEventListener:function(){return this},
|
||||
removeEventListener:s,get:s,set:s,getSlotElementId:function(){return''},
|
||||
getSlotId:function(){return{getDomId:function(){return''}}}}}
|
||||
window.googletag=window.googletag||{};
|
||||
window.googletag.cmd=window.googletag.cmd||{push:function(fn){try{fn()}catch(e){}}};
|
||||
var pubads=u();
|
||||
window.googletag.pubads=function(){return pubads};
|
||||
window.googletag.defineSlot=function(){return u()};
|
||||
window.googletag.defineOutOfPageSlot=function(){return u()};
|
||||
window.googletag.enableServices=s;window.googletag.display=s;
|
||||
window.googletag.apiReady=true;})();
|
||||
""".trimIndent().toByteArray()
|
||||
|
||||
private val NOOP_CSS = "".toByteArray()
|
||||
private val NOOP_HTML = "<!doctype html><html><head></head><body></body></html>".toByteArray()
|
||||
private val NOOP_JSON = "{}".toByteArray()
|
||||
|
||||
private val STUBS: Map<String, Pair<String, ByteArray>> = buildMap {
|
||||
// Names as they appear across uBO's and AdGuard's resources.txt / redirect syntax.
|
||||
for (name in listOf("noopjs", "noop.js", "noop-js")) put(name, "application/javascript" to EMPTY_JS)
|
||||
for (name in listOf("noopcss", "noop.css", "noop-css")) put(name, "text/css" to NOOP_CSS)
|
||||
for (name in listOf("noop.txt", "nooptext")) put(name, "text/plain" to "".toByteArray())
|
||||
for (name in listOf("noop.json", "empty")) put(name, "application/json" to NOOP_JSON)
|
||||
for (name in listOf("noop.html", "click2load.html")) put(name, "text/html" to NOOP_HTML)
|
||||
for (name in listOf("google-analytics_ga.js", "google-analytics.com/ga.js", "ga.js"))
|
||||
put(name, "application/javascript" to GOOGLE_ANALYTICS_GA)
|
||||
for (name in listOf(
|
||||
"google-analytics_analytics.js", "google-analytics.com/analytics.js",
|
||||
"googletagmanager_gtag.js", "googletagmanager-gtag"
|
||||
)) put(name, "application/javascript" to GOOGLE_ANALYTICS_ANALYTICS)
|
||||
for (name in listOf("googletagmanager_gtm.js", "googletagmanager.com/gtm.js", "gtm.js"))
|
||||
put(name, "application/javascript" to GOOGLETAGMANAGER_GTM)
|
||||
for (name in listOf("googletagservices_gpt.js", "googletagservices.com/gpt.js", "gpt.js"))
|
||||
put(name, "application/javascript" to GOOGLETAGSERVICES_GPT)
|
||||
for (name in listOf("1x1.gif", "1x1-transparent.gif", "2x2.png", "3x2.png", "32x32.png"))
|
||||
put(name, "image/gif" to AdBlockManager.transparentGifBytes)
|
||||
}
|
||||
|
||||
/** Null when [name] is not one of the resources we ship a stub for. */
|
||||
fun response(name: String, headers: Map<String, String>): WebResourceResponse? {
|
||||
val stub = STUBS[name.lowercase()] ?: return null
|
||||
val (mimeType, body) = stub
|
||||
val encoding = if (mimeType.startsWith("image/")) null else "utf-8"
|
||||
return WebResourceResponse(mimeType, encoding, 200, "OK", headers, ByteArrayInputStream(body))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
package app.seb3thehacker.gearslip.browser.adblock
|
||||
|
||||
/**
|
||||
* YouTube ad blocking, plus the cosmetic cleanup that rides along with it.
|
||||
*
|
||||
* Filter lists cannot touch YouTube's video ads. The ad and the video are delivered by the same
|
||||
* hosts, so there is no request to block and no element to hide: the player decides to show an
|
||||
* ad because its own API response told it to. What can be done is what uBlock Origin's
|
||||
* scriptlets do — edit that response before the player reads it.
|
||||
*
|
||||
* Three hooks, all installed before any page script runs:
|
||||
* - `ytInitialPlayerResponse`, the JSON embedded in the page for the first video watched;
|
||||
* - `fetch`, used by the single-page app for every later video;
|
||||
* - `JSON.parse`, a backstop for whatever path the two above miss.
|
||||
*
|
||||
* Each strips the keys the player uses to schedule ads. A periodic sweep then clicks any skip
|
||||
* button and seeks past anything that still slipped through, which also covers ads stitched
|
||||
* into the stream server-side, where nothing client-side can remove them cleanly. All of this,
|
||||
* plus hiding the ad's own banner/in-feed markup, is gated on "Block YouTube video ads": it's
|
||||
* inherently maintenance-prone (YouTube changes these field and class names when it wants to,
|
||||
* and may respond with an anti-adblock notice), so it needs to be switchable off as a unit
|
||||
* without losing ordinary filter-list blocking.
|
||||
*
|
||||
* The Shorts shelf and filter chip row are a separate concern -- cosmetic preference, not ad
|
||||
* blocking -- so they sit behind their own "Custom site tweaks" setting instead, independent of
|
||||
* whether YouTube ad blocking is on. The floating menu button covering a page's own fixed
|
||||
* bottom bar is handled generically for every site in [CosmeticFilterBridge], not here.
|
||||
*/
|
||||
object YouTubeAdBlock {
|
||||
|
||||
val INJECTION_JS: String = """
|
||||
(function () {
|
||||
try {
|
||||
var host = location.hostname || '';
|
||||
if (!/(^|\.)(youtube\.com|youtube-nocookie\.com|youtubekids\.com)${'$'}/.test(host)) {
|
||||
return;
|
||||
}
|
||||
var bridge = window.${CosmeticFilterBridge.BRIDGE_OBJECT_NAME};
|
||||
if (!bridge) { return; }
|
||||
var adBlockingOn = typeof bridge.youtubeBlockingEnabled === 'function' &&
|
||||
bridge.youtubeBlockingEnabled(host);
|
||||
var tweaksOn = typeof bridge.customSiteTweaksEnabled === 'function' &&
|
||||
bridge.customSiteTweaksEnabled();
|
||||
if (!adBlockingOn && !tweaksOn) { return; }
|
||||
if (window.__gearslipYouTube) { return; }
|
||||
window.__gearslipYouTube = true;
|
||||
|
||||
var styleRules = '';
|
||||
|
||||
if (adBlockingOn) {
|
||||
// The fields the player consults to schedule ad breaks.
|
||||
var AD_KEYS = [
|
||||
'adPlacements', 'playerAds', 'adSlots', 'adBreakHeartbeatParams'
|
||||
];
|
||||
|
||||
var stripKeys = function (object) {
|
||||
if (!object || typeof object !== 'object') { return; }
|
||||
for (var i = 0; i < AD_KEYS.length; i++) {
|
||||
if (AD_KEYS[i] in object) {
|
||||
try { delete object[AD_KEYS[i]]; } catch (e) {}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Deliberately shallow. A player response is a large object and walking all of
|
||||
// it on every parse would cost more than the ads do.
|
||||
var prune = function (payload) {
|
||||
if (!payload || typeof payload !== 'object') { return payload; }
|
||||
if (Array.isArray(payload)) {
|
||||
for (var i = 0; i < payload.length && i < 50; i++) { prune(payload[i]); }
|
||||
return payload;
|
||||
}
|
||||
stripKeys(payload);
|
||||
if (payload.playerResponse) { stripKeys(payload.playerResponse); }
|
||||
if (payload.response) { stripKeys(payload.response); }
|
||||
if (payload.playerData) { stripKeys(payload.playerData); }
|
||||
return payload;
|
||||
};
|
||||
|
||||
// 1. The first video: YouTube assigns this from an inline script in the document.
|
||||
try {
|
||||
var initial;
|
||||
Object.defineProperty(window, 'ytInitialPlayerResponse', {
|
||||
configurable: true,
|
||||
get: function () { return initial; },
|
||||
set: function (value) { initial = prune(value); }
|
||||
});
|
||||
} catch (e) {}
|
||||
|
||||
// 2. Every later video in the single-page app.
|
||||
var originalFetch = window.fetch;
|
||||
if (typeof originalFetch === 'function') {
|
||||
window.fetch = function (input, init) {
|
||||
var url = '';
|
||||
try {
|
||||
url = (typeof input === 'string') ? input : ((input && input.url) || '');
|
||||
} catch (e) {}
|
||||
var pending = originalFetch.apply(this, arguments);
|
||||
// Only the player call carries ad scheduling. /next is the comments and
|
||||
// related-video payload: large, and re-serialising it buys nothing.
|
||||
if (url.indexOf('/youtubei/v1/player') < 0) {
|
||||
return pending;
|
||||
}
|
||||
return pending.then(function (response) {
|
||||
return response.text().then(function (text) {
|
||||
var body = text;
|
||||
// Cheap string test first: most responses have nothing to strip, and
|
||||
// parsing plus re-serialising a player response is not free.
|
||||
if (text.indexOf('"adPlacements"') >= 0 ||
|
||||
text.indexOf('"playerAds"') >= 0 ||
|
||||
text.indexOf('"adSlots"') >= 0) {
|
||||
try {
|
||||
var data = JSON.parse(text);
|
||||
prune(data);
|
||||
body = JSON.stringify(data);
|
||||
} catch (e) {
|
||||
body = text;
|
||||
}
|
||||
}
|
||||
// Content-Length describes the original bytes, and Content-Encoding a
|
||||
// compression this body no longer has. Copying either onto the edited
|
||||
// body leaves the player waiting on bytes that never arrive.
|
||||
var headers;
|
||||
try {
|
||||
headers = new Headers(response.headers);
|
||||
headers.delete('content-length');
|
||||
headers.delete('content-encoding');
|
||||
} catch (e) {
|
||||
headers = undefined;
|
||||
}
|
||||
return new Response(body, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: headers
|
||||
});
|
||||
}).catch(function () { return response; });
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
// 3. Backstop for any path the first two miss.
|
||||
var originalParse = JSON.parse;
|
||||
JSON.parse = function (text, reviver) {
|
||||
var result = originalParse.apply(this, arguments);
|
||||
try { prune(result); } catch (e) {}
|
||||
return result;
|
||||
};
|
||||
|
||||
// Hide the banner, overlay and in-feed ads, which are ordinary elements. The
|
||||
// :has() rules collapse the wrapping feed section too, instead of leaving an
|
||||
// empty gap where the ad used to be.
|
||||
styleRules +=
|
||||
'ytd-promoted-sparkles-web-renderer,ytd-promoted-video-renderer,' +
|
||||
'ytd-display-ad-renderer,ytd-ad-slot-renderer,ytd-in-feed-ad-layout-renderer,' +
|
||||
'ytd-banner-promo-renderer,ytd-statement-banner-renderer,' +
|
||||
'ytd-companion-slot-renderer,ytd-action-companion-ad-renderer,' +
|
||||
'ytm-promoted-video-renderer,ytm-companion-ad-renderer,' +
|
||||
'ytm-promoted-sparkles-web-renderer,ytm-search-promoted-video-renderer,' +
|
||||
'.ytp-ad-overlay-container,.ytp-ad-overlay-slot,.ytp-ad-message-container,' +
|
||||
'#masthead-ad,#player-ads,' +
|
||||
'ytm-item-section-renderer:has(>*>ytm-companion-ad-renderer),' +
|
||||
'ytm-item-section-renderer:has(>*>ytm-promoted-sparkles-web-renderer),' +
|
||||
'ytm-item-section-renderer:has(>*>ytm-promoted-video-renderer),' +
|
||||
'ytm-item-section-renderer:has(>*>ytm-search-promoted-video-renderer),' +
|
||||
'ytm-item-section-renderer:has(>*>ytd-ad-slot-renderer),' +
|
||||
// The "desktop site" grid feed uses different wrapper elements entirely: each
|
||||
// card is a ytd-rich-item-renderer grid cell, so hiding just the ad renderer
|
||||
// inside it still leaves an empty cell-shaped hole in the grid.
|
||||
'ytd-rich-item-renderer:has(ytd-display-ad-renderer),' +
|
||||
'ytd-rich-item-renderer:has(ytd-ad-slot-renderer),' +
|
||||
'ytd-rich-item-renderer:has(ytd-promoted-sparkles-web-renderer),' +
|
||||
'ytd-rich-item-renderer:has(ytd-statement-banner-renderer),' +
|
||||
'ytd-rich-item-renderer:has(ytd-banner-promo-renderer),' +
|
||||
// Ads can also occupy a whole grid row, or a section above/between rows, not
|
||||
// just a single cell -- same empty-hole problem, one level up each time.
|
||||
'ytd-rich-grid-row:has(ytd-display-ad-renderer),' +
|
||||
'ytd-rich-grid-row:has(ytd-ad-slot-renderer),' +
|
||||
'ytd-rich-grid-row:has(ytd-promoted-sparkles-web-renderer),' +
|
||||
'ytd-rich-grid-row:has(ytd-statement-banner-renderer),' +
|
||||
'ytd-rich-grid-row:has(ytd-banner-promo-renderer),' +
|
||||
'ytd-rich-section-renderer:has(ytd-ad-slot-renderer),' +
|
||||
'ytd-rich-section-renderer:has(ytd-display-ad-renderer),' +
|
||||
'ytd-rich-section-renderer:has(ytd-statement-banner-renderer),' +
|
||||
'ytd-rich-section-renderer:has(ytd-banner-promo-renderer),' +
|
||||
'ytd-ad-slot-renderer,ytd-primetime-promo-renderer' +
|
||||
'{display:none!important}';
|
||||
|
||||
// Sweep for anything still playing: press skip, or seek to the end of the ad.
|
||||
var adTicks = 0;
|
||||
var sweep = function () {
|
||||
try {
|
||||
var skip = document.querySelector(
|
||||
'.ytp-ad-skip-button,.ytp-ad-skip-button-modern,.ytp-skip-ad-button,' +
|
||||
'.ytp-ad-skip-button-container button'
|
||||
);
|
||||
if (skip) { skip.click(); }
|
||||
var player = document.querySelector('.html5-video-player');
|
||||
if (player && player.classList.contains('ad-showing')) {
|
||||
// Only seek once the ad state has held for two ticks. Seeking during the
|
||||
// player's own transition makes it re-buffer, which looks like a stall.
|
||||
adTicks++;
|
||||
var video = player.querySelector('video');
|
||||
if (adTicks >= 2 && video && isFinite(video.duration) && video.duration > 0) {
|
||||
video.currentTime = video.duration;
|
||||
}
|
||||
} else {
|
||||
adTicks = 0;
|
||||
}
|
||||
} catch (e) {}
|
||||
};
|
||||
setInterval(sweep, 700);
|
||||
}
|
||||
|
||||
if (tweaksOn) {
|
||||
styleRules +=
|
||||
// The mobile site's fixed Home/Shorts/You bar and the "desktop site" grid's
|
||||
// reel shelf section it sits inside of.
|
||||
'ytm-pivot-bar-renderer,' +
|
||||
'ytd-rich-section-renderer:has(ytd-reel-shelf-renderer),' +
|
||||
// Shorts: a different, faster-cut format than the rest of the feed, and its
|
||||
// vertical-video shelf doesn't fit a landscape car display well either way.
|
||||
'ytm-reel-shelf-renderer,ytm-rich-shelf-renderer:has(ytm-reel-item-renderer),' +
|
||||
'ytm-reel-item-renderer,ytd-reel-shelf-renderer,' +
|
||||
// The "All / Podcasts / News / ..." filter chip row above the feed.
|
||||
'ytm-chip-bar-renderer,ytm-feed-filter-chip-bar-renderer' +
|
||||
'{display:none!important}';
|
||||
}
|
||||
|
||||
if (styleRules) {
|
||||
var style = document.createElement('style');
|
||||
style.setAttribute('data-gearslip', 'youtube');
|
||||
style.textContent = styleRules;
|
||||
var root = document.head || document.documentElement;
|
||||
if (root) { root.appendChild(style); }
|
||||
}
|
||||
} catch (e) {}
|
||||
})();
|
||||
""".trimIndent()
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import app.seb3thehacker.gearslip.browser.Bookmark
|
||||
import app.seb3thehacker.gearslip.browser.BrowserSettings
|
||||
import app.seb3thehacker.gearslip.browser.SiteIcons
|
||||
import app.seb3thehacker.gearslip.car.theme.GsIconBox
|
||||
import app.seb3thehacker.gearslip.car.theme.GsTone
|
||||
import app.seb3thehacker.gearslip.car.theme.gsColors
|
||||
import app.seb3thehacker.gearslip.car.theme.gsShape
|
||||
|
||||
/** How wide a tile is, icon and label together; the grid fits as many as the screen allows. */
|
||||
private val TILE_WIDTH = 112.dp
|
||||
private val ICON_SIZE = 64.dp
|
||||
|
||||
/**
|
||||
* What the browser shows before a page is open: a search bar, and the bookmarks as big tiles.
|
||||
* Holding a tile shows a remove button on every tile, as a launcher does; tapping anywhere
|
||||
* else, or Done, puts them away. Adding happens from the browser's star, or on the phone.
|
||||
*/
|
||||
@Composable
|
||||
internal fun BrowserStartPage(onOpen: (String) -> Unit, onSearch: () -> Unit, onAdd: () -> Unit) {
|
||||
val bookmarks by BrowserSettings.bookmarks.collectAsState()
|
||||
var editing by remember { mutableStateOf(false) }
|
||||
if (bookmarks.isEmpty()) editing = false
|
||||
|
||||
Column(Modifier.fillMaxSize().padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
SearchBar(onSearch)
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(TILE_WIDTH),
|
||||
contentPadding = PaddingValues(bottom = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
items(bookmarks, key = { it.url }) { bookmark ->
|
||||
BookmarkTile(
|
||||
bookmark,
|
||||
editing = editing,
|
||||
onClick = { if (editing) editing = false else onOpen(bookmark.url) },
|
||||
onHold = { editing = true },
|
||||
onRemove = { BrowserSettings.removeBookmark(bookmark.url) },
|
||||
)
|
||||
}
|
||||
item(key = "add") {
|
||||
if (editing) {
|
||||
Tile(stringResource(R.string.web_done), onClick = { editing = false }) {
|
||||
Icon(Icons.Filled.Close, null, Modifier.size(28.dp))
|
||||
}
|
||||
} else {
|
||||
Tile(stringResource(R.string.web_add_bookmark), onClick = onAdd) {
|
||||
Icon(Icons.Filled.Add, null, Modifier.size(28.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Looks like a text field, but opens the car keyboard: the same as the header's Search. */
|
||||
@Composable
|
||||
private fun SearchBar(onSearch: () -> Unit) {
|
||||
val colors = gsColors(GsTone.Neutral)
|
||||
GsIconBox(onSearch, Modifier.fillMaxWidth().height(56.dp), colors, shape = CircleShape) {
|
||||
Row(Modifier.fillMaxWidth().padding(horizontal = 20.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Filled.Search, null, Modifier.size(24.dp))
|
||||
Text(
|
||||
stringResource(R.string.web_search),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(start = 12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BookmarkTile(bookmark: Bookmark, editing: Boolean, onClick: () -> Unit, onHold: () -> Unit, onRemove: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val host = bookmark.host
|
||||
val version by SiteIcons.version.collectAsState()
|
||||
LaunchedEffect(host) { SiteIcons.fetch(context, host) }
|
||||
val icon = remember(host, version) { SiteIcons.icon(context, host) }
|
||||
Box {
|
||||
Tile(bookmark.title, onClick = onClick, onHold = onHold) {
|
||||
if (icon != null) {
|
||||
Image(icon.asImageBitmap(), null, Modifier.size(40.dp).clip(gsShape()))
|
||||
} else {
|
||||
Text(
|
||||
bookmark.title.firstOrNull()?.uppercase() ?: "?",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (editing) {
|
||||
GsIconBox(
|
||||
onRemove,
|
||||
Modifier.align(Alignment.TopEnd).offset(x = (-14).dp, y = (-6).dp).size(32.dp),
|
||||
gsColors(GsTone.Danger),
|
||||
shape = CircleShape,
|
||||
) {
|
||||
Icon(Icons.Filled.Close, stringResource(R.string.web_remove_bookmark, bookmark.title), Modifier.size(18.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** An icon box with its label under it, the start page's one shape. */
|
||||
@Composable
|
||||
private fun Tile(label: String, onClick: () -> Unit, onHold: (() -> Unit)? = null, icon: @Composable () -> Unit) {
|
||||
Column(Modifier.width(TILE_WIDTH), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
GsIconBox(onClick, Modifier.size(ICON_SIZE), gsColors(GsTone.Tonal), onLongClick = onHold) { icon() }
|
||||
Text(
|
||||
label,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,11 @@ import android.webkit.JavascriptInterface
|
||||
import android.webkit.WebChromeClient
|
||||
import android.widget.FrameLayout
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.size
|
||||
import app.seb3thehacker.gearslip.car.theme.GsIconBox
|
||||
import app.seb3thehacker.gearslip.car.theme.GsTone
|
||||
import app.seb3thehacker.gearslip.car.theme.gsColors
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import app.seb3thehacker.gearslip.car.theme.GsIconButton
|
||||
import android.webkit.WebResourceError
|
||||
@@ -33,14 +38,29 @@ import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import app.seb3thehacker.gearslip.GearslipLog
|
||||
import org.json.JSONObject
|
||||
import android.graphics.Bitmap
|
||||
import androidx.car.app.CarToast
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import app.seb3thehacker.gearslip.browser.BrowserSettings
|
||||
import app.seb3thehacker.gearslip.browser.SiteIcons
|
||||
import app.seb3thehacker.gearslip.browser.adblock.AdBlockManager
|
||||
import app.seb3thehacker.gearslip.browser.adblock.CosmeticFilterBridge
|
||||
import app.seb3thehacker.gearslip.browser.adblock.YouTubeAdBlock
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import androidx.compose.material.icons.filled.Star
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Home
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
||||
|
||||
/**
|
||||
* A browser inside the car UI. [content] is a URL, or raw HTML when it does not look like one.
|
||||
* The bottom bar's Back walks the page history first and only leaves the app at the start.
|
||||
* A browser inside the car UI. [content] is a URL, or raw HTML when it does not look like one;
|
||||
* blank opens [BrowserStartPage], the search bar and bookmark tiles. The bottom bar's Back walks
|
||||
* the page history first, then returns to the start page, and only then leaves the app.
|
||||
*
|
||||
* Every request a page makes passes [AdBlockManager] first, and [CosmeticFilterBridge] hides the
|
||||
* ad slots that are left (issue #85). Both are switched from the phone's browser settings.
|
||||
*
|
||||
* A page's own text inputs can't call up the phone's IME: the car UI lives in a Presentation on
|
||||
* a virtual display, and the platform's soft keyboard has nowhere sensible to appear there (see
|
||||
@@ -67,6 +87,29 @@ fun WebApp(content: String) {
|
||||
var canGoForward by remember { mutableStateOf(false) }
|
||||
// The address being typed, or null while the header shows the page title.
|
||||
var address by remember { mutableStateOf<String?>(null) }
|
||||
// An address being typed to save as a bookmark, from the start page's Add tile.
|
||||
var newBookmark by remember { mutableStateOf<String?>(null) }
|
||||
// The start page covers the WebView until something is opened.
|
||||
var atStart by remember { mutableStateOf(content.isBlank()) }
|
||||
var pageUrl by remember { mutableStateOf<String?>(null) }
|
||||
val bookmarks by BrowserSettings.bookmarks.collectAsState()
|
||||
val context = LocalContext.current
|
||||
LaunchedEffect(Unit) {
|
||||
AdBlockManager.ensureInitialized(context)
|
||||
AdBlockManager.refreshIfDue(context)
|
||||
}
|
||||
val open: (String) -> Unit = { url ->
|
||||
atStart = false
|
||||
web?.loadUrl(url)
|
||||
}
|
||||
val goHome: () -> Unit = {
|
||||
if (content.isBlank()) {
|
||||
atStart = true
|
||||
pageTitle = ""
|
||||
} else {
|
||||
web?.let { view -> loadContent(view, content) }
|
||||
}
|
||||
}
|
||||
|
||||
// Leaving the browser ends full screen, whoever started it.
|
||||
DisposableEffect(Unit) { onDispose { CarFullscreen.exit() } }
|
||||
@@ -78,15 +121,18 @@ fun WebApp(content: String) {
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(web) {
|
||||
DisposableEffect(web, atStart) {
|
||||
navigator.backInterceptor = {
|
||||
val view = web
|
||||
if (CarFullscreen.active) {
|
||||
CarFullscreen.exit()
|
||||
true
|
||||
} else if (view != null && view.canGoBack()) {
|
||||
} else if (!atStart && view != null && view.canGoBack()) {
|
||||
view.goBack()
|
||||
true
|
||||
} else if (!atStart && content.isBlank()) {
|
||||
goHome()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
@@ -97,19 +143,39 @@ fun WebApp(content: String) {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
// Back steps through the page's history first (the interceptor above), then leaves.
|
||||
if (!fullscreen) {
|
||||
ScreenHeader(pageTitle.ifEmpty { stringResource(R.string.app_browser) }, Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
|
||||
ScreenHeader(pageTitle.takeUnless { atStart }.orEmpty().ifEmpty { stringResource(R.string.app_browser) }, Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
|
||||
val url = pageUrl
|
||||
if (!atStart && url != null && url.startsWith("http")) {
|
||||
val saved = remember(bookmarks, url) { BrowserSettings.isBookmarked(url) }
|
||||
GsIconBox(
|
||||
{
|
||||
if (saved) BrowserSettings.removeBookmark(url) else BrowserSettings.addBookmark(pageTitle, url)
|
||||
CarToasts.show(BrowserSettings, context.getString(if (saved) R.string.web_bookmark_removed else R.string.web_bookmark_added), CarToast.LENGTH_SHORT)
|
||||
},
|
||||
Modifier.size(48.dp),
|
||||
gsColors(if (saved) GsTone.Tonal else GsTone.Neutral),
|
||||
latched = saved,
|
||||
) {
|
||||
androidx.compose.material3.Icon(
|
||||
androidx.compose.material.icons.Icons.Filled.Star,
|
||||
stringResource(if (saved) R.string.web_remove_this_bookmark else R.string.web_bookmark_this),
|
||||
Modifier.size(24.dp),
|
||||
tint = if (saved) MaterialTheme.colorScheme.primary else androidx.compose.material3.LocalContentColor.current.copy(alpha = 0.6f),
|
||||
)
|
||||
}
|
||||
}
|
||||
GsIconButton(androidx.compose.material.icons.Icons.Filled.Search, stringResource(R.string.web_search), {
|
||||
fieldText = null
|
||||
address = ""
|
||||
})
|
||||
if (canGoForward) {
|
||||
if (canGoForward && !atStart) {
|
||||
GsIconButton(androidx.compose.material.icons.Icons.AutoMirrored.Filled.ArrowForward, stringResource(R.string.web_forward), { web?.goForward() })
|
||||
}
|
||||
GsIconButton(androidx.compose.material.icons.Icons.Filled.Refresh, stringResource(R.string.web_reload), { web?.reload() })
|
||||
GsIconButton(androidx.compose.material.icons.Icons.Filled.Home, stringResource(R.string.web_start_page), {
|
||||
web?.let { view -> loadContent(view, content) }
|
||||
})
|
||||
GsIconButton(MediaIcons.Fullscreen, stringResource(R.string.web_fullscreen), CarFullscreen::enter)
|
||||
if (!atStart) {
|
||||
GsIconButton(androidx.compose.material.icons.Icons.Filled.Refresh, stringResource(R.string.web_reload), { web?.reload() })
|
||||
GsIconButton(androidx.compose.material.icons.Icons.Filled.Home, stringResource(R.string.web_start_page), goHome)
|
||||
GsIconButton(MediaIcons.Fullscreen, stringResource(R.string.web_fullscreen), CarFullscreen::enter)
|
||||
}
|
||||
}
|
||||
}
|
||||
Box(Modifier.weight(1f).fillMaxSize()) {
|
||||
@@ -117,12 +183,18 @@ fun WebApp(content: String) {
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
factory = { context ->
|
||||
WebView(context).apply {
|
||||
// The page a request belongs to, for telling first-party requests from
|
||||
// third-party ones: shouldInterceptRequest runs off the UI thread, where a
|
||||
// WebView's own url can't be read.
|
||||
val documentUrl = AtomicReference<String?>(null)
|
||||
CosmeticFilterBridge.attach(this)
|
||||
settings.javaScriptEnabled = true
|
||||
settings.domStorageEnabled = true
|
||||
settings.loadWithOverviewMode = true
|
||||
settings.useWideViewPort = true
|
||||
webViewClient = LoggingClient(ring) { view ->
|
||||
webViewClient = LoggingClient(ring, documentUrl, onStarted = { pageUrl = it }) { view ->
|
||||
canGoForward = view.canGoForward()
|
||||
pageUrl = view.url
|
||||
pageTitle = view.title.orEmpty().takeUnless { it.startsWith("http") || it == "about:blank" }.orEmpty()
|
||||
}
|
||||
webChromeClient = object : WebChromeClient() {
|
||||
@@ -130,6 +202,12 @@ fun WebApp(content: String) {
|
||||
pageTitle = title.orEmpty().takeUnless { it.startsWith("http") || it == "about:blank" }.orEmpty()
|
||||
}
|
||||
|
||||
// Kept for the start page's tiles, so a bookmark shows its own icon.
|
||||
override fun onReceivedIcon(view: WebView?, icon: Bitmap?) {
|
||||
val host = view?.url?.let { runCatching { android.net.Uri.parse(it).host }.getOrNull() }?.removePrefix("www.")
|
||||
if (icon != null && host != null) SiteIcons.save(view.context, host, icon)
|
||||
}
|
||||
|
||||
override fun onShowCustomView(view: View, callback: CustomViewCallback) {
|
||||
videoMadeFullscreen = !CarFullscreen.active
|
||||
pageFullscreen = view to callback
|
||||
@@ -146,7 +224,7 @@ fun WebApp(content: String) {
|
||||
FieldFocusBridge(onFocus = { fieldText = it }, onBlur = { fieldText = null }),
|
||||
"GearslipField",
|
||||
)
|
||||
loadContent(this, content)
|
||||
if (content.isNotBlank()) loadContent(this, content)
|
||||
web = this
|
||||
}
|
||||
},
|
||||
@@ -154,6 +232,18 @@ fun WebApp(content: String) {
|
||||
update = { it.settings.isAlgorithmicDarkeningAllowed = darkOutside },
|
||||
onRelease = { it.destroy() },
|
||||
)
|
||||
if (atStart) {
|
||||
androidx.compose.foundation.layout.Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surface)) {
|
||||
BrowserStartPage(
|
||||
onOpen = open,
|
||||
onSearch = {
|
||||
fieldText = null
|
||||
address = ""
|
||||
},
|
||||
onAdd = { newBookmark = "https://" },
|
||||
)
|
||||
}
|
||||
}
|
||||
pageFullscreen?.let { (view, _) ->
|
||||
androidx.compose.runtime.key(view) {
|
||||
AndroidView(
|
||||
@@ -175,12 +265,32 @@ fun WebApp(content: String) {
|
||||
text = typed,
|
||||
onTextChange = { address = it },
|
||||
onSubmit = {
|
||||
web?.loadUrl(addressToUrl(typed))
|
||||
open(BrowserSettings.addressToUrl(typed))
|
||||
address = null
|
||||
},
|
||||
onDismiss = { address = null },
|
||||
)
|
||||
}
|
||||
newBookmark?.let { typed ->
|
||||
CarKeyboard(
|
||||
text = typed,
|
||||
onTextChange = { newBookmark = it },
|
||||
onSubmit = {
|
||||
// Only an address: words typed here would otherwise save a search as a bookmark.
|
||||
val text = typed.trim()
|
||||
val url = when {
|
||||
text.startsWith("http://") || text.startsWith("https://") -> text
|
||||
' ' !in text && '.' in text -> "https://$text"
|
||||
else -> null
|
||||
}
|
||||
if (url != null && BrowserSettings.addBookmark("", url)) {
|
||||
CarToasts.show(BrowserSettings, context.getString(R.string.web_bookmark_added), CarToast.LENGTH_SHORT)
|
||||
}
|
||||
newBookmark = null
|
||||
},
|
||||
onDismiss = { newBookmark = null },
|
||||
)
|
||||
}
|
||||
fieldText?.let { text ->
|
||||
CarKeyboard(
|
||||
text = text,
|
||||
@@ -207,18 +317,27 @@ private fun loadContent(view: WebView, content: String) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Something that looks like an address opens as one; anything else is searched for. */
|
||||
private fun addressToUrl(typed: String): String {
|
||||
val text = typed.trim()
|
||||
return when {
|
||||
text.startsWith("http://") || text.startsWith("https://") -> text
|
||||
' ' !in text && '.' in text -> "https://$text"
|
||||
else -> "https://duckduckgo.com/?q=" + java.net.URLEncoder.encode(text, "UTF-8")
|
||||
private class LoggingClient(
|
||||
private val ring: String,
|
||||
private val documentUrl: AtomicReference<String?>,
|
||||
private val onStarted: (String?) -> Unit,
|
||||
private val onLoaded: (WebView) -> Unit,
|
||||
) : WebViewClient() {
|
||||
override fun shouldInterceptRequest(view: WebView?, request: WebResourceRequest?): WebResourceResponse? =
|
||||
request?.let { AdBlockManager.response(it, documentUrl.get()) }
|
||||
|
||||
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
|
||||
documentUrl.set(url)
|
||||
onStarted(url)
|
||||
// Without document-start scripts, inject as early as this WebView allows.
|
||||
if (CosmeticFilterBridge.needsPageStartFallback) {
|
||||
view?.evaluateJavascript(CosmeticFilterBridge.INJECTION_JS, null)
|
||||
view?.evaluateJavascript(YouTubeAdBlock.INJECTION_JS, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class LoggingClient(private val ring: String, private val onLoaded: (WebView) -> Unit) : WebViewClient() {
|
||||
override fun onPageFinished(view: WebView?, url: String?) {
|
||||
documentUrl.set(url)
|
||||
GearslipLog.i("car web page loaded: $url")
|
||||
view?.let(onLoaded)
|
||||
view?.evaluateJavascript(FOCUS_TRACKING_JS, null)
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
package app.seb3thehacker.gearslip.ui
|
||||
|
||||
import android.text.format.DateUtils
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import app.seb3thehacker.gearslip.browser.BrowserSettings
|
||||
import app.seb3thehacker.gearslip.browser.SearchEngine
|
||||
import app.seb3thehacker.gearslip.browser.adblock.AdBlockManager
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* The phone's settings for the car's browser (issue #85): its bookmarks, where searches go, and
|
||||
* the ad blocker with its filter lists. Typing addresses is easier here than on the car screen.
|
||||
*/
|
||||
@Composable
|
||||
internal fun BrowserSettingsRows(webPageRow: @Composable () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
LaunchedEffect(Unit) { AdBlockManager.ensureInitialized(context) }
|
||||
val bookmarks by BrowserSettings.bookmarks.collectAsState()
|
||||
val engine by BrowserSettings.searchEngine.collectAsState()
|
||||
val adBlock by BrowserSettings.adBlock.collectAsState()
|
||||
val youTube by BrowserSettings.youTubeAdBlock.collectAsState()
|
||||
val lists by BrowserSettings.filterLists.collectAsState()
|
||||
var addingBookmark by remember { mutableStateOf(false) }
|
||||
var addingList by remember { mutableStateOf(false) }
|
||||
|
||||
webPageRow()
|
||||
SettingsDivider()
|
||||
SearchEngineRow(engine)
|
||||
|
||||
SettingsDivider()
|
||||
Text(
|
||||
stringResource(R.string.browser_bookmarks),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 12.dp),
|
||||
)
|
||||
bookmarks.forEach { bookmark ->
|
||||
SettingsRow(
|
||||
bookmark.title,
|
||||
subtitle = bookmark.host,
|
||||
trailing = {
|
||||
IconButton({ BrowserSettings.removeBookmark(bookmark.url) }) {
|
||||
Icon(Icons.Filled.Delete, stringResource(R.string.web_remove_bookmark, bookmark.title))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
SettingsRow(
|
||||
stringResource(R.string.browser_add_bookmark),
|
||||
Modifier.clickable { addingBookmark = true },
|
||||
subtitle = stringResource(R.string.browser_bookmarks_note),
|
||||
icon = Icons.Filled.Add,
|
||||
)
|
||||
|
||||
SettingsDivider()
|
||||
SwitchRow(stringResource(R.string.browser_adblock), adBlock, BrowserSettings::setAdBlock, stringResource(R.string.browser_adblock_note))
|
||||
if (adBlock) {
|
||||
SettingsDivider()
|
||||
SwitchRow(stringResource(R.string.browser_youtube), youTube, BrowserSettings::setYouTubeAdBlock, stringResource(R.string.browser_youtube_note))
|
||||
SettingsDivider()
|
||||
Text(
|
||||
stringResource(R.string.browser_filter_lists),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 12.dp),
|
||||
)
|
||||
lists.forEach { list ->
|
||||
SettingsRow(
|
||||
list.title,
|
||||
Modifier.clickable {
|
||||
BrowserSettings.setListEnabled(list.id, !list.enabled)
|
||||
AdBlockManager.reload(context)
|
||||
},
|
||||
subtitle = if (list.builtIn) null else list.url,
|
||||
leading = { Checkbox(list.enabled, onCheckedChange = null) },
|
||||
trailing = if (list.builtIn) null else {
|
||||
{
|
||||
IconButton({
|
||||
AdBlockManager.forget(context, list)
|
||||
BrowserSettings.removeCustomList(list.id)
|
||||
AdBlockManager.reload(context)
|
||||
}) { Icon(Icons.Filled.Delete, stringResource(R.string.browser_remove)) }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
SettingsRow(stringResource(R.string.browser_add_list), Modifier.clickable { addingList = true }, icon = Icons.Filled.Add)
|
||||
UpdateListsRow()
|
||||
}
|
||||
|
||||
if (addingBookmark) {
|
||||
BookmarkDialog(onDismiss = { addingBookmark = false }) { title, url ->
|
||||
BrowserSettings.addBookmark(title, url).also { if (it) addingBookmark = false }
|
||||
}
|
||||
}
|
||||
if (addingList) {
|
||||
AddressDialog(
|
||||
title = stringResource(R.string.browser_add_list),
|
||||
label = stringResource(R.string.browser_list_address),
|
||||
note = stringResource(R.string.browser_list_note),
|
||||
onDismiss = { addingList = false },
|
||||
) { url ->
|
||||
BrowserSettings.addCustomList(url).also { if (it) addingList = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SearchEngineRow(engine: SearchEngine) {
|
||||
var menu by remember { mutableStateOf(false) }
|
||||
SettingsRow(
|
||||
stringResource(R.string.browser_search_engine),
|
||||
Modifier.clickable { menu = true },
|
||||
trailing = {
|
||||
Box {
|
||||
Text(engine.label, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.primary)
|
||||
DropdownMenu(menu, onDismissRequest = { menu = false }) {
|
||||
SearchEngine.entries.forEach { choice ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(choice.label, fontWeight = if (choice == engine) FontWeight.Bold else FontWeight.Normal) },
|
||||
onClick = {
|
||||
BrowserSettings.setSearchEngine(choice)
|
||||
menu = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** How many rules are loaded and when the lists last came down, with a button to fetch them now. */
|
||||
@Composable
|
||||
private fun UpdateListsRow() {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
var updating by remember { mutableStateOf(false) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
// Bumped after an update, so the counts and the date are read again.
|
||||
var refreshed by remember { mutableIntStateOf(0) }
|
||||
val rules = remember(refreshed, updating) { AdBlockManager.networkRuleCount + AdBlockManager.cosmeticRuleCount }
|
||||
val updatedAt = remember(refreshed, updating) { AdBlockManager.lastUpdatedAt(context) }
|
||||
val status = when {
|
||||
updating -> stringResource(R.string.browser_updating)
|
||||
error != null -> stringResource(R.string.browser_update_failed, error.orEmpty())
|
||||
updatedAt > 0 -> stringResource(R.string.browser_lists_status, rules, DateUtils.getRelativeTimeSpanString(updatedAt).toString())
|
||||
else -> stringResource(R.string.browser_lists_never, rules)
|
||||
}
|
||||
SettingsRow(
|
||||
stringResource(R.string.browser_update_lists),
|
||||
Modifier.clickable(enabled = !updating) {
|
||||
updating = true
|
||||
error = null
|
||||
scope.launch {
|
||||
val result = withContext(Dispatchers.IO) { AdBlockManager.refreshLists(context) }
|
||||
error = (result as? AdBlockManager.RefreshResult.Failure)?.message
|
||||
updating = false
|
||||
refreshed++
|
||||
}
|
||||
},
|
||||
subtitle = status,
|
||||
icon = Icons.Filled.Refresh,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SwitchRow(title: String, on: Boolean, onChange: (Boolean) -> Unit, note: String) {
|
||||
SettingsRow(title, Modifier.clickable { onChange(!on) }, subtitle = note, trailing = { Switch(checked = on, onCheckedChange = null) })
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BookmarkDialog(onDismiss: () -> Unit, onSave: (String, String) -> Boolean) {
|
||||
var title by remember { mutableStateOf("") }
|
||||
var url by remember { mutableStateOf("https://") }
|
||||
var invalid by remember { mutableStateOf(false) }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.browser_add_bookmark)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
OutlinedTextField(title, { title = it }, label = { Text(stringResource(R.string.browser_bookmark_name)) }, singleLine = true)
|
||||
OutlinedTextField(
|
||||
url, { url = it; invalid = false },
|
||||
label = { Text(stringResource(R.string.browser_bookmark_address)) },
|
||||
singleLine = true,
|
||||
isError = invalid,
|
||||
supportingText = if (invalid) ({ Text(stringResource(R.string.browser_shared_not_url)) }) else null,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton({ invalid = !onSave(title, withScheme(url)) }) { Text(stringResource(R.string.save)) } },
|
||||
dismissButton = { TextButton(onDismiss) { Text(stringResource(R.string.cancel)) } },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AddressDialog(title: String, label: String, note: String, onDismiss: () -> Unit, onSave: (String) -> Boolean) {
|
||||
var url by remember { mutableStateOf("https://") }
|
||||
var invalid by remember { mutableStateOf(false) }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(title) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(note)
|
||||
OutlinedTextField(
|
||||
url, { url = it; invalid = false },
|
||||
label = { Text(label) },
|
||||
singleLine = true,
|
||||
isError = invalid,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton({ invalid = !onSave(withScheme(url)) }) { Text(stringResource(R.string.save)) } },
|
||||
dismissButton = { TextButton(onDismiss) { Text(stringResource(R.string.cancel)) } },
|
||||
)
|
||||
}
|
||||
|
||||
/** "example.com" is what someone typing an address on a phone meant. */
|
||||
private fun withScheme(text: String): String {
|
||||
val t = text.trim()
|
||||
return if ("://" in t || t.isEmpty()) t else "https://$t"
|
||||
}
|
||||
@@ -64,6 +64,7 @@ enum class CarGroup(@param:StringRes val title: Int) {
|
||||
NAV_BAR(R.string.car_group_nav_bar),
|
||||
SOUND(R.string.car_group_sound),
|
||||
PLACES(R.string.places_title),
|
||||
BROWSER(R.string.app_browser),
|
||||
WARNINGS(R.string.car_group_warnings),
|
||||
MORE(R.string.car_group_more),
|
||||
}
|
||||
@@ -151,6 +152,8 @@ internal fun HomeCarSettings(onOpen: (CarGroup) -> Unit, modifier: Modifier = Mo
|
||||
val playerLeft by CarSettings.playerLeft.collectAsState()
|
||||
val theme by CarSettings.appTheme.collectAsState()
|
||||
val experimental by CarSettings.experimentalFeaturesEnabled.collectAsState()
|
||||
val browserEngine by app.seb3thehacker.gearslip.browser.BrowserSettings.searchEngine.collectAsState()
|
||||
val browserAdBlock by app.seb3thehacker.gearslip.browser.BrowserSettings.adBlock.collectAsState()
|
||||
|
||||
val opens = openChoices()
|
||||
val sizes = sizeChoices()
|
||||
@@ -180,8 +183,11 @@ internal fun HomeCarSettings(onOpen: (CarGroup) -> Unit, modifier: Modifier = Mo
|
||||
str(if (bluetoothWarning && !pipeAudio) R.string.summary_bluetooth_on else R.string.summary_bluetooth_off),
|
||||
str(if (locationWarning) R.string.summary_location_on else R.string.summary_location_off),
|
||||
)
|
||||
CarGroup.BROWSER -> listOf(
|
||||
browserEngine.label,
|
||||
str(if (browserAdBlock) R.string.summary_adblock_on else R.string.summary_adblock_off),
|
||||
)
|
||||
CarGroup.MORE -> listOf(
|
||||
str(R.string.web_page),
|
||||
str(if (experimental) R.string.summary_experimental_shown else R.string.summary_experimental_hidden),
|
||||
)
|
||||
}.filter { it.isNotEmpty() }.joinToString(" · ")
|
||||
@@ -350,9 +356,8 @@ private fun GroupRows(group: CarGroup) {
|
||||
}
|
||||
Toggle(stringResource(R.string.car_warn_location), locationWarning, CarSettings::setLocationWarning)
|
||||
}
|
||||
CarGroup.BROWSER -> BrowserSettingsRows { WebPageRow() }
|
||||
CarGroup.MORE -> {
|
||||
WebPageRow()
|
||||
SettingsDivider()
|
||||
Toggle(stringResource(R.string.car_experimental), experimental, CarSettings::setExperimentalFeaturesEnabled, note = stringResource(R.string.car_experimental_note))
|
||||
if (experimental) {
|
||||
SettingsDivider()
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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">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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user