mirror of
https://github.com/twinksoftware/Gearslip.git
synced 2026-11-11 16:22:25 +01:00
Compare commits
1
Commits
experimental
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb0736a3b3 |
No files matched your search
@@ -16,15 +16,6 @@ body:
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Gearslip version
|
||||
description: Shown under the Gearslip name on the app's home screen.
|
||||
placeholder: v0.3.40-alpha
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: head-unit
|
||||
attributes:
|
||||
|
||||
@@ -16,15 +16,6 @@ body:
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Gearslip version
|
||||
description: Shown under the Gearslip name on the app's home screen.
|
||||
placeholder: v0.3.40-alpha
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: head-unit
|
||||
attributes:
|
||||
|
||||
@@ -12,9 +12,6 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# The build names itself after the newest tag, which a shallow clone doesn't have.
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
|
||||
@@ -3,7 +3,6 @@ name: Translations
|
||||
# Weblate is the only thing that writes translated strings: it pushes straight to experimental,
|
||||
# never through a pull request. A PR that edits them collides with Weblate's own copy, and
|
||||
# Weblate locks itself until someone resets it. Edit the English strings.xml only.
|
||||
# Weblate's own pull requests are the exception: they are the translations.
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
@@ -11,7 +10,6 @@ on:
|
||||
|
||||
jobs:
|
||||
weblate-only:
|
||||
if: github.event.pull_request.user.login != 'weblate'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Translations go through Weblate
|
||||
|
||||
@@ -469,44 +469,6 @@ home-screen pane comes later. Anything that needs its own rendering belongs in a
|
||||
|
||||
---
|
||||
|
||||
## Works with Gearslip
|
||||
|
||||
Once your app runs well on Gearslip, say so in two places: your manifest and your README.
|
||||
|
||||
### In your manifest
|
||||
|
||||
Add these lines to `<application>`:
|
||||
|
||||
```xml
|
||||
<meta-data
|
||||
android:name="app.seb3thehacker.gearslip.works_with"
|
||||
android:value="true" />
|
||||
<meta-data
|
||||
android:name="app.seb3thehacker.gearslip.about"
|
||||
android:resource="@string/gearslip_about" />
|
||||
```
|
||||
|
||||
Gearslip then marks your app "Works with Gearslip" in its Recommended apps list and on your app's
|
||||
page in Gearslip. An installed app with the flag gets its own place at the top of the list.
|
||||
|
||||
The second entry is optional. It gives your page one sentence on what your app does in the car,
|
||||
such as "Spots trackers and cameras nearby." Write it in place with `android:value` if you have no
|
||||
string resource for it.
|
||||
|
||||
The flag is a label. Gearslip finds and runs your app the same way with or without it.
|
||||
|
||||
### In your README
|
||||
|
||||
Add the badge:
|
||||
|
||||
```markdown
|
||||
[](https://github.com/twinksoftware/gearslip)
|
||||
```
|
||||
|
||||
It matches the flat badges most READMEs already show.
|
||||
|
||||
---
|
||||
|
||||
## Further reading
|
||||
|
||||
- [README.md](../README.md) - what Gearslip is.
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="147" height="20" role="img" aria-label="Works with Gearslip">
|
||||
<title>Works with Gearslip</title>
|
||||
<clipPath id="r"><rect width="147" height="20" rx="3" fill="#fff"/></clipPath>
|
||||
<g clip-path="url(#r)">
|
||||
<rect width="88" height="20" fill="#555"/>
|
||||
<rect x="88" width="59" height="20" fill="#8C1F1F"/>
|
||||
</g>
|
||||
<g transform="translate(5 3.5) scale(0.228) translate(-29.5 -25.5)" fill="#fff">
|
||||
<path fill-rule="evenodd" d="M54 36.18 58.64 30.7 67.2 34.24 66.6 41.4 71.82 54 77.3 58.64 73.76 67.2 66.6 66.6 67.2 73.76 58.64 77.3 54 71.82 49.36 77.3 40.8 73.76 41.4 66.6 34.24 67.2 30.7 58.64 36.18 54 30.7 49.36 34.24 40.8 41.4 41.4 40.8 34.24 49.36 30.7ZM54 46.08A7.92 7.92 0 1 1 53.99 46.08Z"/>
|
||||
<path d="M78.61 40.7 85.31 43.29 84.74 52.55 77.78 54.31Z"/>
|
||||
</g>
|
||||
<g fill="#fff" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11" text-rendering="geometricPrecision">
|
||||
<text x="22" y="14" textLength="60">works with</text>
|
||||
<text x="94" y="14" textLength="47">Gearslip</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.0 KiB |
@@ -24,36 +24,6 @@ val keystoreProperties = Properties().apply {
|
||||
).firstOrNull { it.exists() }?.inputStream()?.use { load(it) }
|
||||
}
|
||||
|
||||
// The version comes from the newest v* git tag, so a build can never disagree with its tag. A
|
||||
// build of the tagged commit is named after it ("0.4.0-beta"); anything later adds the commits
|
||||
// since and the commit ("0.4.0-beta+3.ba4374e"), so a test build can't pass for a release.
|
||||
// The version code is worked out from the numbers, ((major*100 + minor)*100 + patch)*10 + stage,
|
||||
// where stage is 1 for alpha, 2 for beta and 9 for a finished release. It only ever goes up as
|
||||
// long as minor and patch stay under 100. Changelog entries use the same number.
|
||||
class GitVersion(val name: String, val code: Int)
|
||||
|
||||
val gitVersion: GitVersion = run {
|
||||
val described = runCatching {
|
||||
providers.exec {
|
||||
commandLine("git", "describe", "--tags", "--match", "v*", "--abbrev=7")
|
||||
isIgnoreExitValue = true
|
||||
}.standardOutput.asText.get().trim()
|
||||
}.getOrNull().orEmpty()
|
||||
val match = Regex("""^v((\d+)\.(\d+)\.(\d+)(?:-([A-Za-z]+))?)(?:-(\d+)-g([0-9a-f]+))?$""").find(described)
|
||||
?: return@run GitVersion("0.0.0-dev", 1) // no git or no tags, e.g. a source download
|
||||
val (base, major, minor, patch, suffix, since, commit) = match.destructured
|
||||
val stage = when (suffix.lowercase()) {
|
||||
"" -> 9
|
||||
"beta" -> 2
|
||||
"alpha" -> 1
|
||||
else -> 0
|
||||
}
|
||||
GitVersion(
|
||||
name = if (since.isEmpty()) base else "$base+$since.$commit",
|
||||
code = ((major.toInt() * 100 + minor.toInt()) * 100 + patch.toInt()) * 10 + stage,
|
||||
)
|
||||
}
|
||||
|
||||
// Keeps build output out of the module folder: <root>/build/gearslip/... instead of
|
||||
// <root>/gearslip/build/..., so everything this project produces lands under one
|
||||
// root-level build/ directory. Set before android {}, which works out the path to R8's default
|
||||
@@ -68,9 +38,9 @@ android {
|
||||
applicationId = "app.seb3thehacker.gearslip"
|
||||
minSdk = 31
|
||||
targetSdk = 37
|
||||
versionCode = gitVersion.code
|
||||
versionCode = 15
|
||||
// -PgearslipVersionName=0.1.99-test makes a build that thinks it's older, to try the updater.
|
||||
versionName = project.findProperty("gearslipVersionName")?.toString() ?: gitVersion.name
|
||||
versionName = project.findProperty("gearslipVersionName")?.toString() ?: "0.3.40-alpha"
|
||||
manifestPlaceholders["appLabel"] = "Gearslip"
|
||||
// Where opted-in phones send their daily usage note: the stats-worker/ Worker's address,
|
||||
// set as gearslipStatsUrl in gradle.properties. Left empty, nothing is ever sent.
|
||||
@@ -124,15 +94,8 @@ android {
|
||||
// Lists each language that has a translation in the app's locale config, so Android 13+
|
||||
// offers it under the system's per-app languages. English is the unqualified default
|
||||
// (res/resources.properties).
|
||||
//
|
||||
// Only languages that are mostly translated ship, so the app never comes up half in English.
|
||||
// Danish and Estonian are still early on Weblate; add them here once they're close to done.
|
||||
androidResources {
|
||||
generateLocaleConfig = true
|
||||
localeFilters += listOf(
|
||||
"en", "ar", "cs", "de", "es", "fr", "it", "ja", "ko", "nl", "pl", "pt-rBR", "ro", "ru",
|
||||
"sv", "tr", "uk", "zh-rCN",
|
||||
)
|
||||
}
|
||||
|
||||
lint {
|
||||
@@ -146,16 +109,11 @@ android {
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
// Speech-to-text (sherpa-onnx 1.13.8, static-link-onnxruntime build, in jniLibs) is the
|
||||
// only native code. One APK for all three, since the updater takes the release's only APK;
|
||||
// 32-bit x86 is left out, nothing that drives a car runs it.
|
||||
ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64") }
|
||||
// Speech-to-text (sherpa-onnx, in jniLibs) ships for 64-bit ARM only: every phone
|
||||
// Gearslip runs on, and one copy of the native library instead of four.
|
||||
ndk { abiFilters += "arm64-v8a" }
|
||||
}
|
||||
|
||||
// Compressed, the three copies cost a 33 MB download instead of 75 MB; the phone unpacks
|
||||
// only its own at install.
|
||||
packaging { jniLibs { useLegacyPackaging = true } }
|
||||
|
||||
testOptions {
|
||||
// GearslipLog calls android.util.Log, which is a stub on the local JVM.
|
||||
unitTests.isReturnDefaultValues = true
|
||||
@@ -196,10 +154,6 @@ 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)
|
||||
// The map behind a place list (PlaceListMapTemplate), which the host draws, not the app.
|
||||
implementation(libs.maplibre.android)
|
||||
testImplementation(libs.junit)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,8 +5,6 @@
|
||||
<!-- The projected WebView loads real pages; without this every load fails silently. -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<!-- Lets an update Gearslip installed itself skip Android's prompt. -->
|
||||
<uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" />
|
||||
|
||||
<!--
|
||||
Phone mirroring. Android refuses to hand over a MediaProjection unless a foreground
|
||||
@@ -66,8 +64,7 @@
|
||||
|
||||
<!--
|
||||
Phone calls on the car screen. READ_PHONE_STATE and READ_CALL_LOG together are what a
|
||||
call's own state (ringing, in progress) and the incoming number need, and READ_CALL_LOG
|
||||
also lists recent calls on the car's Phone screen, read on the phone only; READ_CONTACTS
|
||||
call's own state (ringing, in progress) and the incoming number need; READ_CONTACTS
|
||||
turns that number into a name; ANSWER_PHONE_CALLS lets the driver pick up from here.
|
||||
Declining a call, and the caller ID itself, need Gearslip set as the phone's caller ID
|
||||
and spam app too - an on-device consent, not a permission declared here.
|
||||
@@ -189,21 +186,6 @@
|
||||
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"
|
||||
@@ -234,14 +216,6 @@
|
||||
android:name=".notify.TestMessageReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<receiver
|
||||
android:name=".update.UpdateReceiver"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<service
|
||||
android:name=".mirror.MirrorService"
|
||||
android:exported="false"
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -4,10 +4,6 @@ package app.seb3thehacker.gearslip
|
||||
* What changed in each release, newest first, shown on the car screen after an update (and on
|
||||
* the phone). Only the changes a driver would notice. Plain words, short lines: this is read in
|
||||
* a parked car.
|
||||
*
|
||||
* After 0.3.40, the version code is the one the build works out from the tag (see gitVersion in
|
||||
* build.gradle.kts): ((major*100 + minor)*100 + patch)*10 + stage, so 0.4.0-beta is 4002. Older
|
||||
* entries keep the hand-set codes they shipped with, which are all lower.
|
||||
*/
|
||||
object Changelog {
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import app.seb3thehacker.gearslip.car.ClusterScreen
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
/**
|
||||
* Gearslip's picture on the car's instrument-cluster screen: a second video sink, for a car that
|
||||
@@ -16,12 +15,8 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
* -> VideoFocusRequest <- VideoFocusNotification { projected }
|
||||
* -> Start, CodecConfig, Data... <- Ack
|
||||
*
|
||||
* No touch: a cluster has none. Experimental, behind [car.CarSettings.clusterVideoEnabled].
|
||||
*
|
||||
* Gearslip holds the cluster only while there's a route, as the card has nothing else to show.
|
||||
* Otherwise the car keeps its own picture there: an Audi's virtual cockpit went black instead of
|
||||
* showing its map (issue #56). [watchRoute] asks for the screen when a route starts and hands it
|
||||
* back once there has been none for [NO_ROUTE_MS].
|
||||
* No touch: a cluster has none. Experimental, behind [car.CarSettings.clusterVideoEnabled], and
|
||||
* untested in a car: none in the notes has a cluster sink.
|
||||
*/
|
||||
internal class ClusterVideo(
|
||||
private val context: Context,
|
||||
@@ -35,13 +30,6 @@ internal class ClusterVideo(
|
||||
|
||||
private var configIndex = 0
|
||||
@Volatile private var started = false
|
||||
/** Configured and ready for focus requests. */
|
||||
@Volatile private var ready = false
|
||||
/** The car shows Gearslip's picture now; frames aren't sent while it shows its own. */
|
||||
@Volatile private var projected = false
|
||||
/** Gearslip has asked for the screen and not handed it back since. */
|
||||
private var wanted = false
|
||||
private var noRouteSince = 0L
|
||||
@Volatile private var running = true
|
||||
@Volatile private var source: VideoSource? = null
|
||||
private var projector: ScreenProjector? = null
|
||||
@@ -51,8 +39,6 @@ internal class ClusterVideo(
|
||||
@Volatile private var maxUnacked = 1
|
||||
@Volatile private var lastAckAt = 0L
|
||||
private var frames = 0
|
||||
/** The part of the picture the car's own gauges and bars leave clear, as the car last said. */
|
||||
private val clearArea = MutableStateFlow<Insets?>(null)
|
||||
|
||||
fun onMessage(messageId: Int, body: ByteArray) {
|
||||
when (messageId) {
|
||||
@@ -71,19 +57,16 @@ internal class ClusterVideo(
|
||||
// The cluster gets the smallest picture it offers: a turn card needs no more.
|
||||
configIndex = offered.minByOrNull { service.configs.getOrNull(it)?.pixelCount() ?: Int.MAX_VALUE } ?: 0
|
||||
log.i("<- Config status=${Wire.varint(fields, 1)} max_unacked=$maxUnacked offered=$offered - using $configIndex")
|
||||
ready = true
|
||||
main.post(watchRoute)
|
||||
val focus = Protobuf.varintField(2, GearslipRunner.VIDEO_FOCUS_PROJECTED.toLong()) + Protobuf.varintField(3, 1L)
|
||||
send(GearslipRunner.MSG_VIDEO_FOCUS_REQUEST, focus)
|
||||
log.i("-> VideoFocusRequest(mode=PROJECTED)")
|
||||
}
|
||||
|
||||
GearslipRunner.MSG_VIDEO_FOCUS_NOTIFICATION -> {
|
||||
val mode = Wire.varint(Wire.fields(body), 1)?.toInt() ?: GearslipRunner.VIDEO_FOCUS_PROJECTED
|
||||
log.i("<- VideoFocusNotification: mode=$mode")
|
||||
val now = mode == GearslipRunner.VIDEO_FOCUS_PROJECTED || mode == GearslipRunner.VIDEO_FOCUS_PROJECTED_NO_INPUT
|
||||
val back = now && !projected
|
||||
projected = now
|
||||
if (now && !started) startStream()
|
||||
// Back from the car's own picture: a fresh key frame, so it doesn't start on a partial one.
|
||||
else if (back) source?.requestSyncFrame()
|
||||
val projected = mode == GearslipRunner.VIDEO_FOCUS_PROJECTED || mode == GearslipRunner.VIDEO_FOCUS_PROJECTED_NO_INPUT
|
||||
if (projected && !started) startStream()
|
||||
}
|
||||
|
||||
GearslipRunner.MSG_MEDIA_ACK -> {
|
||||
@@ -95,42 +78,10 @@ internal class ClusterVideo(
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateUiConfigRequest { AdditionalVideoConfig { display_insets = 1 } }: an Audi's
|
||||
// virtual cockpit sends one as the picture starts, and draws its dials over the frame.
|
||||
GearslipRunner.MSG_VIDEO_UI_CONFIG -> {
|
||||
log.i("<- UpdateUiConfigRequest (${body.size} bytes)\n${Protobuf.describe(body)}")
|
||||
clearArea.value = insetsFrom(body)
|
||||
}
|
||||
|
||||
else -> log.i("<- unhandled message id=$messageId (${body.size} bytes)")
|
||||
}
|
||||
}
|
||||
|
||||
/** Once a second: the cluster follows whether there's a route. */
|
||||
private val watchRoute = object : Runnable {
|
||||
override fun run() {
|
||||
if (!running || !ready) return
|
||||
val route = app.seb3thehacker.gearslip.car.NavTurn.current() != null
|
||||
val now = System.currentTimeMillis()
|
||||
if (route) noRouteSince = 0L else if (noRouteSince == 0L) noRouteSince = now
|
||||
when {
|
||||
route && !wanted -> requestFocus(GearslipRunner.VIDEO_FOCUS_PROJECTED, "a route started")
|
||||
!route && wanted && now - noRouteSince >= NO_ROUTE_MS -> requestFocus(GearslipRunner.VIDEO_FOCUS_NATIVE, "no route")
|
||||
}
|
||||
main.postDelayed(this, WATCH_MS)
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestFocus(mode: Int, why: String) {
|
||||
wanted = mode == GearslipRunner.VIDEO_FOCUS_PROJECTED
|
||||
// VideoFocusRequestNotification { disp_channel_id = 1; mode = 2; reason = 3 }, as the main
|
||||
// screen asks for its own picture back.
|
||||
val reason = if (wanted) 1L else GearslipRunner.VIDEO_FOCUS_REASON_LAUNCH_NATIVE.toLong()
|
||||
val body = Protobuf.varintField(1, channelId.toLong()) + Protobuf.varintField(2, mode.toLong()) + Protobuf.varintField(3, reason)
|
||||
runCatching { send(GearslipRunner.MSG_VIDEO_FOCUS_REQUEST, body) }
|
||||
log.i("-> VideoFocusRequest(mode=${if (wanted) "PROJECTED" else "NATIVE"}): $why")
|
||||
}
|
||||
|
||||
private fun startStream() {
|
||||
val config = service.configs.getOrNull(configIndex) ?: service.configs.firstOrNull() ?: return
|
||||
val (width, height) = config.pixelSize() ?: run {
|
||||
@@ -168,7 +119,7 @@ internal class ClusterVideo(
|
||||
projector = ScreenProjector(context).also {
|
||||
it.start(surface, width, height, density, name = "gearslip-cluster") {
|
||||
// The margins the car crops off are kept clear, as on the main screen.
|
||||
ClusterScreen(config.widthMargin, config.heightMargin, density, clearArea)
|
||||
ClusterScreen(config.widthMargin, config.heightMargin)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -187,7 +138,7 @@ internal class ClusterVideo(
|
||||
ackLock.wait(GearslipRunner.ACK_STALL_MS - since + 1)
|
||||
}
|
||||
}
|
||||
if (!running || !projected) return
|
||||
if (!running) return
|
||||
val timestamp = ByteArray(8)
|
||||
for (i in 0 until 8) timestamp[i] = ((presentationTimeUs shr ((7 - i) * 8)) and 0xFF).toByte()
|
||||
synchronized(ackLock) { inFlight++ }
|
||||
@@ -195,18 +146,8 @@ internal class ClusterVideo(
|
||||
if (++frames <= 2) log.i("-> Data frame #$frames (${data.size} bytes)")
|
||||
}
|
||||
|
||||
/** VideoInsets { top = 1; bottom = 2; left = 3; right = 4 }, or null when the car sent none. */
|
||||
private fun insetsFrom(body: ByteArray): Insets? {
|
||||
val config = Wire.bytes(Wire.fields(body), 1) ?: return null
|
||||
val insets = Wire.fields(Wire.bytes(Wire.fields(config), 1) ?: return null)
|
||||
fun side(n: Int) = Wire.varint(insets, n)?.toInt() ?: 0
|
||||
return Insets(top = side(1), bottom = side(2), left = side(3), right = side(4))
|
||||
.also { log.i("the car keeps clear: $it") }
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
running = false
|
||||
main.removeCallbacks(watchRoute)
|
||||
synchronized(ackLock) { ackLock.notifyAll() }
|
||||
main.post {
|
||||
projector?.stop()
|
||||
@@ -219,8 +160,5 @@ internal class ClusterVideo(
|
||||
private companion object {
|
||||
// A turn card changes a few times a minute; the encoder repeats the last frame between.
|
||||
const val FRAME_RATE = 15
|
||||
const val WATCH_MS = 1_000L
|
||||
/** A gap in the turns this long is the route over, not a reroute or a moment between steps. */
|
||||
const val NO_ROUTE_MS = 10_000L
|
||||
}
|
||||
}
|
||||
@@ -151,7 +151,6 @@ class GearslipActivity : ComponentActivity(), Projection {
|
||||
app.seb3thehacker.gearslip.wireless.WirelessLink.onConnected = { socket ->
|
||||
runOnUiThread { startWireless(socket) }
|
||||
}
|
||||
app.seb3thehacker.gearslip.wireless.WirelessLink.sessionWaiting = { runner?.waitingForVersion == true }
|
||||
app.seb3thehacker.gearslip.wireless.WirelessAuto.showApproval = { chooser, address ->
|
||||
runOnUiThread {
|
||||
approvingCar = address
|
||||
@@ -159,10 +158,6 @@ class GearslipActivity : ComponentActivity(), Projection {
|
||||
}
|
||||
}
|
||||
CarSettings.init(this)
|
||||
// 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.
|
||||
@@ -250,7 +245,6 @@ class GearslipActivity : ComponentActivity(), Projection {
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
app.seb3thehacker.gearslip.wireless.WirelessLink.onConnected = null
|
||||
app.seb3thehacker.gearslip.wireless.WirelessLink.sessionWaiting = null
|
||||
app.seb3thehacker.gearslip.wireless.WirelessAuto.showApproval = null
|
||||
runCatching { unregisterReceiver(detachReceiver) }
|
||||
PhoneMirror.onStartRequested = null
|
||||
|
||||
@@ -74,9 +74,6 @@ class GearslipRunner(
|
||||
@Volatile private var stopped = false
|
||||
|
||||
@Volatile private var state = State.WAIT_VERSION
|
||||
|
||||
/** Connected, but the car hasn't sent its first message yet. */
|
||||
val waitingForVersion: Boolean get() = running && state == State.WAIT_VERSION
|
||||
@Volatile private var lastProgress = System.currentTimeMillis()
|
||||
@Volatile private var running = true
|
||||
|
||||
@@ -85,8 +82,6 @@ class GearslipRunner(
|
||||
private var selectedConfigIndex: Int? = null
|
||||
private var vehicleProfile: VehicleProfile? = null
|
||||
@Volatile private var videoStarted = false
|
||||
/** The car's screen has been asked for once, so asking again is in order. */
|
||||
@Volatile private var focusAsked = false
|
||||
private var videoSource: VideoSource? = null
|
||||
|
||||
/** Between the car UI and the encoder when the car squashes the frame (see [screenShape]). */
|
||||
@@ -175,10 +170,6 @@ class GearslipRunner(
|
||||
reportDisconnect()
|
||||
} finally {
|
||||
running = false
|
||||
// The car is gone, but nothing calls stop() when a wireless session drops: it waits
|
||||
// for the next one. Close the music channel now, so the audio capture behind it,
|
||||
// and the screen sharing Android shows for it, stop too (issue #43).
|
||||
if (!stopped) audioLink?.close()
|
||||
writer.shutdown()
|
||||
if (drainWriterOnExit) writer.awaitTermination(2, TimeUnit.SECONDS)
|
||||
writer.shutdownNow()
|
||||
@@ -206,7 +197,6 @@ class GearslipRunner(
|
||||
app.seb3thehacker.gearslip.car.CarData.clear()
|
||||
CarMic.current = null
|
||||
CarBluetooth.onCarDisconnected()
|
||||
CarSettings.carDisconnected()
|
||||
BluetoothAudioCheck.onCarDisconnected()
|
||||
LocationCheck.onCarDisconnected()
|
||||
mediaStatus?.stop()
|
||||
@@ -634,10 +624,7 @@ class GearslipRunner(
|
||||
onPausedForNow = { CarServices.pausePhoneMediaForNow() },
|
||||
onGivenBack = { CarServices.resumePhoneMedia() },
|
||||
)
|
||||
// A car known to switch Bluetooth music off gets it through Gearslip, whatever the setting.
|
||||
CarSettings.carConnected(ServiceDiscovery.findBluetoothService(serviceDiscoveryResponse)?.carAddress)
|
||||
if (CarSettings.pipesNow) {
|
||||
if (!CarSettings.pipeAudio.value) log.i("this car turns Bluetooth music off, so music goes through Gearslip")
|
||||
if (CarSettings.pipeAudio.value) {
|
||||
openChannel(media.serviceId)
|
||||
} else {
|
||||
// Many cars switch their source to Android Auto the moment this channel opens, which
|
||||
@@ -779,13 +766,6 @@ class GearslipRunner(
|
||||
bluetooth,
|
||||
open = { openChannel(bluetooth.serviceId) },
|
||||
send = { id, body -> send(id, body, encrypted = true, channel = bluetooth.serviceId) },
|
||||
// A car keeping its screen until it knows the phone may give it up now; ask again.
|
||||
answered = {
|
||||
if (running && focusAsked && !videoStarted) {
|
||||
log.i("the car answered on Bluetooth while keeping its screen - asking for it again")
|
||||
requestProjectedFocus(withDisplay = true)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1081,7 +1061,7 @@ class GearslipRunner(
|
||||
"(head unit offered ${offered.size}), max_unacked=$maxUnacked",
|
||||
)
|
||||
requestProjectedFocus()
|
||||
watchForWithheldScreen()
|
||||
if (CarAudio.openSink != null) watchForWithheldScreen()
|
||||
}
|
||||
|
||||
MSG_VIDEO_FOCUS_NOTIFICATION -> {
|
||||
@@ -1112,16 +1092,6 @@ class GearslipRunner(
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateUiConfigRequest { AdditionalVideoConfig }: new margins or day/night. A LuChi
|
||||
// box sent it as video began, showed our first frame, then black (issue #58), as if
|
||||
// it rebuilt its decoder. A rebuilt decoder needs SPS/PPS and a keyframe again.
|
||||
MSG_VIDEO_UI_CONFIG -> {
|
||||
log.i("<- UpdateUiConfigRequest (${body.size} bytes) - sending the stream's start again")
|
||||
log.hex(" ui config", body, limit = 64)
|
||||
lastCodecConfig?.let { send(MSG_MEDIA_CODEC_CONFIG, it, encrypted = true, channel = videoChannelId) }
|
||||
videoSource?.requestSyncFrame()
|
||||
}
|
||||
|
||||
else -> log.i("<- unhandled video message id=$messageId (${body.size} bytes)")
|
||||
}
|
||||
}
|
||||
@@ -1374,7 +1344,6 @@ class GearslipRunner(
|
||||
* the field is deprecated, but a car older than that may ignore a request without it.
|
||||
*/
|
||||
private fun requestProjectedFocus(withDisplay: Boolean = false) {
|
||||
focusAsked = true
|
||||
// VideoFocusRequestNotification { disp_channel_id = 1; mode = 2; reason = 3 }. Reason 1
|
||||
// means "the phone's screen is off", which a 2020 Civic may have taken as a reason to
|
||||
// keep its own screen (issue #43); 0 is UNKNOWN.
|
||||
@@ -1394,10 +1363,6 @@ class GearslipRunner(
|
||||
* straight over keep their own source as before.
|
||||
* The 2017 Pilot (protocol 1.0) still kept its screen with the channel open, so the second
|
||||
* request also names the display's channel, as cars of the Pilot's age may need.
|
||||
* This runs whether or not the media channel was open: a 2020 Civic with music through
|
||||
* Gearslip opened it at connect, so on 0.3.40 it never heard the second request (issue #43).
|
||||
* The car also hears which phone this is over Bluetooth (see [CarBluetooth.onScreenWithheld]),
|
||||
* which Hondas may wait for; its answer asks for the screen once more.
|
||||
*/
|
||||
private fun watchForWithheldScreen() {
|
||||
Thread({
|
||||
@@ -1407,12 +1372,11 @@ class GearslipRunner(
|
||||
if (open != null) {
|
||||
log.w("no screen from the car after ${WITHHELD_SCREEN_MS / 1000}s - opening the media channel and asking again")
|
||||
open()
|
||||
// Give the car the channel's open response before it hears the request again.
|
||||
Thread.sleep(1_000)
|
||||
} else {
|
||||
log.w("no screen from the car after ${WITHHELD_SCREEN_MS / 1000}s - asking again")
|
||||
}
|
||||
val toldBluetooth = CarBluetooth.onScreenWithheld()
|
||||
// Give the car the channels' open responses before it hears the request again.
|
||||
if (open != null || toldBluetooth) Thread.sleep(1_000)
|
||||
if (running && !videoStarted) requestProjectedFocus(withDisplay = true)
|
||||
}, "gearslip-focus-fallback").apply { isDaemon = true }.start()
|
||||
}
|
||||
@@ -1717,7 +1681,6 @@ class GearslipRunner(
|
||||
const val MSG_MEDIA_ACK = 32772
|
||||
const val MSG_VIDEO_FOCUS_REQUEST = 32775
|
||||
const val MSG_VIDEO_FOCUS_NOTIFICATION = 32776
|
||||
const val MSG_VIDEO_UI_CONFIG = 32777
|
||||
|
||||
// aap_protobuf/service/sensor/... - same "first specific message on this channel type
|
||||
// starts at 0x8000" numbering as media/input above.
|
||||
|
||||
@@ -8,8 +8,7 @@ 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). Blank opens the
|
||||
* browser's own start page.
|
||||
* pre-Settings way, still honoured so existing setups keep working), then [DEFAULT_URL].
|
||||
*/
|
||||
fun resolve(context: Context): String {
|
||||
val saved = AppSettings.startupUrl(context)
|
||||
@@ -26,9 +25,12 @@ object ProjectedContent {
|
||||
return url
|
||||
}
|
||||
}
|
||||
return ""
|
||||
return DEFAULT_URL
|
||||
}
|
||||
|
||||
/** 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"
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
package app.seb3thehacker.gearslip
|
||||
|
||||
import android.app.ActivityOptions
|
||||
import android.app.LocaleManager
|
||||
import android.app.Presentation
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Color
|
||||
import android.hardware.display.DisplayManager
|
||||
import android.hardware.display.VirtualDisplay
|
||||
import android.os.SystemClock
|
||||
import android.view.MotionEvent
|
||||
import android.view.Surface
|
||||
import android.view.ContextThemeWrapper
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -99,7 +96,7 @@ class ScreenProjector(private val context: Context) {
|
||||
decor.setViewTreeViewModelStoreOwner(treeOwner)
|
||||
decor.setBackgroundColor(Color.BLACK)
|
||||
|
||||
show.setContentView(ComposeView(withAppLanguage(show.context)).apply { setContent(content) })
|
||||
show.setContentView(ComposeView(show.context).apply { setContent(content) })
|
||||
show.show()
|
||||
|
||||
presentation = show
|
||||
@@ -109,18 +106,6 @@ class ScreenProjector(private val context: Context) {
|
||||
GearslipLog.i("projecting ${width}x$height @ ${densityDpi}dpi")
|
||||
}
|
||||
|
||||
/**
|
||||
* [context] in the language the driver picked for Gearslip, when it differs from the phone's
|
||||
* (issue #80). Android builds a virtual display's context from the system locale and leaves
|
||||
* the per-app one out, so the car screen stayed in the phone's language.
|
||||
*/
|
||||
private fun withAppLanguage(context: Context): Context {
|
||||
val locales = this.context.getSystemService(LocaleManager::class.java)?.applicationLocales
|
||||
if (locales == null || locales.isEmpty) return context
|
||||
val config = Configuration(context.resources.configuration).apply { setLocales(locales) }
|
||||
return ContextThemeWrapper(context, context.theme).apply { applyOverrideConfiguration(config) }
|
||||
}
|
||||
|
||||
/** The projected display's id, for placing other apps' activities on it. */
|
||||
val displayId: Int? get() = virtualDisplay?.display?.displayId
|
||||
|
||||
|
||||
@@ -157,8 +157,6 @@ class AudioCaptureService : Service() {
|
||||
val startedAt = heardAt
|
||||
var everHeard = false
|
||||
var warnedSilent = false
|
||||
var lastSound = startedAt
|
||||
var silenceReported = false
|
||||
try {
|
||||
record.startRecording()
|
||||
GearslipLog.i("audio: capturing media playback at ${rate}Hz x$channels")
|
||||
@@ -176,14 +174,6 @@ class AudioCaptureService : Service() {
|
||||
}
|
||||
if (peak > 0) everHeard = true
|
||||
val now = System.currentTimeMillis()
|
||||
// A stretch of silence: CarAudio says why on the car if a blocking app is playing.
|
||||
if (peak > 0) {
|
||||
lastSound = now
|
||||
silenceReported = false
|
||||
} else if (!silenceReported && now - lastSound >= SILENCE_MS) {
|
||||
silenceReported = true
|
||||
CarAudio.silent(this@AudioCaptureService)
|
||||
}
|
||||
val level = peak / 32768f
|
||||
if (now - heardAt >= LEVEL_LOG_MS) {
|
||||
GearslipLog.i("audio: peak %.3f".format(level))
|
||||
|
||||
@@ -52,21 +52,6 @@ class AudioLink(
|
||||
// The Uconnect asks for 4 on its video channel; assume the same until its Config says otherwise.
|
||||
@Volatile private var maxUnacked = 4
|
||||
@Volatile private var lastAckAt = 0L
|
||||
/**
|
||||
* Bytes of PCM per Data message, or 0 to send each capture read as it comes. Raised by
|
||||
* [adaptChunkSize] for a car that acks fewer chunks than it plays.
|
||||
*/
|
||||
@Volatile private var chunkBytes = 0
|
||||
/** Capture reads gathered until they fill [chunkBytes]. Only the capture thread touches it. */
|
||||
private val pending = java.io.ByteArrayOutputStream()
|
||||
/** Set by a new stream, so the capture thread drops what an older one left in [pending]. */
|
||||
@Volatile private var pendingStale = false
|
||||
/** The size of the last chunk sent, for [adaptChunkSize] to know what each one holds. */
|
||||
@Volatile private var lastPayloadBytes = 0
|
||||
/** Acks that came while the window was full, and the time they took: the car's own pace. */
|
||||
private var slowAcks = 0
|
||||
private var slowAckMs = 0L
|
||||
private var lastAckFull = false
|
||||
private var stalls = 0
|
||||
private var chunks = 0
|
||||
private var bytes = 0L
|
||||
@@ -226,21 +211,11 @@ class AudioLink(
|
||||
|
||||
MSG_ACK -> {
|
||||
val acked = Wire.varint(Wire.fields(body), 2)?.toInt() ?: 1
|
||||
val now = System.currentTimeMillis()
|
||||
synchronized(ackLock) {
|
||||
// A full window means the sender is waiting on this ack. When it was waiting on
|
||||
// the one before too, the gap between them is the car's own pace.
|
||||
val full = inFlight >= maxUnacked
|
||||
if (full && lastAckFull) {
|
||||
slowAcks++
|
||||
slowAckMs += now - lastAckAt
|
||||
}
|
||||
lastAckFull = full
|
||||
inFlight = maxOf(0, inFlight - maxOf(1, acked))
|
||||
lastAckAt = now
|
||||
lastAckAt = System.currentTimeMillis()
|
||||
ackLock.notifyAll()
|
||||
}
|
||||
adaptChunkSize()
|
||||
}
|
||||
|
||||
// Whatever else the head unit answers Setup with, we need its bytes to identify it.
|
||||
@@ -315,40 +290,6 @@ class AudioLink(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A 2020 Civic acks a chunk about every 83 ms whatever the chunk holds, so 21 ms chunks
|
||||
* reached it at a quarter of real time: a second of music, then silence, over and over
|
||||
* (issue #43). When the car keeps the sender waiting and its acks come much further apart
|
||||
* than the audio in each chunk, gather the capture into chunks that last at least that gap,
|
||||
* rounded up to a power of two in frames: 83 ms is about 4096 frames at 48 kHz, which looks
|
||||
* like the car's own buffer. A car that keeps up never fills the window, so it never gets here.
|
||||
*/
|
||||
private fun adaptChunkSize() {
|
||||
if (aac != null) return
|
||||
val interval = synchronized(ackLock) {
|
||||
if (slowAcks < ADAPT_ACKS) return
|
||||
(slowAckMs.toDouble() / slowAcks).also {
|
||||
slowAcks = 0
|
||||
slowAckMs = 0
|
||||
}
|
||||
}
|
||||
val frame = channels * 2
|
||||
val bytesPerMs = sampleRate * frame / 1000.0
|
||||
val current = if (chunkBytes > 0) chunkBytes else lastPayloadBytes
|
||||
if (current <= 0 || current >= MAX_CHUNK_BYTES) return
|
||||
val heldMs = current / bytesPerMs
|
||||
if (interval < heldMs * SLOW_ACK_RATIO) return
|
||||
val gapFrames = Math.ceil(interval * sampleRate / 1000.0).toInt().coerceAtLeast(1)
|
||||
val frames = Integer.highestOneBit(gapFrames).let { if (it < gapFrames) it shl 1 else it }
|
||||
val target = minOf(MAX_CHUNK_BYTES / frame * frame, frames * frame)
|
||||
if (target <= current) return
|
||||
chunkBytes = target
|
||||
GearslipLog.i(
|
||||
"$tag: the car acks a chunk every ${interval.toInt()}ms, but each holds ${heldMs.toInt()}ms - " +
|
||||
"sending $target-byte chunks (${(target / bytesPerMs).toInt()}ms) from now on",
|
||||
)
|
||||
}
|
||||
|
||||
/** Silence for [ms], whole frames of this sink's format. */
|
||||
private fun silence(ms: Int): ByteArray = ByteArray(sampleRate * ms / 1000 * channels * 2)
|
||||
|
||||
@@ -390,12 +331,6 @@ class AudioLink(
|
||||
}
|
||||
inFlight = 0
|
||||
lastAckAt = System.currentTimeMillis()
|
||||
pendingStale = true
|
||||
synchronized(ackLock) {
|
||||
slowAcks = 0
|
||||
slowAckMs = 0
|
||||
lastAckFull = false
|
||||
}
|
||||
if (aacSink && aac == null) {
|
||||
aac = runCatching { AacEncoder(sampleRate, channels, adts = service.codecType == ServiceDiscovery.CODEC_AAC_LC_ADTS) }
|
||||
.onSuccess { GearslipLog.i("$tag: the car takes ${service.codecName} only - encoding to AAC") }
|
||||
@@ -445,20 +380,7 @@ class AudioLink(
|
||||
private fun writeNow(pcm: ByteArray, size: Int) {
|
||||
val encoder = aac
|
||||
if (encoder == null) {
|
||||
if (pendingStale) {
|
||||
pendingStale = false
|
||||
pending.reset()
|
||||
}
|
||||
val want = chunkBytes
|
||||
if (want <= 0 && pending.size() == 0) {
|
||||
send(pcm.copyOf(size))
|
||||
return
|
||||
}
|
||||
pending.write(pcm, 0, size)
|
||||
if (pending.size() >= want) {
|
||||
send(pending.toByteArray())
|
||||
pending.reset()
|
||||
}
|
||||
send(pcm.copyOf(size))
|
||||
return
|
||||
}
|
||||
val frames = try {
|
||||
@@ -508,7 +430,6 @@ class AudioLink(
|
||||
return
|
||||
}
|
||||
synchronized(ackLock) { inFlight++ }
|
||||
lastPayloadBytes = payload.size
|
||||
chunks++
|
||||
bytes += payload.size
|
||||
// The first chunk proves the whole path; after that, an occasional line is enough to show
|
||||
@@ -557,12 +478,6 @@ class AudioLink(
|
||||
private const val FOCUS_STATE_LOSS_TRANSIENT = 5
|
||||
private const val SESSION_ID = 2
|
||||
private const val STALL_MS = 2_000L
|
||||
/** Acks measured with the window full before the chunk size is judged. */
|
||||
private const val ADAPT_ACKS = 20
|
||||
/** Acks this much further apart than a chunk lasts mean the car is the one holding back. */
|
||||
private const val SLOW_ACK_RATIO = 1.5
|
||||
/** About 170 ms at 48 kHz stereo: the most delay a bigger chunk may add. */
|
||||
private const val MAX_CHUNK_BYTES = 32_768
|
||||
private const val CHUNK_LOG_EVERY = 200 // about every four seconds
|
||||
private const val CONFIG_WAIT_MS = 1_000L
|
||||
|
||||
|
||||
@@ -47,28 +47,11 @@ object CarAudio {
|
||||
/** Opens the car's media channel, left closed while music went over Bluetooth; null once open. */
|
||||
@Volatile var openSink: (() -> Unit)? = null
|
||||
|
||||
/** The car offers a music channel, open or waiting to be. */
|
||||
val sinkOffered: Boolean get() = link != null || openSink != null
|
||||
|
||||
/** Rate and channel count the capture should use: the car's, or a sensible default. */
|
||||
val sampleRate get() = link?.sampleRate ?: 48_000
|
||||
val channels get() = link?.channels ?: 2
|
||||
|
||||
private val main by lazy { android.os.Handler(android.os.Looper.getMainLooper()) }
|
||||
|
||||
/**
|
||||
* The car left and none came back: stop capturing, which ends the screen sharing Android
|
||||
* shows for it and gives the phone its speaker back. Nothing else stopped it once the drive
|
||||
* was over; with a wireless dongle Gearslip's screen stays open, so it ran on (issue #43).
|
||||
*/
|
||||
private val releaseWhenGone = Runnable {
|
||||
if (link != null || _capture.value == Capture.OFF) return@Runnable
|
||||
GearslipLog.i("audio: no car for ${CAR_GONE_MS / 1000}s - stopping the capture and screen sharing")
|
||||
release()
|
||||
}
|
||||
|
||||
fun attach(link: Link) {
|
||||
main.removeCallbacks(releaseWhenGone)
|
||||
this.link = link
|
||||
GearslipLog.i("audio: the car offered a sink at ${link.sampleRate}Hz x${link.channels}")
|
||||
when {
|
||||
@@ -77,24 +60,10 @@ object CarAudio {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The car's music channel closed. A car that comes straight back (a cable that slips) keeps
|
||||
* the capture it had, so the driver isn't asked again mid-drive.
|
||||
*/
|
||||
fun detach(link: Link) {
|
||||
if (this.link !== link) return
|
||||
this.link = null
|
||||
main.removeCallbacks(releaseWhenGone)
|
||||
main.postDelayed(releaseWhenGone, CAR_GONE_MS)
|
||||
if (this.link === link) this.link = null
|
||||
}
|
||||
|
||||
/**
|
||||
* How long after the car leaves the capture waits for it to come back: a cable that slips, or a
|
||||
* wireless link that drops and reconnects, which can take well over 3 seconds. Stopping sooner
|
||||
* would have Android ask the driver to share the screen again in the middle of a drive.
|
||||
*/
|
||||
private const val CAR_GONE_MS = 30_000L
|
||||
|
||||
/**
|
||||
* The car wants sound. Safe to call as often as the session likes: the consent dialog appears
|
||||
* at most once, and only once there is a sink to play through - asking on the bench, where the
|
||||
@@ -124,30 +93,9 @@ object CarAudio {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture has heard nothing for a while. If a media app that blocks capture is the one
|
||||
* playing, the car screen says why the music is silent, once a drive: Spotify does this.
|
||||
*/
|
||||
internal fun silent(context: android.content.Context) = android.os.Handler(android.os.Looper.getMainLooper()).post {
|
||||
if (toldBlocked) return@post
|
||||
val media = app.seb3thehacker.gearslip.car.CarServices.mediaOrNull() ?: return@post
|
||||
if (!media.now.value.playing) return@post
|
||||
val name = BLOCKS_CAPTURE[media.packageName] ?: return@post
|
||||
toldBlocked = true
|
||||
GearslipLog.i("audio: $name is playing and capture hears nothing: it blocks other apps from playing its sound")
|
||||
app.seb3thehacker.gearslip.car.CarToasts.show(this, context.getString(app.seb3thehacker.gearslip.R.string.car_audio_blocked, name), androidx.car.app.CarToast.LENGTH_LONG)
|
||||
}
|
||||
|
||||
@Volatile private var toldBlocked = false
|
||||
|
||||
/** Media apps known to keep other apps from capturing their sound, by package. */
|
||||
private val BLOCKS_CAPTURE = mapOf("com.spotify.music" to "Spotify")
|
||||
|
||||
/** The car went away. Everything is torn down; the next connection asks again. */
|
||||
fun release() {
|
||||
main.removeCallbacks(releaseWhenGone)
|
||||
wanted = false
|
||||
toldBlocked = false
|
||||
AudioCaptureService.stop()
|
||||
_capture.value = Capture.OFF
|
||||
}
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
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) }
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
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+""")
|
||||
}
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
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")
|
||||
}
|
||||
@@ -1,417 +0,0 @@
|
||||
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)
|
||||
// Time spent deciding requests since the last page load, for the log line that tells whether
|
||||
// the blocker is what makes a page or a video slow to start.
|
||||
private val checkedCount = AtomicLong(0)
|
||||
private val checkedNanos = AtomicLong(0)
|
||||
private val blockedSincePage = 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
|
||||
// A ranged request is a video or audio stream fetching its next piece: ads never come
|
||||
// that way, and a stream makes hundreds of them, so they skip the lists entirely.
|
||||
if (request.requestHeaders?.keys?.any { it.equals("Range", ignoreCase = true) } == true) return null
|
||||
val started = System.nanoTime()
|
||||
try {
|
||||
return decide(request, pageUrl)
|
||||
} finally {
|
||||
checkedCount.incrementAndGet()
|
||||
checkedNanos.addAndGet(System.nanoTime() - started)
|
||||
}
|
||||
}
|
||||
|
||||
/** Logs what the blocker cost the page that just finished loading, and starts counting afresh. */
|
||||
fun logPageCost(url: String?) {
|
||||
val count = checkedCount.getAndSet(0)
|
||||
val ms = checkedNanos.getAndSet(0) / 1_000_000
|
||||
val blocked = blockedSincePage.getAndSet(0)
|
||||
if (count > 0) GearslipLog.i("adblock: $count requests checked in ${ms}ms, $blocked blocked, for ${url?.substringBefore('?')}")
|
||||
}
|
||||
|
||||
private fun decide(request: WebResourceRequest, pageUrl: String?): WebResourceResponse? {
|
||||
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()
|
||||
blockedSincePage.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()
|
||||
}
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
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
@@ -1,336 +0,0 @@
|
||||
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("")
|
||||
}
|
||||
}
|
||||
@@ -1,610 +0,0 @@
|
||||
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>()
|
||||
}
|
||||
}
|
||||
@@ -1,330 +0,0 @@
|
||||
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
@@ -1,93 +0,0 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
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()
|
||||
}
|
||||
@@ -39,7 +39,6 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -70,8 +69,7 @@ fun AssistantOverlay(modifier: Modifier = Modifier) {
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.drawBehind {
|
||||
// Dark enough by the text that nothing on the screen behind reads through it.
|
||||
drawRect(Brush.verticalGradient(0f to Color.Transparent, 0.3f to Color.Black.copy(alpha = 0.8f), 1f to Color.Black.copy(alpha = 0.92f)))
|
||||
drawRect(Brush.verticalGradient(0f to Color.Transparent, 0.45f to Color.Black.copy(alpha = 0.6f), 1f to Color.Black.copy(alpha = 0.85f)))
|
||||
// A wide, flat oval of light rising from the bottom edge.
|
||||
scale(1f, 0.22f, Offset(size.width / 2, size.height)) {
|
||||
drawCircle(
|
||||
@@ -88,12 +86,8 @@ fun AssistantOverlay(modifier: Modifier = Modifier) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
PulsingMic(listening)
|
||||
Column(Modifier.weight(1f), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
val listeningText = stringResource(R.string.assist_listening)
|
||||
val momentText = stringResource(R.string.assist_one_moment)
|
||||
val waiting = state.heard.isBlank() &&
|
||||
(state.phase == CarAssistant.Phase.LISTENING || state.phase == CarAssistant.Phase.THINKING)
|
||||
val (small, big) = when (state.phase) {
|
||||
CarAssistant.Phase.LISTENING -> "" to state.heard.ifBlank { if (state.ready) listeningText else momentText }
|
||||
CarAssistant.Phase.LISTENING -> "" to state.heard.ifBlank { if (state.ready) "Listening…" else "One moment…" }
|
||||
CarAssistant.Phase.THINKING -> "" to state.heard.ifBlank { "…" }
|
||||
else -> state.heard to state.reply
|
||||
}
|
||||
@@ -102,33 +96,22 @@ fun AssistantOverlay(modifier: Modifier = Modifier) {
|
||||
}
|
||||
Text(
|
||||
big,
|
||||
color = if (waiting) Color.White.copy(alpha = 0.7f) else Color.White,
|
||||
color = if (big == "Listening…" || big == "One moment…" || big == "…") Color.White.copy(alpha = 0.7f) else Color.White,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
// What to say, until the driver starts saying it.
|
||||
if (state.phase == CarAssistant.Phase.LISTENING && state.ready && state.heard.isBlank()) {
|
||||
Text(
|
||||
stringResource(R.string.assist_examples),
|
||||
color = Color.White.copy(alpha = 0.6f),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 2,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (state.missingPermission) {
|
||||
GsButton(tone = GsTone.Tonal, onClick = {
|
||||
context.startActivity(
|
||||
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", context.packageName, null)),
|
||||
)
|
||||
}) { Text(stringResource(R.string.assist_grant)) }
|
||||
}) { Text("Grant") }
|
||||
}
|
||||
GsIconButton(Icons.Filled.Close, stringResource(R.string.msg_dismiss), CarAssistant::cancel)
|
||||
GsIconButton(Icons.Filled.Close, "Dismiss", CarAssistant::cancel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.PackageManager
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import app.seb3thehacker.gearslip.GearslipLog
|
||||
@@ -26,11 +25,6 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
*
|
||||
* When the car named its Bluetooth address, only that device counts. Otherwise any audio
|
||||
* connection does, since there's no telling which device is the car.
|
||||
*
|
||||
* Many cars switch Bluetooth music off while Android Auto runs and want it over Android Auto
|
||||
* instead (a Skoda, a VW, an Audi, a Bolt). If the phone's Bluetooth audio to the car was up when
|
||||
* the car showed Gearslip and drops after, that's what happened: this car's music goes through
|
||||
* Gearslip from now on, and the car screen says so.
|
||||
*/
|
||||
object BluetoothAudioCheck {
|
||||
|
||||
@@ -47,8 +41,6 @@ object BluetoothAudioCheck {
|
||||
private var appContext: Context? = null
|
||||
private var armed = false
|
||||
private var receiver: BroadcastReceiver? = null
|
||||
/** Whether Bluetooth audio to the car was up when the car showed Gearslip; null until known. */
|
||||
private var connectedAtStart: Boolean? = null
|
||||
|
||||
fun init(context: Context) {
|
||||
appContext = context.applicationContext
|
||||
@@ -58,14 +50,11 @@ object BluetoothAudioCheck {
|
||||
fun onProjecting() = main.post {
|
||||
if (armed) return@post
|
||||
armed = true
|
||||
connectedAtStart = null
|
||||
audioToCar { connectedAtStart = it }
|
||||
main.postDelayed(::start, WAIT_MS)
|
||||
}
|
||||
|
||||
fun onCarDisconnected() = main.post {
|
||||
armed = false
|
||||
connectedAtStart = null
|
||||
main.removeCallbacksAndMessages(null)
|
||||
stopWatching()
|
||||
_missing.value = false
|
||||
@@ -108,58 +97,36 @@ object BluetoothAudioCheck {
|
||||
private fun check() {
|
||||
if (!armed) return
|
||||
val context = appContext ?: return
|
||||
if (CarSettings.pipesNow) {
|
||||
if (CarSettings.pipeAudio.value) {
|
||||
set(false, "audio goes through Gearslip")
|
||||
return
|
||||
}
|
||||
audioToCar { ok ->
|
||||
if (ok == null) return@audioToCar
|
||||
if (!ok && connectedAtStart == true) return@audioToCar switchToGearslip()
|
||||
if (!CarSettings.bluetoothWarning.value) {
|
||||
set(false, "the warning is turned off")
|
||||
return@audioToCar
|
||||
}
|
||||
set(!ok, if (ok) "connected to the car" else "not connected to the car")
|
||||
if (!CarSettings.bluetoothWarning.value) {
|
||||
set(false, "the warning is turned off")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
/** The car dropped Bluetooth music once Gearslip was up: send this car's music through Gearslip. */
|
||||
private fun switchToGearslip() {
|
||||
val car = CarBluetooth.carAddress ?: return
|
||||
val context = appContext ?: return
|
||||
if (!app.seb3thehacker.gearslip.audio.CarAudio.sinkOffered) return
|
||||
log.i("Bluetooth audio: the car turned it off with Gearslip running, so this car's music goes through Gearslip")
|
||||
CarSettings.rememberAudioCar(car)
|
||||
set(false, "audio goes through Gearslip")
|
||||
app.seb3thehacker.gearslip.audio.CarAudio.request()
|
||||
CarToasts.show(this, context.getString(R.string.car_audio_switched), androidx.car.app.CarToast.LENGTH_LONG)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the phone's Bluetooth audio is connected to the car, on the main thread: null when
|
||||
* that can't be told (no permission). Bluetooth switched off counts as not connected.
|
||||
*/
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun audioToCar(result: (Boolean?) -> Unit) {
|
||||
val context = appContext ?: return result(null)
|
||||
if (context.checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED) {
|
||||
log.i("can't check the audio connection: the Bluetooth permission isn't granted")
|
||||
return result(null)
|
||||
return
|
||||
}
|
||||
val adapter = context.getSystemService(BluetoothManager::class.java)?.adapter ?: return
|
||||
if (!adapter.isEnabled) {
|
||||
set(true, "Bluetooth is off")
|
||||
return
|
||||
}
|
||||
val adapter = context.getSystemService(BluetoothManager::class.java)?.adapter ?: return result(null)
|
||||
if (!adapter.isEnabled) return result(false)
|
||||
val car = CarBluetooth.carAddress?.uppercase()
|
||||
val asked = adapter.getProfileProxy(context, object : BluetoothProfile.ServiceListener {
|
||||
adapter.getProfileProxy(context, object : BluetoothProfile.ServiceListener {
|
||||
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
|
||||
val connected = runCatching { proxy.connectedDevices }.getOrDefault(emptyList())
|
||||
adapter.closeProfileProxy(profile, proxy)
|
||||
val ok = if (car != null) connected.any { it.address.uppercase() == car } else connected.isNotEmpty()
|
||||
main.post { result(ok) }
|
||||
main.post {
|
||||
set(!ok, if (ok) "connected to the car" else "not connected to the car")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(profile: Int) {}
|
||||
}, BluetoothProfile.A2DP)
|
||||
if (!asked) result(null)
|
||||
}
|
||||
|
||||
private fun set(missing: Boolean, why: String) {
|
||||
|
||||
@@ -38,7 +38,7 @@ import androidx.compose.ui.unit.dp
|
||||
@Composable
|
||||
internal fun BluetoothPairOverlay() {
|
||||
val prompt by CarBluetooth.prompt.collectAsState()
|
||||
val bluetoothAudio = !CarSettings.pipeAudio.collectAsState().value && !CarSettings.thisCarPipes.collectAsState().value
|
||||
val bluetoothAudio = !CarSettings.pipeAudio.collectAsState().value
|
||||
when (prompt) {
|
||||
CarBluetooth.Prompt.NONE -> {
|
||||
val missing by BluetoothAudioCheck.missing.collectAsState()
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
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.layout.widthIn
|
||||
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
|
||||
private val SEARCH_MAX_WIDTH = 560.dp
|
||||
/** Five tiles across at most, so a short list sits in the middle instead of along the left. */
|
||||
private val GRID_MAX_WIDTH = TILE_WIDTH * 5
|
||||
/** Room above each tile for the remove button, which sits on the icon's corner. */
|
||||
private val REMOVE_ROOM = 10.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
|
||||
|
||||
// A narrow, centred column: on a wide car screen a bar from edge to edge reads as a toolbar
|
||||
// rather than a search box, and tiles spread across it look scattered.
|
||||
Column(
|
||||
Modifier.fillMaxSize().padding(horizontal = 16.dp).padding(top = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||
) {
|
||||
SearchBar(onSearch)
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(TILE_WIDTH),
|
||||
contentPadding = PaddingValues(bottom = 16.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.widthIn(max = GRID_MAX_WIDTH).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") {
|
||||
Box(Modifier.padding(top = REMOVE_ROOM)) {
|
||||
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.widthIn(max = SEARCH_MAX_WIDTH).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) }
|
||||
// The remove button overhangs the icon's corner, into the room left above it, so nothing
|
||||
// clips it: neither the grid's edge nor the next row.
|
||||
Box(Modifier.width(TILE_WIDTH).padding(top = REMOVE_ROOM)) {
|
||||
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.TopCenter).offset(x = ICON_SIZE / 2, y = -REMOVE_ROOM).size(28.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),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -74,12 +74,8 @@ fun CarAppStage(
|
||||
// The app's own drawing goes underneath; everything else is the host's rendering of
|
||||
// the template it described, laid over the top.
|
||||
androidx.compose.runtime.CompositionLocalProvider(LocalChromeCover provides cover, LocalCarAppConnection provides connection) {
|
||||
// A place list's map is the host's to draw; any other map is the app's own surface.
|
||||
val placeList = template as? androidx.car.app.model.PlaceListMapTemplate
|
||||
if (placeList != null) {
|
||||
HostPlaceMap(placeList, Modifier.fillMaxSize())
|
||||
} else if (status.surfaceRequested) {
|
||||
AppSurface(connection, frame, template, embedded, Modifier.fillMaxSize())
|
||||
if (status.surfaceRequested) {
|
||||
AppSurface(connection, frame, template, Modifier.fillMaxSize())
|
||||
}
|
||||
// The docked player floats over one side of the map: the chrome keeps clear of it,
|
||||
// and the app hears that side is covered, so its map centres in the rest.
|
||||
@@ -120,7 +116,6 @@ private fun AppSurface(
|
||||
connection: CarAppConnection,
|
||||
frame: CarEnvironment.Frame,
|
||||
template: Template?,
|
||||
embedded: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var size by remember { mutableStateOf(0 to 0) }
|
||||
@@ -130,37 +125,19 @@ private fun AppSurface(
|
||||
// The app lays its map out around whatever the host covers, so a template change or a card
|
||||
// changing size moves the visible area even though the surface itself hasn't changed.
|
||||
val (width, height) = size
|
||||
// A full-screen map takes the knob: turn to zoom, nudge to pan, press for its buttons. The
|
||||
// home screen's map pane leaves it alone, so the knob still moves between the cards there.
|
||||
val focusManager = androidx.compose.ui.platform.LocalFocusManager.current
|
||||
val isMap = !embedded && !isContentTemplate(template)
|
||||
DisposableEffect(connection, isMap, width, height) {
|
||||
if (!isMap || width == 0 || height == 0) return@DisposableEffect onDispose {}
|
||||
val knob = MapKnob(connection, width, height) { focusManager.clearFocus() }
|
||||
CarKeys.knobTaker = knob
|
||||
onDispose {
|
||||
knob.release()
|
||||
if (CarKeys.knobTaker === knob) CarKeys.knobTaker = null
|
||||
}
|
||||
}
|
||||
val measured = bounds?.takeIf { width > 0 && height > 0 }?.let { cover?.visibleWithin(it, width, height) }
|
||||
// The card column moves the map's centre but isn't taken out of the stable area: apps size
|
||||
// their own overlays from it, and Vela's speedometer shrank to fit the strip right of the cards.
|
||||
val cardsAtEnd = LocalCardsAtEnd.current
|
||||
val cardEdge = cardEdge
|
||||
val measuredStable = bounds?.takeIf { width > 0 && height > 0 }?.let { cover?.visibleWithin(it, width, height, skip = cardEdge) }
|
||||
// A little room above whatever ends the map at the bottom (the nav bar, or the screen's edge):
|
||||
// apps draw their own corner pieces (Waze's speedometer) right on the area's bottom edge,
|
||||
// which left them sitting flush on the bar.
|
||||
val bottomGap = with(androidx.compose.ui.platform.LocalDensity.current) { MAP_BOTTOM_GAP.roundToPx() }
|
||||
LaunchedEffect(template, size, measured, measuredStable, cardsAtEnd, bottomGap) {
|
||||
LaunchedEffect(template, size, measured, measuredStable, cardsAtEnd) {
|
||||
if (width > 0 && height > 0) {
|
||||
fun android.graphics.Rect.clearOfBar() = if (isEmpty) this else android.graphics.Rect(left, top, right, maxOf(top, bottom - bottomGap))
|
||||
val visible = visibleAreaFor(template, width, height, measured, cardsAtEnd).clearOfBar()
|
||||
val visible = visibleAreaFor(template, width, height, measured, cardsAtEnd)
|
||||
// Waze seems to centre its car in the stable area rather than the visible one, so it
|
||||
// gets the cards taken out of that too. Test: see whether its car moves out from under them.
|
||||
val stable = if (!isContentTemplate(template) && connection.appPackage != "com.waze") {
|
||||
visibleAreaFor(template, width, height, measuredStable, cardsAtEnd).clearOfBar()
|
||||
visibleAreaFor(template, width, height, measuredStable, cardsAtEnd)
|
||||
} else {
|
||||
visible
|
||||
}
|
||||
@@ -293,9 +270,6 @@ private fun ChromeBar(
|
||||
}
|
||||
}
|
||||
|
||||
/** How far above the bottom of the map the area an app may draw in stops. */
|
||||
private val MAP_BOTTOM_GAP = 12.dp
|
||||
|
||||
/** Height of [ChromeBar]; the template's own chrome starts below it. */
|
||||
private val CHROME_BAR = 44.dp
|
||||
|
||||
|
||||
@@ -57,12 +57,11 @@ object CarBluetooth {
|
||||
private val main = Handler(Looper.getMainLooper())
|
||||
private var appContext: Context? = null
|
||||
|
||||
/** This session's car: its channel, how to open and write to it, and who hears its answers. */
|
||||
/** This session's car: its channel, and how to open and write to it. */
|
||||
private class Car(
|
||||
val service: ServiceDiscovery.BluetoothService,
|
||||
val open: () -> Unit,
|
||||
val send: (messageId: Int, body: ByteArray) -> Unit,
|
||||
val answered: () -> Unit,
|
||||
)
|
||||
|
||||
@Volatile private var car: Car? = null
|
||||
@@ -81,59 +80,15 @@ object CarBluetooth {
|
||||
|
||||
fun init(context: Context) {
|
||||
appContext = context.applicationContext
|
||||
_addressWanted.value = AppSettings.getString(context, KEY_ADDRESS_ASK, "").ifEmpty { null }
|
||||
}
|
||||
|
||||
private val _addressWanted = MutableStateFlow<String?>(null)
|
||||
|
||||
/**
|
||||
* A car paired over Bluetooth connected while Gearslip didn't know the phone's address: the
|
||||
* car's name, for the phone's home screen to ask for it. Null when there's nothing to ask.
|
||||
* Issue #18: a 2021 Bolt hung up 25 seconds in until it was told which phone this is.
|
||||
*/
|
||||
val addressWanted: StateFlow<String?> = _addressWanted
|
||||
|
||||
/** Debug builds: shows the phone's card as if a car had just asked. */
|
||||
fun askForAddressNow(context: Context) {
|
||||
AppSettings.putString(context, KEY_ADDRESS_ASK, "Test car")
|
||||
_addressWanted.value = "Test car"
|
||||
}
|
||||
|
||||
/** The phone's card was answered or turned away: it doesn't come back for this car. */
|
||||
fun addressAsked(context: Context) {
|
||||
AppSettings.putString(context, KEY_ADDRESS_ASK, "")
|
||||
_addressWanted.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Once per car: it waits on the phone, and the car screen says so once. A car that keeps its
|
||||
* screen isn't asked again on every drive: plenty of cars just wait for a tap on their icon.
|
||||
*/
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun askForAddress(device: BluetoothDevice, carAddress: String) {
|
||||
val context = appContext ?: return
|
||||
val asked = AppSettings.getString(context, KEY_ADDRESS_ASKED, "").split(",").filter { it.isNotEmpty() }
|
||||
if (hash(carAddress) in asked) return
|
||||
AppSettings.putString(context, KEY_ADDRESS_ASKED, (asked + hash(carAddress)).distinct().joinToString(","))
|
||||
val name = runCatching { device.alias ?: device.name }.getOrNull()?.takeIf { it.isNotBlank() }
|
||||
?: context.getString(R.string.bt_address_your_car)
|
||||
AppSettings.putString(context, KEY_ADDRESS_ASK, name)
|
||||
_addressWanted.value = name
|
||||
log.i("paired, but the phone's address isn't known: asking on the phone")
|
||||
main.post { CarToasts.show(this, context.getString(R.string.bt_address_car_note), CarToast.LENGTH_LONG) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Called once the car lists its services. Asks on the car screen only if there's pairing to do.
|
||||
* [answered] runs when the car answers a pairing request or the pairing finishes.
|
||||
*/
|
||||
/** Called once the car lists its services. Asks on the car screen only if there's pairing to do. */
|
||||
fun onCarConnected(
|
||||
service: ServiceDiscovery.BluetoothService,
|
||||
open: () -> Unit,
|
||||
send: (messageId: Int, body: ByteArray) -> Unit,
|
||||
answered: () -> Unit = {},
|
||||
) {
|
||||
car = Car(service, open, send, answered)
|
||||
car = Car(service, open, send)
|
||||
bondStarted = false
|
||||
announcing = false
|
||||
log.i("the car offers Bluetooth pairing, methods=${service.pairingMethods}")
|
||||
@@ -144,10 +99,7 @@ object CarBluetooth {
|
||||
announcing = true
|
||||
open()
|
||||
}
|
||||
device.bondState == BluetoothDevice.BOND_BONDED -> {
|
||||
log.i("already paired with this car")
|
||||
askForAddress(device, service.carAddress)
|
||||
}
|
||||
device.bondState == BluetoothDevice.BOND_BONDED -> log.i("already paired with this car")
|
||||
declined(service.carAddress) -> log.i("not paired, and the driver said not now for this car")
|
||||
else -> _prompt.value = Prompt.ASK
|
||||
}
|
||||
@@ -167,40 +119,6 @@ object CarBluetooth {
|
||||
c.open()
|
||||
}
|
||||
|
||||
/**
|
||||
* The car is keeping its own screen: do what Android Auto does on every connection, and tell
|
||||
* it over Bluetooth which phone this is. Every Honda on file keeps its screen (a 2017 Pilot, a
|
||||
* 2017 and a 2020 Civic; issues #43 and #57), and in each log the car's Bluetooth channel was
|
||||
* never opened. Honda starts Android Auto with what its manuals call auto pairing, which runs
|
||||
* on that channel, and a 2021 Bolt kept hanging up until it heard the phone's address (#18).
|
||||
*
|
||||
* - Paired, with the address typed in: the car already heard it at connect.
|
||||
* - Paired, no address: open the channel, and ask for the address on the phone again.
|
||||
* - Not paired: the Pair card waits on the car's screen, which this car won't give up, so
|
||||
* pair as if the driver had tapped it. Android's pairing dialog still asks on the phone.
|
||||
*
|
||||
* True when the car was sent something it may answer.
|
||||
*/
|
||||
fun onScreenWithheld(): Boolean {
|
||||
val c = car ?: return false
|
||||
if (pairing || announcing) return false
|
||||
val device = device() ?: return false
|
||||
return when {
|
||||
device.bondState == BluetoothDevice.BOND_BONDED -> {
|
||||
askForAddress(device, c.service.carAddress)
|
||||
announce("the car is keeping its screen")
|
||||
true
|
||||
}
|
||||
device.bondState != BluetoothDevice.BOND_NONE -> false
|
||||
declined(c.service.carAddress) -> false
|
||||
else -> {
|
||||
log.i("the car is keeping its screen and isn't paired with this phone - pairing, as Android Auto does")
|
||||
main.post { pair() }
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onCarDisconnected() {
|
||||
car = null
|
||||
pairing = false
|
||||
@@ -268,7 +186,6 @@ object CarBluetooth {
|
||||
MSG_PAIRING_RESPONSE -> {
|
||||
val fields = Wire.fields(body)
|
||||
log.i("<- PairingResponse status=${Wire.varint(fields, 1)} already_paired=${Wire.varint(fields, 2) == 1L}")
|
||||
c.answered()
|
||||
if (!pairing) return
|
||||
// Some cars start pairing themselves once they answer; give them a moment first.
|
||||
main.postDelayed({ startBond("the car answered") }, CAR_BOND_WAIT_MS)
|
||||
@@ -303,10 +220,7 @@ object CarBluetooth {
|
||||
log.i("bond state $previous -> $state")
|
||||
when {
|
||||
state == BluetoothDevice.BOND_BONDING -> bondStarted = true
|
||||
state == BluetoothDevice.BOND_BONDED -> {
|
||||
finish(R.string.bt_paired)
|
||||
car?.answered?.invoke()
|
||||
}
|
||||
state == BluetoothDevice.BOND_BONDED -> finish(R.string.bt_paired)
|
||||
previous == BluetoothDevice.BOND_BONDING -> finish(R.string.bt_pair_unfinished)
|
||||
}
|
||||
}
|
||||
@@ -331,10 +245,7 @@ object CarBluetooth {
|
||||
|
||||
/** The car as a Bluetooth device, or null (and logged) when Gearslip can't reach Bluetooth. */
|
||||
private fun device(): BluetoothDevice? {
|
||||
val context = appContext ?: run {
|
||||
log.w("can't check pairing: Bluetooth isn't set up yet")
|
||||
return null
|
||||
}
|
||||
val context = appContext ?: return null
|
||||
val address = car?.service?.carAddress ?: return null
|
||||
if (context.checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED) {
|
||||
log.i("can't check pairing: the Bluetooth permission isn't granted")
|
||||
@@ -373,7 +284,6 @@ object CarBluetooth {
|
||||
val tidy = address.trim().uppercase().replace('-', ':')
|
||||
if (tidy.isNotEmpty() && !BluetoothAdapter.checkBluetoothAddress(tidy)) return false
|
||||
AppSettings.putString(context, KEY_PHONE_ADDRESS, tidy)
|
||||
if (tidy.isNotEmpty()) addressAsked(context)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -388,10 +298,6 @@ object CarBluetooth {
|
||||
.take(8).joinToString("") { "%02x".format(it) }
|
||||
|
||||
private const val KEY_DECLINED = "bluetooth_pair_declined"
|
||||
/** The car's name while the phone's card asks for the address; empty when it doesn't. */
|
||||
private const val KEY_ADDRESS_ASK = "bluetooth_address_ask"
|
||||
/** Hashes of the cars already asked about, so each asks once. */
|
||||
private const val KEY_ADDRESS_ASKED = "bluetooth_address_asked"
|
||||
private const val KEY_PHONE_ADDRESS = "phone_bluetooth_address"
|
||||
private const val CAR_REPLY_WAIT_MS = 5_000L
|
||||
private const val CAR_BOND_WAIT_MS = 3_000L
|
||||
|
||||
@@ -14,10 +14,6 @@ import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -98,66 +94,19 @@ private fun WeatherCard(context: Context) {
|
||||
is WeatherState.Ready -> {
|
||||
val w = s.data
|
||||
WeatherIcon(w.code, w.isDay, size = 44.dp)
|
||||
Column(Modifier.weight(1f)) {
|
||||
Column {
|
||||
Text(
|
||||
"${w.temp}${w.tempUnit} · ${Weather.condition(w.code)}",
|
||||
style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(w.place, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1)
|
||||
Text(w.place, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
NextHours(w.hours, Modifier.weight(1.3f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The next few hours beside the weather now, in the same card, so a glance answers "will it rain
|
||||
* on the way" without opening Weather. As many as fit, up to five; none if fewer than three would.
|
||||
*/
|
||||
@Composable
|
||||
private fun NextHours(all: List<HourForecast>, modifier: Modifier) {
|
||||
val now = System.currentTimeMillis()
|
||||
// The forecast is cached, so its first hours may already be past.
|
||||
val hours = all.filter { it.time > now }
|
||||
androidx.compose.foundation.layout.BoxWithConstraints(modifier) {
|
||||
val fit = (maxWidth / HOUR_WIDTH).toInt().coerceAtMost(5)
|
||||
if (fit < 3 || hours.size < 3) return@BoxWithConstraints
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(Modifier.width(1.dp).height(64.dp).background(MaterialTheme.colorScheme.outlineVariant))
|
||||
hours.take(fit).forEach { h ->
|
||||
Column(
|
||||
Modifier.weight(1f),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
Text(
|
||||
DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(h.time)).replace(":00", ""),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
)
|
||||
WeatherIcon(h.code, h.isDay, size = 24.dp)
|
||||
Text("${h.temp}°", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
|
||||
// Kept as a blank line when dry, so every column lines up.
|
||||
Text(
|
||||
if (h.rainChance >= RAIN_SHOWN) "${h.rainChance}%" else " ",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val HOUR_WIDTH = 64.dp
|
||||
|
||||
/** Below this a chance of rain is noise on a glance card; the Weather screen still shows it. */
|
||||
private const val RAIN_SHOWN = 20
|
||||
|
||||
@Composable
|
||||
private fun AgendaCard(context: Context) {
|
||||
val events by produceState<List<AgendaEvent>?>(cachedAgenda) {
|
||||
|
||||
@@ -35,35 +35,11 @@ object CarKeys {
|
||||
/** Delivers an Android key event to the car display; set while the car UI is showing. */
|
||||
@Volatile var focusSink: ((keyCode: Int, down: Boolean) -> Unit)? = null
|
||||
|
||||
/** What a [KnobTaker] is offered: a turn of the knob, a focus key edge, or Back. */
|
||||
sealed interface KnobEvent {
|
||||
data class Turn(val steps: Int) : KnobEvent
|
||||
data class Key(val keyCode: Int, val down: Boolean) : KnobEvent
|
||||
data object Back : KnobEvent
|
||||
}
|
||||
|
||||
/**
|
||||
* Something on screen that wants the knob before focus moves: the full-screen map, which
|
||||
* zooms and pans with it ([MapKnob]), and the app grid, which starts on the app opened last.
|
||||
* Returns true for an event it kept. Called on the thread the keys arrive on, not the main one.
|
||||
*/
|
||||
fun interface KnobTaker {
|
||||
fun take(event: KnobEvent): Boolean
|
||||
}
|
||||
|
||||
@Volatile var knobTaker: KnobTaker? = null
|
||||
|
||||
private var appContext: Context? = null
|
||||
private val main = Handler(Looper.getMainLooper())
|
||||
|
||||
fun init(context: Context) { appContext = context.applicationContext }
|
||||
|
||||
/** The keys that act on the media app. */
|
||||
private val MEDIA_KEYS = setOf(
|
||||
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, KeyEvent.KEYCODE_MEDIA_PLAY, KeyEvent.KEYCODE_MEDIA_PAUSE,
|
||||
KeyEvent.KEYCODE_MEDIA_STOP, KeyEvent.KEYCODE_MEDIA_NEXT, KeyEvent.KEYCODE_MEDIA_PREVIOUS,
|
||||
)
|
||||
|
||||
/**
|
||||
* Every key worth asking the head unit for. The binding request lists what the head unit
|
||||
* offered; when it offers nothing (some units leave the list empty and send keys anyway),
|
||||
@@ -87,30 +63,22 @@ object CarKeys {
|
||||
*/
|
||||
fun onKey(keyCode: Int, down: Boolean, longPress: Boolean): Boolean {
|
||||
if (keyCode in FOCUS_KEYS) {
|
||||
if (knobTaker?.take(KnobEvent.Key(keyCode, down)) == true) return true
|
||||
_focusMoves.tryEmit(Unit)
|
||||
focusSink?.invoke(keyCode, down)
|
||||
return true
|
||||
}
|
||||
if (!down) return keyCode in WANTED
|
||||
// Null until the car shows Gearslip's screen. A 2020 Civic sends Play as soon as it hears
|
||||
// the phone's Bluetooth address, while still on its own screen, and reaching for media
|
||||
// then ended the session (issue #43).
|
||||
val media = CarServices.mediaOrNull()
|
||||
if (media == null && keyCode in MEDIA_KEYS) {
|
||||
GearslipLog.i("keys: $keyCode before the car shows Gearslip - no media app to send it to yet")
|
||||
return true
|
||||
}
|
||||
val media = CarServices.media
|
||||
when (keyCode) {
|
||||
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> media?.togglePlay()
|
||||
KeyEvent.KEYCODE_MEDIA_PLAY -> if (media != null && !media.now.value.playing) media.togglePlay()
|
||||
KeyEvent.KEYCODE_MEDIA_PAUSE, KeyEvent.KEYCODE_MEDIA_STOP -> if (media != null && media.now.value.playing) media.togglePlay()
|
||||
KeyEvent.KEYCODE_MEDIA_NEXT -> media?.next()
|
||||
KeyEvent.KEYCODE_MEDIA_PREVIOUS -> media?.previous()
|
||||
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> media.togglePlay()
|
||||
KeyEvent.KEYCODE_MEDIA_PLAY -> if (!media.now.value.playing) media.togglePlay()
|
||||
KeyEvent.KEYCODE_MEDIA_PAUSE, KeyEvent.KEYCODE_MEDIA_STOP -> if (media.now.value.playing) media.togglePlay()
|
||||
KeyEvent.KEYCODE_MEDIA_NEXT -> media.next()
|
||||
KeyEvent.KEYCODE_MEDIA_PREVIOUS -> media.previous()
|
||||
KeyEvent.KEYCODE_SEARCH, KeyEvent.KEYCODE_VOICE_ASSIST -> voice()
|
||||
KeyEvent.KEYCODE_CALL -> call()
|
||||
KeyEvent.KEYCODE_ENDCALL -> endCall()
|
||||
KeyEvent.KEYCODE_BACK -> if (knobTaker?.take(KnobEvent.Back) != true) screen(ScreenKey.BACK)
|
||||
KeyEvent.KEYCODE_BACK -> screen(ScreenKey.BACK)
|
||||
KeyEvent.KEYCODE_HOME -> screen(ScreenKey.HOME)
|
||||
KEYCODE_NAVIGATION -> screen(ScreenKey.MAP)
|
||||
KEYCODE_MEDIA -> screen(ScreenKey.MEDIA)
|
||||
@@ -124,7 +92,6 @@ object CarKeys {
|
||||
/** A turn of the rotary knob: each click moves focus one step, like Tab or Shift+Tab. */
|
||||
fun onRotate(delta: Int) {
|
||||
val sink = focusSink ?: return
|
||||
if (knobTaker?.take(KnobEvent.Turn(delta)) == true) return
|
||||
_focusMoves.tryEmit(Unit)
|
||||
val code = if (delta > 0) KeyEvent.KEYCODE_TAB else KEYCODE_SHIFT_TAB
|
||||
repeat(kotlin.math.abs(delta).coerceAtMost(MAX_ROTARY_STEPS)) {
|
||||
|
||||
@@ -16,16 +16,6 @@ import androidx.compose.foundation.layout.size
|
||||
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.lazy.grid.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import app.seb3thehacker.gearslip.AppSettings
|
||||
import kotlinx.coroutines.launch
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||
@@ -85,8 +75,6 @@ private class Tile(
|
||||
/** Known not to work; drawn with a red X instead of being left off the launcher. */
|
||||
val broken: Boolean = false,
|
||||
val onLongClick: (() -> Unit)? = null,
|
||||
/** The entry's [Entry.componentId], for finding the app opened last. */
|
||||
val id: String? = null,
|
||||
val onClick: () -> Unit,
|
||||
)
|
||||
|
||||
@@ -149,10 +137,6 @@ internal object LauncherCache {
|
||||
}
|
||||
}
|
||||
}
|
||||
CarSettings.hideOnFirstSight(tiles.mapNotNull { entry ->
|
||||
val pkg = (entry.template?.component ?: entry.media?.component)?.packageName ?: entry.messaging?.packageName
|
||||
entry.componentId?.takeIf { pkg != null && KnownApps.startsHidden(context, pkg, entry.messaging != null) }
|
||||
})
|
||||
return tiles.sortedBy { it.label.lowercase() }.also { flow.value = it }
|
||||
}
|
||||
}
|
||||
@@ -306,53 +290,21 @@ fun CarLauncher() {
|
||||
entry.builtIn?.let { context.getString(it.labelRes) } ?: entry.label, entry.icon, entry.builtIn?.glyph,
|
||||
broken = pkg != null && (KnownApps.isBroken(context, pkg) || (entry.template != null && KnownApps.screenBroken(pkg))),
|
||||
onLongClick = id?.let { { navigator.showAppMenu(it) } },
|
||||
id = id,
|
||||
) {
|
||||
id?.let { AppSettings.putString(context, KEY_LAST_APP, it) }
|
||||
openApp(entry, id?.let { running[it] }, navigator, frame)
|
||||
}
|
||||
) { openApp(entry, id?.let { running[it] }, navigator, frame) }
|
||||
}
|
||||
|
||||
val appOrder by CarSettings.appOrder.collectAsState()
|
||||
val hidden by CarSettings.hiddenApps.collectAsState()
|
||||
val tiles = launcherOrder(all, showVehicleData, appOrder, hidden).map(::tileOf)
|
||||
|
||||
// With the knob, the first move on this screen lands on the app opened last rather than on
|
||||
// the first button anywhere on screen (issue #74). After that the knob moves as usual, out to
|
||||
// the nav bar and back; nudging up or down already jumps a row.
|
||||
val gridState = rememberLazyGridState()
|
||||
val scope = rememberCoroutineScope()
|
||||
val lastFocus = remember { FocusRequester() }
|
||||
val lastId = remember { AppSettings.getString(context, KEY_LAST_APP, "") }
|
||||
val lastIndex = tiles.indexOfFirst { it.id == lastId }.coerceAtLeast(0)
|
||||
val focusIndex by rememberUpdatedState(lastIndex)
|
||||
DisposableEffect(Unit) {
|
||||
var used = false
|
||||
val taker = CarKeys.KnobTaker { event ->
|
||||
if (used || event == CarKeys.KnobEvent.Back || (event is CarKeys.KnobEvent.Key && !event.down)) {
|
||||
return@KnobTaker false
|
||||
}
|
||||
used = true
|
||||
scope.launch {
|
||||
gridState.scrollToItem(focusIndex)
|
||||
withFrameNanos { }
|
||||
runCatching { lastFocus.requestFocus() }
|
||||
}
|
||||
true
|
||||
}
|
||||
CarKeys.knobTaker = taker
|
||||
onDispose { if (CarKeys.knobTaker === taker) CarKeys.knobTaker = null }
|
||||
}
|
||||
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(minSize = 108.dp),
|
||||
state = gridState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(20.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
itemsIndexed(tiles) { i, tile -> AppTile(tile, if (i == lastIndex) Modifier.focusRequester(lastFocus) else Modifier) }
|
||||
items(tiles) { AppTile(it) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -369,14 +321,14 @@ private fun BrokenBadge(size: androidx.compose.ui.unit.Dp, modifier: Modifier =
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun AppTile(tile: Tile, modifier: Modifier = Modifier) {
|
||||
private fun AppTile(tile: Tile) {
|
||||
val shape = MaterialTheme.shapes.extraLarge
|
||||
// A long press opens the app's menu (pin, close) - the same gesture everywhere apps are
|
||||
// shown, here and on the nav bar. GsIconBox marks it in the theme's style.
|
||||
GsIconBox(
|
||||
onClick = tile.onClick,
|
||||
onLongClick = tile.onLongClick,
|
||||
modifier = modifier.height(120.dp),
|
||||
modifier = Modifier.height(120.dp),
|
||||
colors = GsColors(MaterialTheme.colorScheme.surfaceVariant, MaterialTheme.colorScheme.onSurfaceVariant),
|
||||
shape = shape,
|
||||
) {
|
||||
@@ -425,6 +377,3 @@ private fun TileLabel(tile: Tile, maxLines: Int = 2, modifier: Modifier = Modifi
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
/** The app opened from the launcher last, where the knob starts on the Apps screen. */
|
||||
private const val KEY_LAST_APP = "car_last_app"
|
||||
@@ -170,7 +170,6 @@ internal fun CarNavBar(navigator: CarNavigator) {
|
||||
// the screen above rather than a panel on it. Over the player it's see-through, so the
|
||||
// cover's glow carries on behind it.
|
||||
val overPlayer = navigator.current == CarScreen.Media
|
||||
val navMic by CarSettings.navMic.collectAsState()
|
||||
Surface(color = if (overPlayer) androidx.compose.ui.graphics.Color.Transparent else MaterialTheme.colorScheme.background) {
|
||||
BoxWithConstraints(Modifier.fillMaxWidth().height(NAV_BAR_HEIGHT).padding(horizontal = 10.dp)) {
|
||||
Row(Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) {
|
||||
@@ -181,10 +180,8 @@ internal fun CarNavBar(navigator: CarNavigator) {
|
||||
NavHomeButton(navigator, entries, running)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
NavItem(MediaIcons.Apps, navigator.current == CarScreen.Apps) { navigator.apps() }
|
||||
if (navMic) {
|
||||
Spacer(Modifier.width(6.dp))
|
||||
NavAssistantIcon()
|
||||
}
|
||||
Spacer(Modifier.width(6.dp))
|
||||
NavAssistantIcon()
|
||||
|
||||
// Pinned shortcuts, then whatever else is open right now. A pinned app that's also
|
||||
// open stays in its pinned spot, marked with a dot, instead of showing up twice.
|
||||
|
||||
@@ -83,14 +83,6 @@ class CarNavigator(startId: String) {
|
||||
stack.add(Crumb(screen, label, labelRes))
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a back button on this screen leads anywhere the nav bar doesn't: false when the
|
||||
* screen under it is Home or the app grid, which the nav bar's own buttons already reach.
|
||||
* Android Auto shows no back button on an app's first screen for the same reason.
|
||||
*/
|
||||
val backLeadsSomewhere: Boolean
|
||||
get() = stack.size >= 2 && stack[stack.size - 2].screen.let { it != CarScreen.Home && it != CarScreen.Apps }
|
||||
|
||||
/** The one way back to a clean slate: resets the whole trail to just Home. */
|
||||
fun home() {
|
||||
stack.clear()
|
||||
|
||||
@@ -37,13 +37,7 @@ object CarServices {
|
||||
private const val REPAUSE_MS = 2_000L
|
||||
|
||||
val nav: CarAppConnection by lazy { CarAppConnection(requireNotNull(context)) }
|
||||
val media: CarMedia by lazy {
|
||||
CarMedia(requireNotNull(context)).also { m ->
|
||||
// Remembered only once it connects: an app that refuses (NewPipe without Notification
|
||||
// access) must not replace the one the driver was listening to on the next drive.
|
||||
m.onReady = { app -> CarSettings.setLastMedia(app.component.flattenToString()) }
|
||||
}
|
||||
}
|
||||
val media: CarMedia by lazy { CarMedia(requireNotNull(context)) }
|
||||
|
||||
/** [nav], or null before the car screen has set things up. */
|
||||
fun navOrNull(): CarAppConnection? = if (context != null) nav else null
|
||||
@@ -73,7 +67,7 @@ object CarServices {
|
||||
private var autoplayPending = false
|
||||
|
||||
/**
|
||||
* The app that was playing, or paused with a track loaded, when the driver opened another one. Opening an app is only a look
|
||||
* The app that was playing when the driver opened another one. Opening an app is only a look
|
||||
* at its library: until something plays there, the music carries on in this one, so leaving
|
||||
* the new app's screen goes back to it, as Android Auto does.
|
||||
*/
|
||||
@@ -144,20 +138,19 @@ object CarServices {
|
||||
// --- media ---------------------------------------------------------------------------
|
||||
|
||||
fun openMedia(app: MediaApp, autoplay: Boolean = false) {
|
||||
CarSettings.setLastMedia(app.component.flattenToString())
|
||||
val phase = media.phase.value
|
||||
val live = phase == CarMedia.Phase.READY || phase == CarMedia.Phase.CONNECTING
|
||||
if (_mediaApp.value?.component == app.component && live) return
|
||||
|
||||
val previous = _mediaApp.value
|
||||
// Paused counts too: a track loaded there is still the music the driver is listening to.
|
||||
val loaded = media.now.value.let { it.playing || it.title.isNotEmpty() }
|
||||
stillPlaying = previous?.takeIf { it.component != app.component && loaded && !autoplay }
|
||||
stillPlaying = previous?.takeIf { it.component != app.component && media.now.value.playing && !autoplay }
|
||||
_mediaApp.value = app
|
||||
autoplayPending = autoplay
|
||||
media.connect(app)
|
||||
// Not torn down and rebuilt per app: the capture covers whatever the phone plays, so
|
||||
// switching media apps never costs the driver another consent dialog.
|
||||
if (CarSettings.pipesNow) CarAudio.request()
|
||||
if (CarSettings.pipeAudio.value) CarAudio.request()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -57,7 +57,6 @@ object CarSettings {
|
||||
private const val KEY_OPEN = "car_open_on_connect"
|
||||
private const val KEY_NIGHT = "car_night_mode"
|
||||
private const val KEY_PIPE_AUDIO = "car_pipe_audio"
|
||||
private const val KEY_AUDIO_CARS = "car_audio_through_gearslip_cars"
|
||||
private const val KEY_SPEAK_DIRECTIONS = "car_speak_directions"
|
||||
private const val KEY_DIRECTIONS_OVER_MUSIC = "car_directions_over_music"
|
||||
private const val KEY_BLUETOOTH_WARNING = "car_bluetooth_warning"
|
||||
@@ -68,8 +67,7 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
|
||||
private const val KEY_AUTOPLAY = "car_autoplay"
|
||||
private const val KEY_PLAYER_TITLE = "car_player_title"
|
||||
private const val KEY_PLAYER_LEFT = "car_player_left"
|
||||
private const val KEY_NAV_MIC = "car_nav_mic"
|
||||
private const val KEY_DEFAULT_HIDDEN = "car_default_hidden"
|
||||
private const val KEY_MORE_LYRICS = "car_more_lyrics"
|
||||
private const val KEY_THEME = "car_app_theme"
|
||||
private const val KEY_UI_THEME = "car_ui_theme"
|
||||
private const val KEY_DARK_STYLE = "car_dark_style"
|
||||
@@ -115,7 +113,7 @@ private const val KEY_CLUSTER_VIDEO = "cluster_video_enabled"
|
||||
private val autoplayFlow = MutableStateFlow(false)
|
||||
private val playerTitleFlow = MutableStateFlow(false)
|
||||
private val playerLeftFlow = MutableStateFlow(false)
|
||||
private val navMicFlow = MutableStateFlow(true)
|
||||
private val moreLyricsFlow = MutableStateFlow(true)
|
||||
private val themeFlow = MutableStateFlow(AppTheme.PHONE)
|
||||
val appTheme: StateFlow<AppTheme> = themeFlow
|
||||
private val darkStyleFlow = MutableStateFlow(DarkStyle.BLACK)
|
||||
@@ -245,18 +243,14 @@ private const val KEY_CLUSTER_VIDEO = "cluster_video_enabled"
|
||||
/** The home screen's player sits left of the map instead of right of it. */
|
||||
val playerLeft: StateFlow<Boolean> = playerLeftFlow
|
||||
|
||||
/** The assistant's mic button in the nav bar. Off hides only the button: the car's voice key still works. */
|
||||
val navMic: StateFlow<Boolean> = navMicFlow
|
||||
|
||||
/** Lyrics from BetterLyrics and KuGou as well as LRCLIB; off sends song details to LRCLIB only. */
|
||||
val moreLyrics: StateFlow<Boolean> = moreLyricsFlow
|
||||
|
||||
val scale: StateFlow<Float> = scaleFlow
|
||||
|
||||
/** [carDensity] when it leaves the choice to the car. */
|
||||
const val DENSITY_FROM_CAR = 0
|
||||
val DENSITIES = listOf(160, 180, 200, 220, 240)
|
||||
/** The range a typed-in density may take (issue #82): small enough for more on screen, big enough to read. */
|
||||
val CUSTOM_DENSITIES = 60..480
|
||||
|
||||
/** An absolute DPI for the car UI and its apps; the video's resolution doesn't change with it. */
|
||||
val carDensity: StateFlow<Int> = carDensityFlow
|
||||
@@ -275,41 +269,6 @@ private const val KEY_CLUSTER_VIDEO = "cluster_video_enabled"
|
||||
*/
|
||||
val pipeAudio: StateFlow<Boolean> = pipeAudioFlow
|
||||
|
||||
private val thisCarPipesFlow = MutableStateFlow(false)
|
||||
|
||||
/**
|
||||
* The car connected now switches Bluetooth music off while Android Auto runs, so its music
|
||||
* goes through Gearslip whatever [pipeAudio] says. Learned by [BluetoothAudioCheck] and
|
||||
* remembered per car: a Skoda, a VW, an Audi and a Bolt all did this.
|
||||
*/
|
||||
val thisCarPipes: StateFlow<Boolean> = thisCarPipesFlow
|
||||
|
||||
/** Whether music goes through Gearslip in this car: the driver's choice, or what the car needs. */
|
||||
val pipesNow: Boolean get() = pipeAudioFlow.value || thisCarPipesFlow.value
|
||||
|
||||
/** A car connected: whether it's one that needs music through Gearslip. Null for none known. */
|
||||
fun carConnected(carAddress: String?) {
|
||||
thisCarPipesFlow.value = carAddress != null && carKey(carAddress) in audioCars()
|
||||
}
|
||||
|
||||
fun carDisconnected() {
|
||||
thisCarPipesFlow.value = false
|
||||
}
|
||||
|
||||
/** This car turned Bluetooth music off: from now on its music goes through Gearslip. */
|
||||
fun rememberAudioCar(carAddress: String) {
|
||||
AppSettings.putString(app, KEY_AUDIO_CARS, (audioCars() + carKey(carAddress)).distinct().joinToString(","))
|
||||
thisCarPipesFlow.value = true
|
||||
}
|
||||
|
||||
private fun audioCars(): List<String> =
|
||||
AppSettings.getString(app, KEY_AUDIO_CARS, "").split(",").filter { it.isNotEmpty() }
|
||||
|
||||
/** Settings keep a short hash, not the car's address. */
|
||||
private fun carKey(address: String): String =
|
||||
java.security.MessageDigest.getInstance("SHA-256").digest(address.uppercase().toByteArray())
|
||||
.take(8).joinToString("") { "%02x".format(it) }
|
||||
|
||||
/** Gearslip reads the map app's turns aloud on the car's directions channel; see DirectionsVoice. */
|
||||
val speakDirections: StateFlow<Boolean> = speakDirectionsFlow
|
||||
|
||||
@@ -389,7 +348,7 @@ private const val KEY_CLUSTER_VIDEO = "cluster_video_enabled"
|
||||
fun init(context: Context) {
|
||||
app = context.applicationContext
|
||||
scaleFlow.value = AppSettings.getString(app, KEY_SCALE, "1.0").toFloatOrNull() ?: 1f
|
||||
carDensityFlow.value = AppSettings.getString(app, KEY_DENSITY, "").toIntOrNull()?.takeIf { it in DENSITIES || it in CUSTOM_DENSITIES } ?: DENSITY_FROM_CAR
|
||||
carDensityFlow.value = AppSettings.getString(app, KEY_DENSITY, "").toIntOrNull()?.takeIf { it in DENSITIES } ?: DENSITY_FROM_CAR
|
||||
openFlow.value = AppSettings.getString(app, KEY_OPEN, HOME)
|
||||
lastNavFlow.value = AppSettings.getString(app, KEY_LAST_NAV, "").ifEmpty { null }
|
||||
lastMediaFlow.value = AppSettings.getString(app, KEY_LAST_MEDIA, "").ifEmpty { null }
|
||||
@@ -403,7 +362,7 @@ private const val KEY_CLUSTER_VIDEO = "cluster_video_enabled"
|
||||
autoplayFlow.value = AppSettings.getString(app, KEY_AUTOPLAY, "false") == "true"
|
||||
playerTitleFlow.value = AppSettings.getString(app, KEY_PLAYER_TITLE, "false") == "true"
|
||||
playerLeftFlow.value = AppSettings.getString(app, KEY_PLAYER_LEFT, "false") == "true"
|
||||
navMicFlow.value = AppSettings.getString(app, KEY_NAV_MIC, "true") == "true"
|
||||
moreLyricsFlow.value = AppSettings.getString(app, KEY_MORE_LYRICS, "true") == "true"
|
||||
pipeAudioFlow.value = AppSettings.getString(app, KEY_PIPE_AUDIO, "true") == "true"
|
||||
// Until the driver sets it, on for USB audio and off for Bluetooth, where the map app's
|
||||
// own voice already reaches the car.
|
||||
@@ -490,16 +449,16 @@ private const val KEY_CLUSTER_VIDEO = "cluster_video_enabled"
|
||||
AppSettings.putString(app, KEY_PLAYER_TITLE, on.toString())
|
||||
}
|
||||
|
||||
fun setNavMic(on: Boolean) {
|
||||
navMicFlow.value = on
|
||||
AppSettings.putString(app, KEY_NAV_MIC, on.toString())
|
||||
}
|
||||
|
||||
fun setPlayerLeft(on: Boolean) {
|
||||
playerLeftFlow.value = on
|
||||
AppSettings.putString(app, KEY_PLAYER_LEFT, on.toString())
|
||||
}
|
||||
|
||||
fun setMoreLyrics(on: Boolean) {
|
||||
moreLyricsFlow.value = on
|
||||
AppSettings.putString(app, KEY_MORE_LYRICS, on.toString())
|
||||
}
|
||||
|
||||
fun setAutoplay(on: Boolean) {
|
||||
autoplayFlow.value = on
|
||||
AppSettings.putString(app, KEY_AUTOPLAY, on.toString())
|
||||
@@ -512,11 +471,6 @@ private const val KEY_CLUSTER_VIDEO = "cluster_video_enabled"
|
||||
fun setPipeAudio(on: Boolean) {
|
||||
// Saved even when unchanged, so picking the default still counts as a choice.
|
||||
AppSettings.putString(app, KEY_PIPE_AUDIO, on.toString())
|
||||
// Choosing Bluetooth by hand overrules what Gearslip learned about each car.
|
||||
if (!on) {
|
||||
AppSettings.putString(app, KEY_AUDIO_CARS, "")
|
||||
thisCarPipesFlow.value = false
|
||||
}
|
||||
if (pipeAudioFlow.value == on) return
|
||||
pipeAudioFlow.value = on
|
||||
if (AppSettings.getString(app, KEY_SPEAK_DIRECTIONS, "") == "") speakDirectionsFlow.value = on
|
||||
@@ -587,21 +541,6 @@ private const val KEY_CLUSTER_VIDEO = "cluster_video_enabled"
|
||||
AppSettings.putString(app, KEY_PINNED, next.joinToString(","))
|
||||
}
|
||||
|
||||
/**
|
||||
* Unticks each of [ids] the first time it's seen, and never again: a known-broken app starts
|
||||
* off the drawer, but once the driver ticks it back on it stays on.
|
||||
*/
|
||||
fun hideOnFirstSight(ids: List<String>) {
|
||||
if (!::app.isInitialized) return
|
||||
val seen = AppSettings.getString(app, KEY_DEFAULT_HIDDEN, "").split(",").filter { it.isNotEmpty() }.toSet()
|
||||
val fresh = ids.filter { it !in seen }
|
||||
if (fresh.isEmpty()) return
|
||||
AppSettings.putString(app, KEY_DEFAULT_HIDDEN, (seen + fresh).joinToString(","))
|
||||
val next = hiddenAppsFlow.value + fresh
|
||||
hiddenAppsFlow.value = next
|
||||
AppSettings.putString(app, KEY_HIDDEN_APPS, next.joinToString(","))
|
||||
}
|
||||
|
||||
fun setAppHidden(id: String, hide: Boolean) {
|
||||
val next = if (hide) hiddenAppsFlow.value + id else hiddenAppsFlow.value - id
|
||||
hiddenAppsFlow.value = next
|
||||
|
||||
@@ -32,6 +32,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -44,6 +45,9 @@ import app.seb3thehacker.gearslip.car.theme.GsColors
|
||||
import app.seb3thehacker.gearslip.car.theme.GsIconBox
|
||||
import app.seb3thehacker.gearslip.car.theme.GsThemes
|
||||
import app.seb3thehacker.gearslip.AppSettings
|
||||
import app.seb3thehacker.gearslip.ui.CertSummary
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Settings that make sense while sitting in the car. The web page URL is edited here, with the
|
||||
@@ -69,7 +73,7 @@ fun CarSettingsScreen() {
|
||||
val autoplay by CarSettings.autoplay.collectAsState()
|
||||
val playerTitle by CarSettings.playerTitle.collectAsState()
|
||||
val playerLeft by CarSettings.playerLeft.collectAsState()
|
||||
val navMic by CarSettings.navMic.collectAsState()
|
||||
val moreLyrics by CarSettings.moreLyrics.collectAsState()
|
||||
val theme by CarSettings.appTheme.collectAsState()
|
||||
val uiTheme by CarSettings.uiTheme.collectAsState()
|
||||
val darkStyle by CarSettings.darkStyle.collectAsState()
|
||||
@@ -81,9 +85,12 @@ fun CarSettingsScreen() {
|
||||
val display by CarEnvironment.display.collectAsState()
|
||||
val vehicle by CarEnvironment.vehicle.collectAsState()
|
||||
val context = LocalContext.current
|
||||
val cert by produceState<CertSummary?>(null) {
|
||||
value = withContext(Dispatchers.Default) { CertSummary.read(context) }
|
||||
}
|
||||
|
||||
// A rail of the groups down the left, and the chosen group's settings beside it: five short
|
||||
// lists instead of one long scroll. The groups and their order match Settings on the phone.
|
||||
// lists instead of one long scroll.
|
||||
var chosen by androidx.compose.runtime.saveable.rememberSaveable { androidx.compose.runtime.mutableIntStateOf(0) }
|
||||
val listState = androidx.compose.foundation.lazy.rememberLazyListState()
|
||||
androidx.compose.runtime.LaunchedEffect(chosen) { listState.scrollToItem(0) }
|
||||
@@ -97,183 +104,259 @@ fun CarSettingsScreen() {
|
||||
contentPadding = PaddingValues(horizontal = 24.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||
) {
|
||||
when (chosen) {
|
||||
0 -> {
|
||||
item { Group(stringResource(R.string.car_group_screen)) }
|
||||
item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.car_open_on_connect),
|
||||
listOf(CarSettings.HOME to stringResource(R.string.choice_open_home)) + CarApps.shown(launcher, LauncherMode.canCall(context)).map { it.id to stringResource(it.labelRes) },
|
||||
open,
|
||||
) { CarSettings.setOpenOnConnect(it) }
|
||||
}
|
||||
item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.car_size),
|
||||
listOf(1f to stringResource(R.string.choice_size_normal), 1.25f to stringResource(R.string.choice_size_large), 1.5f to stringResource(R.string.choice_size_xlarge)),
|
||||
scale,
|
||||
) { CarSettings.setScale(it) }
|
||||
}
|
||||
item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.car_app_theme),
|
||||
listOf(AppTheme.PHONE to stringResource(R.string.choice_theme_phone), AppTheme.LIGHT to stringResource(R.string.choice_theme_light), AppTheme.DARK to stringResource(R.string.choice_theme_dark)),
|
||||
theme,
|
||||
) { CarSettings.setAppTheme(it) }
|
||||
}
|
||||
if (theme != AppTheme.LIGHT) item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.car_dark_mode),
|
||||
listOf(
|
||||
DarkStyle.BLACK to stringResource(R.string.choice_dark_black),
|
||||
DarkStyle.BLACK_COLOUR to stringResource(R.string.choice_dark_black_colour),
|
||||
DarkStyle.DEEP_COLOUR to stringResource(R.string.choice_dark_deep_colour),
|
||||
DarkStyle.GREY to stringResource(R.string.choice_dark_grey),
|
||||
),
|
||||
darkStyle,
|
||||
) { CarSettings.setDarkStyle(it) }
|
||||
}
|
||||
item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.car_buttons),
|
||||
GsThemes.builtIn.map { it.id to it.name },
|
||||
uiTheme,
|
||||
) { CarSettings.setUiTheme(it) }
|
||||
}
|
||||
item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.car_map_day_night),
|
||||
listOf(NightMode.AUTO to stringResource(R.string.choice_auto), NightMode.DAY to stringResource(R.string.choice_night_day), NightMode.NIGHT to stringResource(R.string.choice_night_night)),
|
||||
night,
|
||||
) { CarSettings.setNightMode(it) }
|
||||
}
|
||||
item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.car_units),
|
||||
listOf(Units.AUTO to stringResource(R.string.choice_auto), Units.IMPERIAL to stringResource(R.string.carset_imperial), Units.METRIC to stringResource(R.string.carset_metric)),
|
||||
units,
|
||||
) { CarSettings.setUnits(it) }
|
||||
}
|
||||
// Fixes for a head unit that gets its own screen wrong (issue #12), together
|
||||
// and last, since most cars never need them.
|
||||
item { SubGroup(stringResource(R.string.carset_advanced)) }
|
||||
item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.carset_screen_size),
|
||||
listOf<ScreenSize?>(null).plus(SCREEN_PRESETS).plus(screenSize).distinct()
|
||||
.map { it to (it?.toString() ?: stringResource(R.string.screen_from_car)) },
|
||||
screenSize,
|
||||
) { CarSettings.setScreenSize(it) }
|
||||
}
|
||||
if (screenSize != null) item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.carset_fit),
|
||||
listOf(ScreenFit.CROP to stringResource(R.string.screen_crop), ScreenFit.SQUASH to stringResource(R.string.screen_squash)),
|
||||
screenFit,
|
||||
) { CarSettings.setScreenFit(it) }
|
||||
}
|
||||
item { ScreenFit(vehicle) }
|
||||
var section: Int
|
||||
section = 0
|
||||
if (section == chosen) item { Group(stringResource(R.string.car_group_screen)) }
|
||||
|
||||
if (section == chosen) item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.car_open_on_connect),
|
||||
listOf(CarSettings.HOME to stringResource(R.string.choice_open_home)) + CarApps.shown(launcher, LauncherMode.canCall(context)).map { it.id to stringResource(it.labelRes) },
|
||||
open,
|
||||
) { CarSettings.setOpenOnConnect(it) }
|
||||
}
|
||||
|
||||
if (section == chosen) item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.car_size),
|
||||
listOf(1f to stringResource(R.string.choice_size_normal), 1.25f to stringResource(R.string.choice_size_large), 1.5f to stringResource(R.string.choice_size_xlarge)),
|
||||
scale,
|
||||
) { CarSettings.setScale(it) }
|
||||
}
|
||||
|
||||
if (section == chosen) item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.car_app_theme),
|
||||
listOf(AppTheme.PHONE to stringResource(R.string.choice_theme_phone), AppTheme.LIGHT to stringResource(R.string.choice_theme_light), AppTheme.DARK to stringResource(R.string.choice_theme_dark)),
|
||||
theme,
|
||||
) { CarSettings.setAppTheme(it) }
|
||||
}
|
||||
|
||||
if (section == chosen && theme != AppTheme.LIGHT) item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.car_dark_mode),
|
||||
listOf(
|
||||
DarkStyle.BLACK to stringResource(R.string.choice_dark_black),
|
||||
DarkStyle.BLACK_COLOUR to stringResource(R.string.choice_dark_black_colour),
|
||||
DarkStyle.DEEP_COLOUR to stringResource(R.string.choice_dark_deep_colour),
|
||||
DarkStyle.GREY to stringResource(R.string.choice_dark_grey),
|
||||
),
|
||||
darkStyle,
|
||||
) { CarSettings.setDarkStyle(it) }
|
||||
}
|
||||
|
||||
if (section == chosen) item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.carset_theme),
|
||||
GsThemes.builtIn.map { it.id to it.name },
|
||||
uiTheme,
|
||||
) { CarSettings.setUiTheme(it) }
|
||||
}
|
||||
|
||||
if (section == chosen) item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.car_map_day_night),
|
||||
listOf(NightMode.AUTO to stringResource(R.string.choice_auto), NightMode.DAY to stringResource(R.string.choice_night_day), NightMode.NIGHT to stringResource(R.string.choice_night_night)),
|
||||
night,
|
||||
) { CarSettings.setNightMode(it) }
|
||||
}
|
||||
|
||||
if (section == chosen) item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.car_units),
|
||||
listOf(Units.AUTO to stringResource(R.string.choice_auto), Units.IMPERIAL to stringResource(R.string.carset_imperial), Units.METRIC to stringResource(R.string.carset_metric)),
|
||||
units,
|
||||
) { CarSettings.setUnits(it) }
|
||||
}
|
||||
|
||||
// For a head unit that reports the wrong size and stretches the picture (issue #12).
|
||||
if (section == chosen) item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.carset_screen_size),
|
||||
listOf<ScreenSize?>(null).plus(SCREEN_PRESETS).plus(screenSize).distinct()
|
||||
.map { it to (it?.toString() ?: stringResource(R.string.screen_from_car)) },
|
||||
screenSize,
|
||||
) { CarSettings.setScreenSize(it) }
|
||||
}
|
||||
|
||||
if (section == chosen && screenSize != null) item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.carset_fit),
|
||||
listOf(ScreenFit.CROP to stringResource(R.string.screen_crop), ScreenFit.SQUASH to stringResource(R.string.screen_squash)),
|
||||
screenFit,
|
||||
) { CarSettings.setScreenFit(it) }
|
||||
}
|
||||
|
||||
section = 1
|
||||
if (section == chosen) item { Group(stringResource(R.string.car_group_nav_bar)) }
|
||||
|
||||
if (section == chosen) item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.car_nav_bar),
|
||||
listOf(
|
||||
NavBarMode.SHOW to stringResource(R.string.choice_nav_show),
|
||||
NavBarMode.HIDE_NAVIGATING to stringResource(R.string.choice_nav_hide_navigating),
|
||||
NavBarMode.HIDE to stringResource(R.string.choice_nav_hide),
|
||||
),
|
||||
navBar,
|
||||
) { CarSettings.setNavBarMode(it) }
|
||||
}
|
||||
|
||||
if (section == chosen) item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.car_player_nav_bar),
|
||||
listOf(false to stringResource(R.string.car_skip_buttons), true to stringResource(R.string.car_song_title)),
|
||||
playerTitle,
|
||||
) { CarSettings.setPlayerTitle(it) }
|
||||
}
|
||||
|
||||
if (section == chosen) item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.car_player_beside_map),
|
||||
listOf(false to stringResource(R.string.car_right), true to stringResource(R.string.car_left)),
|
||||
playerLeft,
|
||||
) { CarSettings.setPlayerLeft(it) }
|
||||
}
|
||||
|
||||
if (section == chosen) item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.car_autoplay),
|
||||
listOf(false to stringResource(R.string.off), true to stringResource(R.string.on)),
|
||||
autoplay,
|
||||
) { CarSettings.setAutoplay(it) }
|
||||
}
|
||||
|
||||
if (section == chosen) item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.carset_lyrics_from),
|
||||
listOf(false to "LRCLIB", true to stringResource(R.string.carset_more_sources)),
|
||||
moreLyrics,
|
||||
) { CarSettings.setMoreLyrics(it) }
|
||||
}
|
||||
|
||||
section = 2
|
||||
if (section == chosen) item { Group(stringResource(R.string.car_group_sound)) }
|
||||
|
||||
if (section == chosen) item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.car_audio),
|
||||
listOf(false to stringResource(R.string.guide_audio_bluetooth), true to stringResource(R.string.guide_audio_usb)),
|
||||
pipeAudio,
|
||||
) { CarSettings.setPipeAudio(it) }
|
||||
}
|
||||
|
||||
if (section == chosen) item {
|
||||
Text(
|
||||
stringResource(R.string.carset_audio_note),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
section = 3
|
||||
if (section == chosen) item { Group(stringResource(R.string.car_group_warnings)) }
|
||||
|
||||
if (section == chosen && !pipeAudio) item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.car_warn_bluetooth),
|
||||
listOf(false to stringResource(R.string.off), true to stringResource(R.string.on)),
|
||||
bluetoothWarning,
|
||||
) { CarSettings.setBluetoothWarning(it) }
|
||||
}
|
||||
|
||||
if (section == chosen) item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.car_warn_location),
|
||||
listOf(false to stringResource(R.string.off), true to stringResource(R.string.on)),
|
||||
locationWarning,
|
||||
) { CarSettings.setLocationWarning(it) }
|
||||
}
|
||||
|
||||
if (section == chosen) item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.car_spoken_directions),
|
||||
listOf(false to stringResource(R.string.off), true to stringResource(R.string.on)),
|
||||
speakDirections,
|
||||
) { CarSettings.setSpeakDirections(it) }
|
||||
}
|
||||
|
||||
if (section == chosen) item {
|
||||
Text(
|
||||
stringResource(R.string.carset_directions_note),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
if (section == chosen && speakDirections) item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.car_directions_over_music),
|
||||
listOf(false to stringResource(R.string.off), true to stringResource(R.string.on)),
|
||||
directionsOverMusic,
|
||||
) { CarSettings.setDirectionsOverMusic(it) }
|
||||
}
|
||||
|
||||
if (section == chosen && speakDirections) item {
|
||||
Text(
|
||||
stringResource(R.string.car_directions_over_music_note),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
if (section == chosen) item {
|
||||
ExperimentalFeatureRow(
|
||||
title = stringResource(R.string.car_phone_speech),
|
||||
description = stringResource(R.string.carset_phone_speech_note),
|
||||
checked = systemSpeech,
|
||||
) { CarSettings.setSystemSpeech(it) }
|
||||
}
|
||||
|
||||
section = 4
|
||||
if (section == chosen) item { Group(stringResource(R.string.car_group_more)) }
|
||||
|
||||
if (section == chosen) item {
|
||||
WebPageSetting()
|
||||
}
|
||||
|
||||
if (section == chosen) item {
|
||||
ScreenFit(vehicle)
|
||||
}
|
||||
|
||||
if (section == chosen) item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.car_experimental),
|
||||
listOf(false to stringResource(R.string.carset_hide), true to stringResource(R.string.carset_show)),
|
||||
experimentalFeatures,
|
||||
) { CarSettings.setExperimentalFeaturesEnabled(it) }
|
||||
}
|
||||
|
||||
if (section == chosen && experimentalFeatures) item {
|
||||
ExperimentalFeaturesList(carSensors, adaptiveVideo, clusterVideo)
|
||||
}
|
||||
|
||||
if (section == chosen) item {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(stringResource(R.string.settings_connection), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
|
||||
Info(stringResource(R.string.carset_phone), "${Build.MANUFACTURER} ${Build.MODEL}")
|
||||
Info(stringResource(R.string.carset_vehicle), vehicle.ifEmpty { "-" })
|
||||
Info(stringResource(R.string.carset_video), display.ifEmpty { "-" })
|
||||
Info(stringResource(R.string.settings_certificate), cert?.headline ?: stringResource(R.string.cert_checking))
|
||||
}
|
||||
1 -> {
|
||||
item { Group(stringResource(R.string.car_group_nav_bar)) }
|
||||
item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.car_nav_bar),
|
||||
listOf(
|
||||
NavBarMode.SHOW to stringResource(R.string.choice_nav_show),
|
||||
NavBarMode.HIDE_NAVIGATING to stringResource(R.string.choice_nav_hide_navigating),
|
||||
NavBarMode.HIDE to stringResource(R.string.choice_nav_hide),
|
||||
),
|
||||
navBar,
|
||||
) { CarSettings.setNavBarMode(it) }
|
||||
}
|
||||
item {
|
||||
SwitchRow(stringResource(R.string.car_nav_mic), stringResource(R.string.car_nav_mic_note), navMic) { CarSettings.setNavMic(it) }
|
||||
}
|
||||
item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.car_player_nav_bar),
|
||||
listOf(false to stringResource(R.string.car_skip_buttons), true to stringResource(R.string.car_song_title)),
|
||||
playerTitle,
|
||||
) { CarSettings.setPlayerTitle(it) }
|
||||
}
|
||||
item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.car_player_beside_map),
|
||||
listOf(false to stringResource(R.string.car_right), true to stringResource(R.string.car_left)),
|
||||
playerLeft,
|
||||
) { CarSettings.setPlayerLeft(it) }
|
||||
}
|
||||
item { Note(stringResource(R.string.car_player_beside_map_note)) }
|
||||
item {
|
||||
SwitchRow(stringResource(R.string.car_autoplay), stringResource(R.string.car_autoplay_note), autoplay) { CarSettings.setAutoplay(it) }
|
||||
}
|
||||
}
|
||||
2 -> {
|
||||
item { Group(stringResource(R.string.car_group_sound)) }
|
||||
item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.car_audio),
|
||||
listOf(false to stringResource(R.string.guide_audio_bluetooth), true to stringResource(R.string.guide_audio_usb)),
|
||||
pipeAudio,
|
||||
) { CarSettings.setPipeAudio(it) }
|
||||
}
|
||||
item { Note(stringResource(R.string.carset_audio_note)) }
|
||||
item {
|
||||
SwitchRow(stringResource(R.string.car_spoken_directions), stringResource(R.string.carset_directions_note), speakDirections) { CarSettings.setSpeakDirections(it) }
|
||||
}
|
||||
if (speakDirections) item {
|
||||
SwitchRow(stringResource(R.string.car_directions_over_music), stringResource(R.string.car_directions_over_music_note), directionsOverMusic) { CarSettings.setDirectionsOverMusic(it) }
|
||||
}
|
||||
item {
|
||||
SwitchRow(stringResource(R.string.car_phone_speech), stringResource(R.string.carset_phone_speech_note), systemSpeech) { CarSettings.setSystemSpeech(it) }
|
||||
}
|
||||
}
|
||||
3 -> {
|
||||
item { Group(stringResource(R.string.car_group_warnings)) }
|
||||
if (!pipeAudio) item {
|
||||
SwitchRow(stringResource(R.string.car_warn_bluetooth), null, bluetoothWarning) { CarSettings.setBluetoothWarning(it) }
|
||||
}
|
||||
item {
|
||||
SwitchRow(stringResource(R.string.car_warn_location), null, locationWarning) { CarSettings.setLocationWarning(it) }
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
item { Group(stringResource(R.string.car_group_more)) }
|
||||
item { WebPageSetting() }
|
||||
item {
|
||||
SwitchRow(stringResource(R.string.car_experimental), stringResource(R.string.car_experimental_note), experimentalFeatures) { CarSettings.setExperimentalFeaturesEnabled(it) }
|
||||
}
|
||||
if (experimentalFeatures) item {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
SubGroup(stringResource(R.string.carset_experimental_heading))
|
||||
ExperimentalFeaturesList(carSensors, adaptiveVideo, clusterVideo)
|
||||
}
|
||||
}
|
||||
item {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
SubGroup(stringResource(R.string.settings_connection))
|
||||
// The certificate is managed on the phone, which shows it in full.
|
||||
val none = stringResource(R.string.status_idle)
|
||||
Info(stringResource(R.string.carset_phone), "${Build.MANUFACTURER} ${Build.MODEL}")
|
||||
Info(stringResource(R.string.carset_vehicle), vehicle.ifEmpty { none })
|
||||
Info(stringResource(R.string.carset_video), display.ifEmpty { none })
|
||||
}
|
||||
}
|
||||
item {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
SubGroup(stringResource(R.string.settings_about))
|
||||
Info(stringResource(R.string.carset_version), app.seb3thehacker.gearslip.BuildConfig.VERSION_NAME)
|
||||
Info(stringResource(R.string.carset_build), app.seb3thehacker.gearslip.BuildConfig.VERSION_CODE.toString())
|
||||
SwitchRow(
|
||||
stringResource(R.string.carset_whats_new_after),
|
||||
stringResource(R.string.carset_whats_new_after_sub),
|
||||
whatsNew,
|
||||
) { CarSettings.setWhatsNewAfterUpdates(it) }
|
||||
GsButton(onClick = CarSettings::openWhatsNew, tone = GsTone.Tonal, modifier = Modifier.height(56.dp)) {
|
||||
Text(stringResource(R.string.settings_whats_new), style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (section == chosen) item {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(stringResource(R.string.settings_about), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
|
||||
Info(stringResource(R.string.carset_version), app.seb3thehacker.gearslip.BuildConfig.VERSION_NAME)
|
||||
Info(stringResource(R.string.carset_build), app.seb3thehacker.gearslip.BuildConfig.VERSION_CODE.toString())
|
||||
ExperimentalFeatureRow(
|
||||
stringResource(R.string.carset_whats_new_after),
|
||||
stringResource(R.string.carset_whats_new_after_sub),
|
||||
whatsNew,
|
||||
) { CarSettings.setWhatsNewAfterUpdates(it) }
|
||||
GsButton(onClick = CarSettings::openWhatsNew, tone = GsTone.Tonal, modifier = Modifier.height(56.dp)) {
|
||||
Text(stringResource(R.string.settings_whats_new), style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -281,18 +364,6 @@ fun CarSettingsScreen() {
|
||||
}
|
||||
}
|
||||
|
||||
/** A heading inside a group, for a few settings that belong together. */
|
||||
@Composable
|
||||
private fun SubGroup(title: String) {
|
||||
Text(title, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
|
||||
/** What a setting above it does, in a sentence or two. */
|
||||
@Composable
|
||||
private fun Note(text: String) {
|
||||
Text(text, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun <T> ChoiceRow(title: String, options: List<Pair<T, String>>, selected: T, onSelect: (T) -> Unit) {
|
||||
// On a wide screen the name sits beside its choices, which halves how far there is to scroll.
|
||||
@@ -380,16 +451,7 @@ private fun <T> ChoiceButtons(options: List<Pair<T, String>>, selected: T, onSel
|
||||
else GsColors(scheme.surfaceContainerHigh, scheme.onSurface),
|
||||
latched = on,
|
||||
) {
|
||||
// Centred however many lines it takes: "Black and colour" wrapped to the left.
|
||||
Text(
|
||||
label,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = if (on) FontWeight.Bold else FontWeight.Normal,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(horizontal = 8.dp),
|
||||
)
|
||||
Text(label, style = MaterialTheme.typography.titleMedium, fontWeight = if (on) FontWeight.Bold else FontWeight.Normal)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -407,17 +469,17 @@ private fun ExperimentalFeaturesList(carSensors: Boolean, adaptiveVideo: Boolean
|
||||
Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
SwitchRow(
|
||||
ExperimentalFeatureRow(
|
||||
title = stringResource(R.string.car_vehicle_data),
|
||||
description = stringResource(R.string.carset_vehicle_data_note),
|
||||
checked = carSensors,
|
||||
) { CarSettings.setCarSensorsEnabled(it) }
|
||||
SwitchRow(
|
||||
ExperimentalFeatureRow(
|
||||
title = stringResource(R.string.car_adaptive_video),
|
||||
description = stringResource(R.string.carset_adaptive_note),
|
||||
checked = adaptiveVideo,
|
||||
) { CarSettings.setAdaptiveVideoEnabled(it) }
|
||||
SwitchRow(
|
||||
ExperimentalFeatureRow(
|
||||
title = stringResource(R.string.car_cluster_video),
|
||||
description = stringResource(R.string.car_cluster_video_note),
|
||||
checked = clusterVideo,
|
||||
@@ -426,9 +488,9 @@ private fun ExperimentalFeaturesList(carSensors: Boolean, adaptiveVideo: Boolean
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SwitchRow(
|
||||
private fun ExperimentalFeatureRow(
|
||||
title: String,
|
||||
description: String?,
|
||||
description: String,
|
||||
checked: Boolean,
|
||||
warning: Boolean = false,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
@@ -446,7 +508,7 @@ private fun SwitchRow(
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(title, style = MaterialTheme.typography.titleMedium)
|
||||
if (description != null) Text(
|
||||
Text(
|
||||
description,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (warning) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
@@ -527,7 +589,7 @@ private fun ScreenFit(vehicle: String) {
|
||||
}
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(stringResource(R.string.carset_screen_fit), style = MaterialTheme.typography.titleMedium)
|
||||
Text(stringResource(R.string.carset_screen_fit), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
|
||||
Stepper(stringResource(R.string.carset_top), insets.top) { change(insets.copy(top = (insets.top + it).coerceAtLeast(0))) }
|
||||
Stepper(stringResource(R.string.carset_bottom), insets.bottom) { change(insets.copy(bottom = (insets.bottom + it).coerceAtLeast(0))) }
|
||||
Text(
|
||||
@@ -535,15 +597,14 @@ private fun ScreenFit(vehicle: String) {
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
// With no car to name, a greyed-out button only said what couldn't be done.
|
||||
if (known) GsButton(
|
||||
GsButton(
|
||||
onClick = {
|
||||
val ok = app.seb3thehacker.gearslip.VehicleProfiles.saveInsets(context, vehicle, insets)
|
||||
saved = if (ok) context.getString(R.string.carset_saved_to, vehicle) else context.getString(R.string.carset_save_failed)
|
||||
},
|
||||
enabled = known,
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
) { Text(stringResource(R.string.carset_save_vehicle)) }
|
||||
else Note(stringResource(R.string.carset_fit_needs_car))
|
||||
) { Text(if (known) stringResource(R.string.carset_save_vehicle) else stringResource(R.string.carset_no_profile)) }
|
||||
saved?.let { Text(it, style = MaterialTheme.typography.bodyMedium) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,7 +146,7 @@ fun CarUi() {
|
||||
// Ask for the phone's sound as soon as the car connects, not only when a media app opens:
|
||||
// a video in the web browser or a map's voice needs it too. Asks once, after the car offers
|
||||
// somewhere to play it.
|
||||
LaunchedEffect(Unit) { if (CarSettings.pipesNow) app.seb3thehacker.gearslip.audio.CarAudio.request() }
|
||||
LaunchedEffect(Unit) { if (CarSettings.pipeAudio.value) app.seb3thehacker.gearslip.audio.CarAudio.request() }
|
||||
// A new version gets one card in the top right, a few seconds in and only while parked.
|
||||
LaunchedEffect(Unit) { app.seb3thehacker.gearslip.update.UpdateChecker.onCarConnected(appContext) }
|
||||
// The map follows the light outside, whatever the app's own theme is set to.
|
||||
|
||||
@@ -1,21 +1,11 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.view.SurfaceHolder
|
||||
import android.view.SurfaceView
|
||||
import androidx.car.app.CarAppService
|
||||
import androidx.car.app.SessionInfo
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
@@ -26,30 +16,19 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import app.seb3thehacker.gearslip.Insets
|
||||
import app.seb3thehacker.gearslip.host.CarAppConnection
|
||||
import app.seb3thehacker.gearslip.host.TemplateApp
|
||||
import app.seb3thehacker.gearslip.host.clockTime
|
||||
import app.seb3thehacker.gearslip.host.display
|
||||
import app.seb3thehacker.gearslip.host.remainingTime
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* The instrument-cluster screen: the next turn, big enough to read in a glance past the wheel.
|
||||
@@ -57,25 +36,10 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
* along the bottom. With no route it shows what's playing, quietly, or nothing.
|
||||
*
|
||||
* Always dark, like the dials around it. [marginX] and [marginY] are the pixels the car crops off
|
||||
* the frame, kept clear. Many cars also draw their own dials and bars over what's left, so the
|
||||
* card keeps to [clearArea] when the car says where that is, and otherwise to the middle of a
|
||||
* wide strip: on an Audi's virtual cockpit it ran under the rev counter and the bottom bar (#56).
|
||||
*
|
||||
* When the main screen's navigation app has a session for clusters, it draws its map to the
|
||||
* right of the card, on a surface Gearslip lends that session as it does the main one.
|
||||
* the frame, kept clear.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ClusterScreen(marginX: Int, marginY: Int, densityDpi: Int, clearArea: StateFlow<Insets?>) {
|
||||
val carInsets by clearArea.collectAsState()
|
||||
val context = LocalContext.current
|
||||
// Which app draws the map: whatever navigation app the main screen runs, if it can.
|
||||
val navApp by produceState<ComponentName?>(null) {
|
||||
while (true) {
|
||||
value = CarServices.navOrNull()?.connectedComponent
|
||||
delay(2_000)
|
||||
}
|
||||
}
|
||||
val mapApp = navApp?.let { app -> remember(app) { app.takeIf { drawsOnCluster(context, it) } } }
|
||||
internal fun ClusterScreen(marginX: Int, marginY: Int) {
|
||||
// The map app's turn changes a few times a minute: read it once a second.
|
||||
val turn by produceState<ClusterTurn?>(null) {
|
||||
while (true) {
|
||||
@@ -88,171 +52,52 @@ internal fun ClusterScreen(marginX: Int, marginY: Int, densityDpi: Int, clearAre
|
||||
CompositionLocalProvider(LocalContentColor provides Color.White) {
|
||||
BoxWithConstraints(
|
||||
Modifier.fillMaxSize().background(Color.Black).padding(
|
||||
horizontal = with(density) { (marginX / 2).toDp() },
|
||||
vertical = with(density) { (marginY / 2).toDp() },
|
||||
horizontal = with(density) { (marginX / 2).toDp() } + 24.dp,
|
||||
vertical = with(density) { (marginY / 2).toDp() } + 16.dp,
|
||||
),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
val clear = clearPadding(maxWidth, maxHeight, carInsets?.let { with(density) { toDp(it) } })
|
||||
BoxWithConstraints(
|
||||
Modifier.fillMaxSize().padding(start = clear[0], top = clear[1], end = clear[2], bottom = clear[3]),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
val t = turn
|
||||
val map = mapApp?.let { clusterMap(it, densityDpi, constraints.maxWidth, constraints.maxHeight) }
|
||||
if (map != null && mapShowing(map)) {
|
||||
Row(Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) {
|
||||
if (t != null) {
|
||||
BoxWithConstraints(Modifier.weight(0.42f).fillMaxHeight(), contentAlignment = Alignment.CenterStart) {
|
||||
// Narrower than alone: the arrow and distance alone are about 12 units wide.
|
||||
TurnCard(t, minOf(maxHeight / t.rows, maxWidth / 12))
|
||||
}
|
||||
Spacer(Modifier.width(12.dp))
|
||||
}
|
||||
MapSurface(map, densityDpi, Modifier.weight(0.58f).fillMaxHeight())
|
||||
}
|
||||
return@BoxWithConstraints
|
||||
// Sized off the height: clusters run from short strips to near squares.
|
||||
val unit = maxHeight / 12
|
||||
val t = turn
|
||||
if (t == null) {
|
||||
NowPlayingLine(Modifier.align(Alignment.Center))
|
||||
return@BoxWithConstraints
|
||||
}
|
||||
Column(verticalArrangement = Arrangement.spacedBy(unit * 0.4f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
CarGlyph(t.icon, Modifier.size(unit * 3.4f))
|
||||
Spacer(Modifier.width(unit))
|
||||
Text(
|
||||
t.distance,
|
||||
fontSize = with(density) { (unit * 2.2f).toSp() },
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
if (t == null) {
|
||||
NowPlayingLine(Modifier.align(Alignment.Center))
|
||||
return@BoxWithConstraints
|
||||
if (t.what.isNotEmpty()) {
|
||||
Text(
|
||||
t.what,
|
||||
fontSize = with(density) { (unit * 1.1f).toSp() },
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
if (t.trip.isNotEmpty()) {
|
||||
Text(
|
||||
t.trip,
|
||||
fontSize = with(density) { (unit * 0.85f).toSp() },
|
||||
color = Color.White.copy(alpha = 0.7f),
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
// The card is t.rows units tall and about 14 wide: fit both ways.
|
||||
TurnCard(t, minOf(maxHeight / t.rows, maxWidth / 14))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The navigation app's cluster session, open while this is on screen. Sized for the clear area
|
||||
* it shares with the card, so the app lays its map out for a cluster-shaped screen.
|
||||
*/
|
||||
@Composable
|
||||
private fun clusterMap(app: ComponentName, densityDpi: Int, width: Int, height: Int): CarAppConnection {
|
||||
val context = LocalContext.current.applicationContext
|
||||
val connection = remember(app) {
|
||||
CarAppConnection(context, SessionInfo(SessionInfo.DISPLAY_TYPE_CLUSTER, CLUSTER_SESSION))
|
||||
}
|
||||
DisposableEffect(connection) {
|
||||
connection.connect(TemplateApp(app, app.packageName, emptyList()), (width * 0.58f).toInt(), height, densityDpi)
|
||||
onDispose { connection.disconnect() }
|
||||
}
|
||||
return connection
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the map's place is kept: while the session comes up, and once running only if the app
|
||||
* asked for a surface. An app whose cluster session draws nothing leaves the card on its own.
|
||||
*/
|
||||
@Composable
|
||||
private fun mapShowing(map: CarAppConnection): Boolean {
|
||||
val status by map.status.collectAsState()
|
||||
return when (status.phase) {
|
||||
CarAppConnection.Phase.BINDING, CarAppConnection.Phase.HANDSHAKE -> true
|
||||
CarAppConnection.Phase.RUNNING -> status.surfaceRequested || status.template == null
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
/** A surface lent to the cluster session; a fresh one for every size, as on the main screen. */
|
||||
@Composable
|
||||
private fun MapSurface(map: CarAppConnection, densityDpi: Int, modifier: Modifier) {
|
||||
BoxWithConstraints(modifier) {
|
||||
key(constraints.maxWidth, constraints.maxHeight) {
|
||||
AndroidView(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
factory = { context ->
|
||||
SurfaceView(context).apply {
|
||||
holder.addCallback(object : SurfaceHolder.Callback {
|
||||
override fun surfaceCreated(holder: SurfaceHolder) = Unit
|
||||
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) =
|
||||
map.attachSurface(holder.surface, width, height, densityDpi)
|
||||
override fun surfaceDestroyed(holder: SurfaceHolder) = map.detachSurface(holder.surface)
|
||||
})
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
DisposableEffect(map) { onDispose { map.detachSurface() } }
|
||||
}
|
||||
|
||||
/** Whether [component] says, in its manifest, that it has a session for instrument clusters. */
|
||||
private fun drawsOnCluster(context: Context, component: ComponentName): Boolean = runCatching {
|
||||
val intent = Intent(CarAppService.SERVICE_INTERFACE).addCategory(FEATURE_CLUSTER).setPackage(component.packageName)
|
||||
context.packageManager.queryIntentServices(intent, 0).any { it.serviceInfo.name == component.className }
|
||||
}.getOrDefault(false)
|
||||
|
||||
private const val FEATURE_CLUSTER = "androidx.car.app.category.FEATURE_CLUSTER"
|
||||
private const val CLUSTER_SESSION = "gearslip-cluster"
|
||||
|
||||
/** The lane to be in, on the cockpit: green, like the main screen's header, against the black. */
|
||||
private val LANE_FILL = Color(0xFF2F7D6D)
|
||||
|
||||
/**
|
||||
* Start, top, end and bottom padding around the card inside the visible picture. The car's own
|
||||
* insets win when they leave a usable area; a wide strip, where dials sit at both ends and a bar
|
||||
* along the bottom, keeps to the upper middle; anything squarer just keeps off the edges.
|
||||
*/
|
||||
private fun clearPadding(width: Dp, height: Dp, car: List<Dp>?): List<Dp> {
|
||||
if (car != null && width - car[0] - car[2] >= width * 0.3f && height - car[1] - car[3] >= height * 0.3f) return car
|
||||
if (width >= height * 2) return listOf(width * 0.22f, height * 0.08f, width * 0.22f, height * 0.33f)
|
||||
return listOf(24.dp, 16.dp, 24.dp, 16.dp)
|
||||
}
|
||||
|
||||
/** Insets in frame pixels as start, top, end and bottom padding. */
|
||||
private fun androidx.compose.ui.unit.Density.toDp(insets: Insets): List<Dp> =
|
||||
listOf(insets.left.toDp(), insets.top.toDp(), insets.right.toDp(), insets.bottom.toDp())
|
||||
|
||||
@Composable
|
||||
private fun TurnCard(t: ClusterTurn, unit: Dp) {
|
||||
val density = LocalDensity.current
|
||||
Column(verticalArrangement = Arrangement.spacedBy(unit * 0.4f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
CarGlyph(t.icon, Modifier.size(unit * 3.4f))
|
||||
Spacer(Modifier.width(unit))
|
||||
Text(
|
||||
t.distance,
|
||||
fontSize = with(density) { (unit * 2.2f).toSp() },
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
if (t.what.isNotEmpty()) {
|
||||
Text(
|
||||
t.what,
|
||||
fontSize = with(density) { (unit * 1.1f).toSp() },
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
if (t.lanes.isNotEmpty()) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
LaneRoad(t.lanes, Modifier.width(unit * 10).height(unit * 2.4f), LANE_FILL, Color.White, Color.Black)
|
||||
laneHint(t.lanes)?.let {
|
||||
Text(
|
||||
it,
|
||||
Modifier.padding(top = unit * 0.3f),
|
||||
fontSize = with(density) { (unit * 0.8f).toSp() },
|
||||
color = Color.White.copy(alpha = 0.85f),
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (t.trip.isNotEmpty()) {
|
||||
Text(
|
||||
t.trip,
|
||||
fontSize = with(density) { (unit * 0.85f).toSp() },
|
||||
color = Color.White.copy(alpha = 0.7f),
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NowPlayingLine(modifier: Modifier) {
|
||||
val now = CarServices.mediaOrNull()?.now?.value
|
||||
@@ -273,11 +118,7 @@ private class ClusterTurn(
|
||||
val distance: String,
|
||||
val what: String,
|
||||
val trip: String,
|
||||
val lanes: List<androidx.car.app.navigation.model.Lane>,
|
||||
) {
|
||||
/** How many units tall the card is: two lines of directions, and the lanes when there are some. */
|
||||
val rows: Float get() = if (lanes.isEmpty()) 8.5f else 12f
|
||||
|
||||
companion object {
|
||||
fun now(): ClusterTurn? {
|
||||
val turn = NavTurn.current() ?: return null
|
||||
@@ -293,7 +134,7 @@ private class ClusterTurn(
|
||||
e.arrivalTimeAtDestination.clockTime(),
|
||||
).filter { it.isNotEmpty() }.joinToString(" · ")
|
||||
}.orEmpty()
|
||||
return ClusterTurn(step.maneuver?.icon, turn.distance.display(), what, trip, step.lanes.orEmpty())
|
||||
return ClusterTurn(step.maneuver?.icon, turn.distance.display(), what, trip)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import android.graphics.Color as AndroidColor
|
||||
import androidx.car.app.model.Place
|
||||
import androidx.car.app.model.PlaceListMapTemplate
|
||||
import androidx.car.app.model.Row
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import app.seb3thehacker.gearslip.GearslipLog
|
||||
import org.maplibre.android.MapLibre
|
||||
import org.maplibre.android.camera.CameraUpdateFactory
|
||||
import org.maplibre.android.geometry.LatLng
|
||||
import org.maplibre.android.geometry.LatLngBounds
|
||||
import org.maplibre.android.maps.MapLibreMap
|
||||
import org.maplibre.android.maps.MapLibreMapOptions
|
||||
import org.maplibre.android.maps.MapView
|
||||
import org.maplibre.android.maps.Style
|
||||
import org.maplibre.android.style.expressions.Expression
|
||||
import org.maplibre.android.style.layers.CircleLayer
|
||||
import org.maplibre.android.style.layers.PropertyFactory
|
||||
import org.maplibre.android.style.layers.SymbolLayer
|
||||
import org.maplibre.android.style.sources.GeoJsonSource
|
||||
import org.maplibre.geojson.Feature
|
||||
import org.maplibre.geojson.FeatureCollection
|
||||
import org.maplibre.geojson.Point
|
||||
|
||||
/**
|
||||
* The map behind a [PlaceListMapTemplate]. For this template the host draws the map, not the
|
||||
* app: Android Auto puts Google's map there, with a numbered pin for each place in the list and
|
||||
* the anchor marked. RF Sentinel lists its nearby places this way, and with nothing drawn the
|
||||
* area stayed empty.
|
||||
*
|
||||
* Tiles come from OpenFreeMap: free, no key, and no account, so nothing ties a drive to anyone.
|
||||
* The camera fits every pin into the part of the map the list card doesn't cover.
|
||||
*/
|
||||
@Composable
|
||||
internal fun HostPlaceMap(template: PlaceListMapTemplate, modifier: Modifier = Modifier) {
|
||||
val dark = MaterialTheme.colorScheme.background.luminance() < 0.5f
|
||||
val pin = MaterialTheme.colorScheme.primary.toArgb()
|
||||
val pinText = MaterialTheme.colorScheme.onPrimary.toArgb()
|
||||
val anchorColor = MaterialTheme.colorScheme.tertiary.toArgb()
|
||||
val cardsAtEnd = LocalCardsAtEnd.current
|
||||
val places = remember(template) { placesOf(template) }
|
||||
var map by remember { mutableStateOf<MapLibreMap?>(null) }
|
||||
var style by remember { mutableStateOf<Style?>(null) }
|
||||
|
||||
BoxWithConstraints(modifier) {
|
||||
val density = LocalDensity.current
|
||||
// Keep the pins out from under the list card, the way the app's own map would.
|
||||
val card = with(density) { cardColumnWidth(maxWidth).roundToPx() }
|
||||
val margin = with(density) { 48.dp.roundToPx() }
|
||||
|
||||
AndroidView(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
factory = { context ->
|
||||
MapLibre.getInstance(context)
|
||||
// A TextureView, so the map composites with the cards drawn over it inside the
|
||||
// car's virtual display, as any other view does.
|
||||
val options = MapLibreMapOptions.createFromAttributes(context).textureMode(true)
|
||||
MapView(context, options).apply {
|
||||
onCreate(null)
|
||||
onStart()
|
||||
onResume()
|
||||
getMapAsync { m ->
|
||||
m.uiSettings.isLogoEnabled = false
|
||||
m.uiSettings.isCompassEnabled = false
|
||||
m.uiSettings.isRotateGesturesEnabled = false
|
||||
m.uiSettings.isTiltGesturesEnabled = false
|
||||
map = m
|
||||
}
|
||||
}
|
||||
},
|
||||
onRelease = { view ->
|
||||
runCatching {
|
||||
view.onPause()
|
||||
view.onStop()
|
||||
view.onDestroy()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// The style follows the car's theme; the pins go in once it's loaded.
|
||||
LaunchedEffect(map, dark) {
|
||||
val m = map ?: return@LaunchedEffect
|
||||
style = null
|
||||
m.setStyle(Style.Builder().fromUri(if (dark) DARK_STYLE else LIGHT_STYLE)) { loaded -> style = loaded }
|
||||
}
|
||||
LaunchedEffect(style, places, pin, card, cardsAtEnd) {
|
||||
val m = map ?: return@LaunchedEffect
|
||||
val s = style ?: return@LaunchedEffect
|
||||
showPins(s, places, pin, pinText, anchorColor)
|
||||
val points = places.map { it.latLng }
|
||||
when {
|
||||
points.isEmpty() -> Unit
|
||||
points.size == 1 -> m.moveCamera(CameraUpdateFactory.newLatLngZoom(points.first(), SINGLE_PLACE_ZOOM))
|
||||
else -> {
|
||||
val bounds = LatLngBounds.Builder().includes(points).build()
|
||||
val left = if (cardsAtEnd) margin else card + margin
|
||||
val right = if (cardsAtEnd) card + margin else margin
|
||||
runCatching { m.moveCamera(CameraUpdateFactory.newLatLngBounds(bounds, left, margin, right, margin)) }
|
||||
.onFailure { GearslipLog.w("host map: couldn't fit ${points.size} places: ${it.message}") }
|
||||
}
|
||||
}
|
||||
}
|
||||
DisposableEffect(Unit) { onDispose { map = null } }
|
||||
}
|
||||
}
|
||||
|
||||
/** One pin: where, and the label on it (the list's number, or the app's own marker text). */
|
||||
private class Pin(val latLng: LatLng, val label: String, val anchor: Boolean)
|
||||
|
||||
private fun placesOf(template: PlaceListMapTemplate): List<Pin> {
|
||||
val rows = template.itemList?.items.orEmpty().filterIsInstance<Row>()
|
||||
val pins = rows.mapIndexedNotNull { i, row ->
|
||||
val place = row.metadata?.place ?: return@mapIndexedNotNull null
|
||||
pinOf(place, (i + 1).toString(), anchor = false)
|
||||
}
|
||||
return pins + listOfNotNull(template.anchor?.let { pinOf(it, "", anchor = true) })
|
||||
}
|
||||
|
||||
private fun pinOf(place: Place, fallback: String, anchor: Boolean): Pin? {
|
||||
val location = place.location
|
||||
if (location.latitude == 0.0 && location.longitude == 0.0) return null
|
||||
val label = place.marker?.label?.toString()?.takeIf { it.isNotBlank() } ?: fallback
|
||||
return Pin(LatLng(location.latitude, location.longitude), label, anchor)
|
||||
}
|
||||
|
||||
private fun showPins(style: Style, pins: List<Pin>, pin: Int, pinText: Int, anchorColor: Int) {
|
||||
val features = FeatureCollection.fromFeatures(
|
||||
pins.map { p ->
|
||||
Feature.fromGeometry(Point.fromLngLat(p.latLng.longitude, p.latLng.latitude)).apply {
|
||||
addStringProperty("label", p.label)
|
||||
addBooleanProperty("anchor", p.anchor)
|
||||
}
|
||||
},
|
||||
)
|
||||
val source = style.getSourceAs<GeoJsonSource>(SOURCE)
|
||||
if (source != null) {
|
||||
source.setGeoJson(features)
|
||||
return
|
||||
}
|
||||
style.addSource(GeoJsonSource(SOURCE, features))
|
||||
style.addLayer(
|
||||
CircleLayer(PIN_LAYER, SOURCE).withProperties(
|
||||
PropertyFactory.circleRadius(Expression.switchCase(Expression.get("anchor"), Expression.literal(9f), Expression.literal(14f))),
|
||||
PropertyFactory.circleColor(Expression.switchCase(Expression.get("anchor"), Expression.color(anchorColor), Expression.color(pin))),
|
||||
PropertyFactory.circleStrokeColor(AndroidColor.WHITE),
|
||||
PropertyFactory.circleStrokeWidth(2f),
|
||||
),
|
||||
)
|
||||
style.addLayer(
|
||||
SymbolLayer(LABEL_LAYER, SOURCE).withProperties(
|
||||
PropertyFactory.textField(Expression.get("label")),
|
||||
PropertyFactory.textFont(arrayOf("Noto Sans Bold")),
|
||||
PropertyFactory.textSize(13f),
|
||||
PropertyFactory.textColor(pinText),
|
||||
PropertyFactory.textAllowOverlap(true),
|
||||
PropertyFactory.textIgnorePlacement(true),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private const val LIGHT_STYLE = "https://tiles.openfreemap.org/styles/positron"
|
||||
private const val DARK_STYLE = "https://tiles.openfreemap.org/styles/dark"
|
||||
private const val SOURCE = "gearslip-places"
|
||||
private const val PIN_LAYER = "gearslip-place-pins"
|
||||
private const val LABEL_LAYER = "gearslip-place-labels"
|
||||
private const val SINGLE_PLACE_ZOOM = 14.0
|
||||
@@ -1,258 +0,0 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.car.app.navigation.model.Lane
|
||||
import androidx.car.app.navigation.model.LaneDirection
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.PathEffect
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.StrokeJoin
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.lerp
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
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 kotlin.math.PI
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.sin
|
||||
|
||||
/** The dark band the lanes sit on under the turn card's header, tinted toward the theme. */
|
||||
@Composable
|
||||
internal fun laneBandColor(): Color = lerp(Color(0xFF0E1417), MaterialTheme.colorScheme.primary, 0.14f)
|
||||
|
||||
/**
|
||||
* The lanes before a manoeuvre as a dark band under the turn card's header: the road ahead in
|
||||
* perspective, with the lane or lanes to be in filled with the header's colour.
|
||||
*/
|
||||
@Composable
|
||||
internal fun LaneBand(lanes: List<Lane>, modifier: Modifier = Modifier) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val band = laneBandColor()
|
||||
LaneRoad(
|
||||
lanes,
|
||||
modifier.background(band).padding(horizontal = 16.dp, vertical = 10.dp).fillMaxWidth().height(52.dp),
|
||||
scheme.primaryContainer,
|
||||
scheme.onPrimaryContainer,
|
||||
band,
|
||||
)
|
||||
}
|
||||
|
||||
/** Which lanes to use, in words, on the same band: "Use the left lane". Nothing when it can't say. */
|
||||
@Composable
|
||||
internal fun LaneHintLine(lanes: List<Lane>, modifier: Modifier = Modifier) {
|
||||
val hint = laneHint(lanes) ?: return
|
||||
Text(
|
||||
hint,
|
||||
modifier.background(laneBandColor()).padding(start = 16.dp, end = 16.dp, bottom = 10.dp),
|
||||
style = ChromeType.label,
|
||||
color = Color.White,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The road ahead, narrowing toward the top: solid edges, dashed lines between lanes, the
|
||||
* recommended lanes filled with [fill] and every lane's arrows drawn from its directions, the
|
||||
* recommended ones in [onFill] and the rest faded toward [ground], the colour behind the road.
|
||||
* The arrows are painted on the road, so they narrow with it. Lanes run left to right, as the app
|
||||
* sends them.
|
||||
*/
|
||||
@Composable
|
||||
internal fun LaneRoad(lanes: List<Lane>, modifier: Modifier, fill: Color, onFill: Color, ground: Color, line: Color = Color.White) {
|
||||
val named = lanes.map {
|
||||
stringResource(if (it.recommended()) R.string.nav_lane_recommended else R.string.nav_lane)
|
||||
}.joinToString(", ")
|
||||
Canvas(modifier.semantics { contentDescription = named }) {
|
||||
val n = lanes.size
|
||||
if (n == 0) return@Canvas
|
||||
val h = size.height
|
||||
// Each lane at most a little wider than the road is tall, so two lanes don't fill a whole card.
|
||||
val bottomWidth = minOf(size.width, h * 1.6f * n)
|
||||
val topWidth = bottomWidth * 0.86f
|
||||
fun width(y: Float) = topWidth + (bottomWidth - topWidth) * (y / h)
|
||||
// The x of lane edge [edge] (0 to n, fractions for the middle of a lane) at height [y].
|
||||
fun x(edge: Float, y: Float): Float = size.width / 2 - width(y) / 2 + width(y) * edge / n
|
||||
lanes.forEachIndexed { i, lane ->
|
||||
if (!lane.recommended()) return@forEachIndexed
|
||||
drawPath(
|
||||
Path().apply {
|
||||
moveTo(x(i.toFloat(), 0f), 0f)
|
||||
lineTo(x(i + 1f, 0f), 0f)
|
||||
lineTo(x(i + 1f, h), h)
|
||||
lineTo(x(i.toFloat(), h), h)
|
||||
close()
|
||||
},
|
||||
fill,
|
||||
)
|
||||
}
|
||||
val edge = 2.dp.toPx()
|
||||
drawLine(line, Offset(x(0f, 0f), 0f), Offset(x(0f, h), h), edge)
|
||||
drawLine(line, Offset(x(n.toFloat(), 0f), 0f), Offset(x(n.toFloat(), h), h), edge)
|
||||
val dashes = PathEffect.dashPathEffect(floatArrayOf(h * 0.22f, h * 0.14f))
|
||||
for (i in 1 until n) {
|
||||
drawLine(lerp(ground, line, 0.7f), Offset(x(i.toFloat(), 0f), 0f), Offset(x(i.toFloat(), h), h), edge, pathEffect = dashes)
|
||||
}
|
||||
|
||||
val top = h * 0.12f
|
||||
val bottom = h * 0.9f
|
||||
// Half the room across a lane the arrows may use, as at the bottom: well inside the lines
|
||||
// even where the lane narrows at the top.
|
||||
val room = bottomWidth / n * 0.31f
|
||||
lanes.forEachIndexed { i, lane ->
|
||||
val directions = lane.directions.orEmpty().ifEmpty { listOf(null) }
|
||||
// Faded first, so the one to take draws on top where they share a stem.
|
||||
val arrows = directions.sortedBy { it?.isRecommended == true }
|
||||
.map { it to laneArrow(it?.shape ?: LaneDirection.SHAPE_STRAIGHT, top, bottom) }
|
||||
// The lane's arrows as one shape: centred in the lane, and shrunk toward the stem's
|
||||
// foot when they'd reach past [room].
|
||||
val xs = arrows.flatMap { (_, a) -> a.line.map { it.x } + a.head.map { it.x } }
|
||||
val shift = -(xs.min() + xs.max()) / 2
|
||||
val scale = minOf(1f, room / ((xs.max() - xs.min()) / 2))
|
||||
val lift = (bottom - top) * (1 - scale) / 2
|
||||
// Flat to the road: centred, scaled, then narrowed with the lane toward the top.
|
||||
val onRoad = { p: Offset ->
|
||||
val y = bottom - (bottom - p.y) * scale - lift
|
||||
Offset(x(i + 0.5f, y) + (p.x + shift) * scale * width(y) / bottomWidth, y)
|
||||
}
|
||||
val stroke = (bottom - top) * 0.085f * scale * width((top + bottom) / 2) / bottomWidth
|
||||
val behind = if (lane.recommended()) fill else ground
|
||||
val on = if (lane.recommended()) onFill else line
|
||||
arrows.forEach { (d, arrow) ->
|
||||
// Solid colours, faded by mixing rather than transparency, so where a stem runs
|
||||
// into its head nothing shows through twice.
|
||||
val color = if (d?.isRecommended == true) on else lerp(behind, on, 0.38f)
|
||||
val points = arrow.line.map(onRoad)
|
||||
drawPath(
|
||||
Path().apply {
|
||||
moveTo(points[0].x, points[0].y)
|
||||
points.drop(1).forEach { lineTo(it.x, it.y) }
|
||||
},
|
||||
color,
|
||||
style = Stroke(stroke, cap = StrokeCap.Round, join = StrokeJoin.Round),
|
||||
)
|
||||
val corners = arrow.head.map(onRoad)
|
||||
drawPath(
|
||||
Path().apply {
|
||||
moveTo(corners[0].x, corners[0].y)
|
||||
lineTo(corners[1].x, corners[1].y)
|
||||
lineTo(corners[2].x, corners[2].y)
|
||||
close()
|
||||
},
|
||||
color,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** An arrow laid out flat: the stem as points, and the head's tip and two back corners. */
|
||||
private class LaneArrow(val line: List<Offset>, val head: List<Offset>)
|
||||
|
||||
/**
|
||||
* One direction's arrow, flat, its stem rising from x 0 at [bottom]: on straight to [top], bending
|
||||
* off to a side from a little above the middle, or looping back for a U-turn. A lane's arrows
|
||||
* share the stem, so a straight-and-right lane reads as one fork.
|
||||
*/
|
||||
private fun laneArrow(shape: Int, top: Float, bottom: Float): LaneArrow {
|
||||
val tall = bottom - top
|
||||
val head = tall * 0.17f
|
||||
val start = Offset(0f, bottom)
|
||||
val degrees = when (shape) {
|
||||
LaneDirection.SHAPE_SLIGHT_LEFT -> -45f
|
||||
LaneDirection.SHAPE_SLIGHT_RIGHT -> 45f
|
||||
LaneDirection.SHAPE_NORMAL_LEFT -> -90f
|
||||
LaneDirection.SHAPE_NORMAL_RIGHT -> 90f
|
||||
LaneDirection.SHAPE_SHARP_LEFT -> -135f
|
||||
LaneDirection.SHAPE_SHARP_RIGHT -> 135f
|
||||
LaneDirection.SHAPE_U_TURN_LEFT -> -180f
|
||||
LaneDirection.SHAPE_U_TURN_RIGHT -> 180f
|
||||
else -> 0f
|
||||
}
|
||||
val side = if (degrees < 0) -1f else 1f
|
||||
val line = mutableListOf(start)
|
||||
val end: Offset
|
||||
val dir: Offset
|
||||
when {
|
||||
degrees == 0f -> {
|
||||
end = Offset(0f, top + head)
|
||||
line += end
|
||||
dir = Offset(0f, -1f)
|
||||
}
|
||||
abs(degrees) == 180f -> {
|
||||
// Up, over the top and back down beside the stem.
|
||||
val r = tall * 0.2f
|
||||
line += Offset(0f, top + r)
|
||||
line += curve(Offset(0f, top + r), Offset(0f, top), Offset(side * r, top))
|
||||
line += curve(Offset(side * r, top), Offset(side * 2 * r, top), Offset(side * 2 * r, top + r))
|
||||
end = Offset(side * 2 * r, top + tall * 0.5f)
|
||||
line += end
|
||||
dir = Offset(0f, 1f)
|
||||
}
|
||||
else -> {
|
||||
val rad = degrees / 180f * PI.toFloat()
|
||||
val d = Offset(sin(rad), -cos(rad))
|
||||
val length = tall * 0.32f
|
||||
val fork = Offset(0f, top + tall * 0.6f)
|
||||
val bend = Offset(0f, fork.y - length * 0.45f)
|
||||
end = bend + d * length
|
||||
line += fork
|
||||
line += curve(fork, bend, end)
|
||||
dir = d
|
||||
}
|
||||
}
|
||||
val tip = end + dir * head
|
||||
val base = tip - dir * (head * 1.5f)
|
||||
val across = Offset(-dir.y, dir.x) * (head * 0.9f)
|
||||
return LaneArrow(line, listOf(tip, base + across, base - across))
|
||||
}
|
||||
|
||||
/** Points along a quadratic curve from [a] to [c] bending toward [b], [a] itself left out. */
|
||||
private fun curve(a: Offset, b: Offset, c: Offset): List<Offset> = (1..10).map { k ->
|
||||
val t = k / 10f
|
||||
a * ((1 - t) * (1 - t)) + b * (2 * (1 - t) * t) + c * (t * t)
|
||||
}
|
||||
|
||||
/**
|
||||
* Which lanes to be in, in words, when the recommended ones sit together: the left or right
|
||||
* lanes, the middle one, a lane counted from the nearer side, or any. Null when nothing is
|
||||
* recommended or the lanes to use aren't next to each other.
|
||||
*/
|
||||
@Composable
|
||||
internal fun laneHint(lanes: List<Lane>): String? {
|
||||
val n = lanes.size
|
||||
val use = lanes.indices.filter { lanes[it].recommended() }
|
||||
if (n < 2 || use.isEmpty()) return null
|
||||
if (use.size == n) return stringResource(R.string.nav_lane_any)
|
||||
if (use.last() - use.first() + 1 != use.size) return null
|
||||
val resources = LocalContext.current.resources
|
||||
val k = use.size
|
||||
val only = use.first()
|
||||
return when {
|
||||
only == 0 -> resources.getQuantityString(R.plurals.nav_lane_left, k, k)
|
||||
use.last() == n - 1 -> resources.getQuantityString(R.plurals.nav_lane_right, k, k)
|
||||
k == 1 && n % 2 == 1 && only == n / 2 -> stringResource(R.string.nav_lane_middle)
|
||||
k == 1 && only < n - 1 - only -> stringResource(R.string.nav_lane_from_left, only + 1)
|
||||
k == 1 -> stringResource(R.string.nav_lane_from_right, n - only)
|
||||
else -> stringResource(R.string.nav_lane_middle_lanes)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Lane.recommended(): Boolean = directions.orEmpty().any { it.isRecommended }
|
||||
@@ -1,111 +0,0 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.view.KeyEvent
|
||||
import app.seb3thehacker.gearslip.host.CarAppConnection
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlin.math.min
|
||||
import kotlin.math.pow
|
||||
|
||||
/**
|
||||
* The knob on a full-screen map, as in Android Auto: turning zooms, nudging pans, and pressing
|
||||
* brings up the map's buttons for the knob to move between. Back puts them away and gives the
|
||||
* knob back to the map. Issue #74: on a Mazda 3, every turn stepped through each map button.
|
||||
*
|
||||
* [width] and [height] are the app's surface in pixels; zooms centre on its middle.
|
||||
* [clearFocus] drops focus off whatever button the knob was on.
|
||||
*/
|
||||
internal class MapKnob(
|
||||
private val connection: CarAppConnection,
|
||||
private val width: Int,
|
||||
private val height: Int,
|
||||
private val clearFocus: () -> Unit,
|
||||
) : CarKeys.KnobTaker {
|
||||
|
||||
private val main = Handler(Looper.getMainLooper())
|
||||
/** The press that opened the buttons lifts afterwards; that lift belongs to no button. */
|
||||
@Volatile private var swallowLift = false
|
||||
|
||||
override fun take(event: CarKeys.KnobEvent): Boolean {
|
||||
// A card over the map with buttons of its own (an app's alert, a call, a message, the
|
||||
// assistant, Bluetooth pairing) needs the knob more than the map does.
|
||||
if (overlayUp()) return false
|
||||
if (_buttonsOpen.value) {
|
||||
if (event is CarKeys.KnobEvent.Key && !event.down && swallowLift && event.keyCode in PRESS_KEYS) {
|
||||
swallowLift = false
|
||||
return true
|
||||
}
|
||||
if (event == CarKeys.KnobEvent.Back) {
|
||||
main.post { close() }
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
when (event) {
|
||||
is CarKeys.KnobEvent.Turn -> main.post { zoom(event.steps) }
|
||||
is CarKeys.KnobEvent.Key -> when (event.keyCode) {
|
||||
in PRESS_KEYS -> if (event.down) main.post { open() }
|
||||
else -> if (event.down) main.post { pan(event.keyCode) }
|
||||
}
|
||||
CarKeys.KnobEvent.Back -> return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun overlayUp(): Boolean =
|
||||
CarAlerts.shown.value != null ||
|
||||
app.seb3thehacker.gearslip.call.CarCalls.state.value is app.seb3thehacker.gearslip.call.CallUiState.Ringing ||
|
||||
app.seb3thehacker.gearslip.notify.CarNotifications.popup.value != null ||
|
||||
CarAssistant.state.value.phase != CarAssistant.Phase.IDLE ||
|
||||
CarBluetooth.prompt.value != CarBluetooth.Prompt.NONE
|
||||
|
||||
private fun zoom(steps: Int) {
|
||||
val clicks = steps.coerceIn(-MAX_STEPS, MAX_STEPS)
|
||||
connection.surfaceScale(width / 2f, height / 2f, ZOOM_STEP.pow(clicks))
|
||||
}
|
||||
|
||||
/** One nudge moves the map a sixth of the screen. Scroll distances run previous minus current. */
|
||||
private fun pan(keyCode: Int) {
|
||||
val step = min(width, height) / 6f
|
||||
when (keyCode) {
|
||||
KeyEvent.KEYCODE_DPAD_UP -> connection.surfaceScroll(0f, -step)
|
||||
KeyEvent.KEYCODE_DPAD_DOWN -> connection.surfaceScroll(0f, step)
|
||||
KeyEvent.KEYCODE_DPAD_LEFT -> connection.surfaceScroll(-step, 0f)
|
||||
KeyEvent.KEYCODE_DPAD_RIGHT -> connection.surfaceScroll(step, 0f)
|
||||
}
|
||||
}
|
||||
|
||||
private fun open() {
|
||||
swallowLift = true
|
||||
_buttonsOpen.value = true
|
||||
// The map's buttons take focus when they see this. A map showing none gets a Tab, so the
|
||||
// knob still lands somewhere.
|
||||
if (controls == 0) CarKeys.focusSink?.let { it(KeyEvent.KEYCODE_TAB, true); it(KeyEvent.KEYCODE_TAB, false) }
|
||||
}
|
||||
|
||||
private fun close() {
|
||||
_buttonsOpen.value = false
|
||||
clearFocus()
|
||||
}
|
||||
|
||||
/** Puts the buttons away when the map leaves the screen. */
|
||||
fun release() {
|
||||
_buttonsOpen.value = false
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val _buttonsOpen = MutableStateFlow(false)
|
||||
|
||||
/** The knob pressed on the map: its buttons show and take focus, until Back. */
|
||||
val buttonsOpen: StateFlow<Boolean> = _buttonsOpen
|
||||
|
||||
/** How many stacks of map buttons are on screen, for [open] to know whether any will take focus. */
|
||||
@Volatile var controls = 0
|
||||
|
||||
private const val ZOOM_STEP = 1.25f
|
||||
private const val MAX_STEPS = 5
|
||||
private val PRESS_KEYS = setOf(KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER)
|
||||
}
|
||||
}
|
||||
@@ -117,7 +117,7 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) {
|
||||
CarMedia.Rejection.NEEDS_NOTIFICATION_ACCESS -> Notice(
|
||||
stringResource(R.string.media_keeps_library, app.label),
|
||||
stringResource(R.string.media_needs_access),
|
||||
action = stringResource(R.string.allow_on_phone) to {
|
||||
action = stringResource(R.string.media_open_on_phone) to {
|
||||
val app = context.applicationContext
|
||||
runCatching {
|
||||
app.startActivity(
|
||||
@@ -259,21 +259,15 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) {
|
||||
@Composable
|
||||
private fun AppIdentity(app: MediaApp, onExit: () -> Unit, modifier: Modifier = Modifier) {
|
||||
val icon = remember(app) { findEntry(app.component.flattenToString())?.icon }
|
||||
val navigator = LocalCarNavigator.current
|
||||
Row(modifier, verticalAlignment = Alignment.CenterVertically) {
|
||||
// Shown on the same terms as every other screen's header: only when it leads somewhere
|
||||
// the nav bar doesn't.
|
||||
if (navigator.backLeadsSomewhere) {
|
||||
GsIconButton(Icons.Filled.ArrowBack, stringResource(R.string.back), onExit)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
}
|
||||
GsIconButton(Icons.Filled.ArrowBack, stringResource(R.string.back), onExit, size = 44.dp)
|
||||
Spacer(Modifier.width(14.dp))
|
||||
icon?.let {
|
||||
Image(it, null, Modifier.size(26.dp).clip(RoundedCornerShape(7.dp)))
|
||||
Spacer(Modifier.width(10.dp))
|
||||
}
|
||||
Text(
|
||||
// As the app writes it: recasing turned NewPipe into "Newpipe" and TIDAL into "Tidal".
|
||||
app.label,
|
||||
app.label.lowercase().replaceFirstChar { it.titlecase() },
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
|
||||
@@ -72,7 +72,7 @@ fun MessagesScreen(packageName: String) {
|
||||
|
||||
Column(Modifier.fillMaxSize().padding(start = 16.dp, end = 16.dp, top = 16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
if (navigator.backLeadsSomewhere) GsIconButton(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back), navigator::back)
|
||||
GsIconButton(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back), navigator::back)
|
||||
entry?.icon?.let { Image(it, contentDescription = null, modifier = Modifier.size(36.dp).clip(RoundedCornerShape(8.dp))) }
|
||||
Text(label, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
|
||||
@@ -97,8 +97,7 @@ fun NotificationsSection() {
|
||||
}
|
||||
}
|
||||
|
||||
// With access off, "nothing yet" would promise notifications that can't come.
|
||||
if (history.isEmpty() && listening) {
|
||||
if (history.isEmpty()) {
|
||||
Text(
|
||||
stringResource(R.string.notif_empty),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
@@ -114,11 +113,10 @@ fun NotificationsSection() {
|
||||
|
||||
@Composable
|
||||
internal fun AccessBanner(onOpen: () -> Unit) {
|
||||
// A setting left off, not a fault: a quiet card, as Android Auto shows one, rather than red.
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
color = MaterialTheme.colorScheme.errorContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onErrorContainer,
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
|
||||
) {
|
||||
Row(
|
||||
@@ -126,13 +124,13 @@ internal fun AccessBanner(onOpen: () -> Unit) {
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Icon(Icons.Filled.Notifications, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
|
||||
Icon(Icons.Filled.Warning, contentDescription = null)
|
||||
Text(
|
||||
stringResource(R.string.notif_off),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
GsButton(onClick = onOpen, tone = GsTone.Tonal) { Text(stringResource(R.string.allow_on_phone)) }
|
||||
GsButton(onClick = onOpen) { Text(stringResource(R.string.guide_open)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,61 +129,21 @@ internal fun CallPromptOverlay() {
|
||||
)
|
||||
}
|
||||
|
||||
private const val TAB_RECENTS = 0
|
||||
private const val TAB_CONTACTS = 1
|
||||
private const val TAB_KEYPAD = 2
|
||||
private const val TAB_CONTACTS = 0
|
||||
private const val TAB_KEYPAD = 1
|
||||
|
||||
/** One number from the call log: its latest call, whether that was missed, and when. */
|
||||
private data class RecentCall(val contact: Contact, val missed: Boolean, val at: Long)
|
||||
|
||||
/** The latest call to or from each number, newest first, as Android Auto lists them. */
|
||||
private fun loadRecents(context: Context): List<RecentCall>? {
|
||||
if (context.checkSelfPermission(Manifest.permission.READ_CALL_LOG) != PackageManager.PERMISSION_GRANTED) return null
|
||||
val out = mutableListOf<RecentCall>()
|
||||
val seen = mutableSetOf<String>()
|
||||
runCatching {
|
||||
context.contentResolver.query(
|
||||
android.provider.CallLog.Calls.CONTENT_URI,
|
||||
arrayOf(
|
||||
android.provider.CallLog.Calls.NUMBER,
|
||||
android.provider.CallLog.Calls.CACHED_NAME,
|
||||
android.provider.CallLog.Calls.TYPE,
|
||||
android.provider.CallLog.Calls.DATE,
|
||||
),
|
||||
null, null,
|
||||
"${android.provider.CallLog.Calls.DATE} DESC",
|
||||
)?.use { cursor ->
|
||||
while (cursor.moveToNext() && out.size < RECENTS_SHOWN) {
|
||||
val number = cursor.getString(0)?.takeIf { it.isNotBlank() } ?: continue
|
||||
if (!seen.add(number)) continue
|
||||
val name = cursor.getString(1)?.takeIf { it.isNotBlank() } ?: number
|
||||
val type = cursor.getInt(2)
|
||||
val missed = type == android.provider.CallLog.Calls.MISSED_TYPE || type == android.provider.CallLog.Calls.REJECTED_TYPE
|
||||
out += RecentCall(Contact(name, number), missed, cursor.getLong(3))
|
||||
}
|
||||
}
|
||||
}.onFailure { GearslipLog.w("calls: reading the call log failed: ${it.message}") }
|
||||
return out
|
||||
}
|
||||
|
||||
private const val RECENTS_SHOWN = 50
|
||||
|
||||
/**
|
||||
* Calling from the car screen, laid out as Android Auto's dialer: recent calls first, since
|
||||
* calling back is the usual reason to open it, then contacts and a keypad.
|
||||
*/
|
||||
/** Dialing a number or calling a contact from the car screen: a contacts tab and a keypad tab. */
|
||||
@Composable
|
||||
fun PhoneDialerScreen() {
|
||||
val context = LocalContext.current
|
||||
var hasPermissions by remember { mutableStateOf(hasCallPermissions(context)) }
|
||||
var tab by remember { mutableStateOf(TAB_RECENTS) }
|
||||
var tab by remember { mutableStateOf(TAB_CONTACTS) }
|
||||
|
||||
Column(Modifier.fillMaxSize().padding(start = 16.dp, end = 16.dp, top = 16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
// The tabs share the title's row, so the list and keypad get the height back.
|
||||
ScreenHeader(stringResource(R.string.carset_phone)) {
|
||||
if (hasPermissions) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
DialerTab(stringResource(R.string.dialer_recents), tab == TAB_RECENTS, Modifier.width(140.dp)) { tab = TAB_RECENTS }
|
||||
DialerTab(stringResource(R.string.dialer_contacts), tab == TAB_CONTACTS, Modifier.width(140.dp)) { tab = TAB_CONTACTS }
|
||||
DialerTab(stringResource(R.string.dialer_keypad), tab == TAB_KEYPAD, Modifier.width(140.dp)) { tab = TAB_KEYPAD }
|
||||
}
|
||||
@@ -196,11 +156,7 @@ fun PhoneDialerScreen() {
|
||||
}
|
||||
|
||||
Box(Modifier.weight(1f).fillMaxWidth()) {
|
||||
when (tab) {
|
||||
TAB_RECENTS -> RecentsList(context)
|
||||
TAB_CONTACTS -> ContactsList(context)
|
||||
else -> Box(Modifier.padding(bottom = 16.dp)) { Keypad(context) }
|
||||
}
|
||||
if (tab == TAB_CONTACTS) ContactsList(context) else Box(Modifier.padding(bottom = 16.dp)) { Keypad(context) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -304,53 +260,6 @@ private fun KeypadButton(digit: String, modifier: Modifier, onClick: () -> Unit)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RecentsList(context: Context) {
|
||||
// Null until read, then null again only when the call log isn't allowed.
|
||||
var loaded by remember { mutableStateOf(false) }
|
||||
val recents by produceState<List<RecentCall>?>(null, context) {
|
||||
value = withContext(Dispatchers.IO) { loadRecents(context) }
|
||||
loaded = true
|
||||
}
|
||||
var pendingCall by remember { mutableStateOf<Contact?>(null) }
|
||||
val list = recents
|
||||
|
||||
when {
|
||||
!loaded -> Unit
|
||||
list == null -> Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(
|
||||
stringResource(R.string.dialer_recents_needs_permission),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
GsButton(tone = GsTone.Tonal, onClick = {
|
||||
app.seb3thehacker.gearslip.PermissionActivity.ask(context, arrayOf(Manifest.permission.READ_CALL_LOG))
|
||||
}) { Text(stringResource(R.string.dialer_ask)) }
|
||||
}
|
||||
list.isEmpty() -> Text(
|
||||
stringResource(R.string.dialer_no_recents),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
else -> LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(bottom = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
items(list) { call ->
|
||||
val now = System.currentTimeMillis()
|
||||
val ago = android.text.format.DateUtils.getRelativeTimeSpanString(call.at, now, android.text.format.DateUtils.MINUTE_IN_MILLIS).toString()
|
||||
val detail = if (call.missed) stringResource(R.string.dialer_missed, ago) else ago
|
||||
ContactRow(call.contact, detail = detail, alert = call.missed) { pendingCall = call.contact }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pendingCall?.let { contact ->
|
||||
ConfirmCallOverlay(
|
||||
contact = contact,
|
||||
onConfirm = { placeCall(context, contact.number); pendingCall = null },
|
||||
onDismiss = { pendingCall = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ContactsList(context: Context) {
|
||||
val contacts by produceState(emptyList<Contact>(), context) {
|
||||
@@ -472,7 +381,7 @@ private fun ContactSearchField(query: String, active: Boolean, onToggle: () -> U
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ContactRow(contact: Contact, detail: String = contact.number, alert: Boolean = false, onClick: () -> Unit) {
|
||||
private fun ContactRow(contact: Contact, onClick: () -> Unit) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
shape = MaterialTheme.shapes.large,
|
||||
@@ -497,11 +406,7 @@ private fun ContactRow(contact: Contact, detail: String = contact.number, alert:
|
||||
}
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(contact.name, style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(
|
||||
detail,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (alert) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(contact.number, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
Icon(Icons.Filled.Call, contentDescription = stringResource(R.string.dialer_call), tint = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
|
||||
@@ -19,9 +19,9 @@ import androidx.compose.ui.unit.dp
|
||||
import app.seb3thehacker.gearslip.car.theme.GsIconButton
|
||||
|
||||
/**
|
||||
* The top of every screen reached from Home: the title, room on the right for the screen's own
|
||||
* controls, and a back button when the screen under this one is more than Home or the app grid -
|
||||
* those the nav bar already reaches, so a back button there only repeated it.
|
||||
* The top of every screen reached from Home: a back button in the current theme, the title, and
|
||||
* room on the right for the screen's own controls. A car touchscreen has no hardware back button,
|
||||
* so this is the way out of a screen.
|
||||
*/
|
||||
@Composable
|
||||
fun ScreenHeader(title: String, modifier: Modifier = Modifier, trailing: @Composable RowScope.() -> Unit = {}) {
|
||||
@@ -31,7 +31,7 @@ fun ScreenHeader(title: String, modifier: Modifier = Modifier, trailing: @Compos
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
if (navigator.backLeadsSomewhere) GsIconButton(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back), navigator::back)
|
||||
GsIconButton(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back), navigator::back)
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
|
||||
@@ -30,6 +30,7 @@ import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowUp
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
@@ -38,11 +39,8 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.layout.layoutId
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
@@ -51,6 +49,8 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.car.app.model.CarIcon
|
||||
import androidx.car.app.model.Template
|
||||
import androidx.car.app.model.PlaceListMapTemplate
|
||||
import androidx.car.app.navigation.model.Lane
|
||||
import androidx.car.app.navigation.model.LaneDirection
|
||||
import androidx.car.app.navigation.model.MapTemplate
|
||||
import androidx.car.app.navigation.model.MapWithContentTemplate
|
||||
import androidx.car.app.navigation.model.NavigationTemplate
|
||||
@@ -203,7 +203,8 @@ private fun NavigationChrome(template: NavigationTemplate, modifier: Modifier) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The drive's card down the left, map on the right, the map buttons at the right edge. The whole column counts as covered, so the app centres its map in
|
||||
* Cards down the left, map on the right: the turn on top, the trip along the bottom, the map
|
||||
* buttons at the right edge. The whole column counts as covered, so the app centres its map in
|
||||
* what is left over and the car sits right of the cards rather than under them. Pan mode drops
|
||||
* the cards and gives the map the whole width.
|
||||
*/
|
||||
@@ -223,14 +224,13 @@ private fun NavigationBars(
|
||||
val column = cardColumnWidth(maxWidth)
|
||||
val cards = @Composable {
|
||||
if (routing != null || estimate != null) {
|
||||
Column(Modifier.width(column).fillMaxHeight().covers("nav-cards", cardEdge).padding(12.dp)) {
|
||||
// As tall as what it holds, up to the column's height.
|
||||
NavigationCard(
|
||||
routing,
|
||||
estimate,
|
||||
showStop = !template.actionStrip.hasStop(),
|
||||
Modifier.fillMaxWidth().weight(1f, fill = false),
|
||||
)
|
||||
Column(
|
||||
Modifier.width(column).fillMaxHeight().covers("nav-cards", cardEdge).padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
routing?.let { RoutingCard(it, Modifier.fillMaxWidth()) }
|
||||
Spacer(Modifier.weight(1f))
|
||||
estimate?.let { TravelEstimateCard(it, Modifier.fillMaxWidth()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -252,144 +252,76 @@ private fun NavigationBars(
|
||||
}
|
||||
|
||||
/**
|
||||
* The drive in one card, top to bottom: the manoeuvre big enough to read at a glance, the lanes
|
||||
* to be in, the turn after it, and the trip. On a short screen parts drop out whole, the lanes'
|
||||
* words first, then the next turn, then the lanes; the manoeuvre and the trip always stay.
|
||||
* The manoeuvre, big enough to read at a glance: the arrow and the distance to it, what to do
|
||||
* and onto which road, the lanes to be in, and the turn after it along the bottom.
|
||||
*/
|
||||
@Composable
|
||||
private fun NavigationCard(info: RoutingInfo?, estimate: TravelEstimate?, showStop: Boolean, modifier: Modifier) {
|
||||
val step = info?.currentStep?.takeUnless { info.isLoading }
|
||||
private fun RoutingCard(info: RoutingInfo, modifier: Modifier = Modifier) {
|
||||
if (info.isLoading) {
|
||||
ChromeSurface(modifier) {
|
||||
Text(stringResource(R.string.nav_finding_route), Modifier.padding(horizontal = 18.dp, vertical = 16.dp), style = ChromeType.title)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val step = info.currentStep ?: return
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
ChromeSurface(modifier) {
|
||||
FitColumn {
|
||||
if (info?.isLoading == true) {
|
||||
Text(
|
||||
stringResource(R.string.nav_finding_route),
|
||||
Modifier.layoutId(0).padding(horizontal = 18.dp, vertical = 16.dp),
|
||||
style = ChromeType.title,
|
||||
)
|
||||
}
|
||||
if (info != null && step != null) {
|
||||
ManeuverHeader(info, step, Modifier.layoutId(0).fillMaxWidth())
|
||||
// Drawn from the lane data whenever there is some, even beside the app's own picture.
|
||||
step.lanes.orEmpty().takeIf { it.isNotEmpty() }?.let {
|
||||
LaneBand(it, Modifier.layoutId(2).fillMaxWidth())
|
||||
LaneHintLine(it, Modifier.layoutId(4).fillMaxWidth())
|
||||
}
|
||||
info.nextStep?.let { NextTurn(it, Modifier.layoutId(3).fillMaxWidth()) }
|
||||
}
|
||||
estimate?.let { TripRow(it, showStop, Modifier.layoutId(1).fillMaxWidth()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The manoeuvre on the theme's colour: the arrow and the distance to it, what to do, onto which road. */
|
||||
@Composable
|
||||
private fun ManeuverHeader(info: RoutingInfo, step: Step, modifier: Modifier) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
Column(
|
||||
modifier.background(scheme.primaryContainer).padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
androidx.compose.runtime.CompositionLocalProvider(
|
||||
androidx.compose.material3.LocalContentColor provides scheme.onPrimaryContainer,
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
CarGlyph(step.maneuver?.icon, Modifier.size(64.dp))
|
||||
Spacer(Modifier.width(14.dp))
|
||||
val distance = info.currentDistance.display()
|
||||
if (distance.isNotEmpty()) {
|
||||
Text(
|
||||
distance,
|
||||
style = MaterialTheme.typography.displaySmall.copy(fontWeight = FontWeight.Bold),
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
val cue = step.cue.text()
|
||||
if (cue.isNotEmpty()) {
|
||||
Text(cue, style = ChromeType.title, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
val road = step.road.text()
|
||||
// "Turn left onto Queensberry Drive" already says the road; saying it twice is noise.
|
||||
if (road.isNotEmpty() && !cue.contains(road, ignoreCase = true)) {
|
||||
Text(
|
||||
road,
|
||||
style = ChromeType.body,
|
||||
color = scheme.onPrimaryContainer.copy(alpha = 0.8f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
// An app that sends only its own picture of the lanes gets it shown as it is.
|
||||
if (step.lanes.orEmpty().isEmpty()) {
|
||||
step.lanesImage?.let { JunctionImage(it, Modifier.heightIn(max = 56.dp)) }
|
||||
}
|
||||
// Rare, and only where an arrow can't explain the junction: worth the card growing for.
|
||||
info.junctionImage?.let { JunctionImage(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The turn after this one, as large as the instruction above it and wrapping rather than cut
|
||||
* short: leaving a motorway, the next move comes quickly and has to read in a glance.
|
||||
*/
|
||||
@Composable
|
||||
private fun NextTurn(next: Step, modifier: Modifier) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
// A shade apart from the trip below, so the two never read as one block of text.
|
||||
Row(
|
||||
modifier.background(scheme.surfaceContainerHighest).padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
CarGlyph(next.maneuver?.icon, Modifier.size(40.dp))
|
||||
Spacer(Modifier.width(14.dp))
|
||||
Column {
|
||||
Text(
|
||||
stringResource(R.string.nav_then).uppercase(java.util.Locale.getDefault()),
|
||||
style = ChromeType.label.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.2.sp),
|
||||
color = scheme.primary,
|
||||
)
|
||||
val text = next.cue.text().ifEmpty { next.road.text() }
|
||||
if (text.isNotEmpty()) {
|
||||
Text(
|
||||
text,
|
||||
style = ChromeType.body.copy(fontWeight = FontWeight.SemiBold),
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Column(
|
||||
Modifier.fillMaxWidth().background(scheme.primaryContainer).padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
androidx.compose.runtime.CompositionLocalProvider(
|
||||
androidx.compose.material3.LocalContentColor provides scheme.onPrimaryContainer,
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
CarGlyph(step.maneuver?.icon, Modifier.size(64.dp))
|
||||
Spacer(Modifier.width(14.dp))
|
||||
val distance = info.currentDistance.display()
|
||||
if (distance.isNotEmpty()) {
|
||||
Text(
|
||||
distance,
|
||||
style = MaterialTheme.typography.displaySmall.copy(fontWeight = androidx.compose.ui.text.font.FontWeight.Bold),
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
val cue = step.cue.text()
|
||||
if (cue.isNotEmpty()) {
|
||||
Text(cue, style = ChromeType.title, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
val road = step.road.text()
|
||||
// "Turn left onto Queensberry Drive" already says the road; saying it twice is noise.
|
||||
if (road.isNotEmpty() && !cue.contains(road, ignoreCase = true)) {
|
||||
Text(
|
||||
road,
|
||||
style = ChromeType.body,
|
||||
color = scheme.onPrimaryContainer.copy(alpha = 0.8f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
if (step.lanesImage != null || step.lanes.orEmpty().isNotEmpty()) {
|
||||
LaneGuidance(step, Modifier.fillMaxWidth().heightIn(max = 56.dp))
|
||||
}
|
||||
// Rare, and only where an arrow can't explain the junction: worth the card growing for.
|
||||
info.junctionImage?.let { JunctionImage(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A column that leaves whole parts out when they don't all fit, rather than cutting the last one
|
||||
* in half. Each part's layoutId is its rank: the lowest is kept first and always shown, then each
|
||||
* next one if there's still room for it. What's kept stays in its written order.
|
||||
*/
|
||||
@Composable
|
||||
private fun FitColumn(modifier: Modifier = Modifier, content: @Composable () -> Unit) {
|
||||
androidx.compose.ui.layout.Layout(content, modifier) { measurables, constraints ->
|
||||
val loose = constraints.copy(minHeight = 0, maxHeight = androidx.compose.ui.unit.Constraints.Infinity)
|
||||
val parts = measurables.map { it.measure(loose) }
|
||||
val ranks = measurables.map { it.layoutId as? Int ?: 0 }
|
||||
val kept = BooleanArray(parts.size)
|
||||
var room = if (constraints.hasBoundedHeight) constraints.maxHeight else Int.MAX_VALUE
|
||||
parts.indices.sortedBy { ranks[it] }.forEachIndexed { order, i ->
|
||||
if (order == 0 || parts[i].height <= room) {
|
||||
kept[i] = true
|
||||
room -= parts[i].height
|
||||
}
|
||||
}
|
||||
val width = (parts.maxOfOrNull { it.width } ?: 0).coerceIn(constraints.minWidth, constraints.maxWidth)
|
||||
val height = parts.indices.filter { kept[it] }.sumOf { parts[it].height }
|
||||
layout(width, height.coerceAtLeast(constraints.minHeight)) {
|
||||
var y = 0
|
||||
parts.forEachIndexed { i, part ->
|
||||
if (kept[i]) {
|
||||
part.place(0, y)
|
||||
y += part.height
|
||||
info.nextStep?.let { next ->
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(stringResource(R.string.nav_then), style = ChromeType.label, color = scheme.onSurfaceVariant)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
CarGlyph(next.maneuver?.icon, Modifier.size(28.dp))
|
||||
val nextText = next.cue.text().ifEmpty { next.road.text() }
|
||||
if (nextText.isNotEmpty()) {
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text(nextText, style = ChromeType.body, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -409,95 +341,120 @@ private fun JunctionImage(icon: CarIcon, modifier: Modifier = Modifier) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The trip along the bottom of the card: time left, coloured as the app colours it (by traffic,
|
||||
* in Vela), then the arrival and the distance, each with what it is under it. Gearslip's stop
|
||||
* button sits at the end unless the app shows its own ([hasStop]). It ends navigation the way
|
||||
* going Home does: the connection stays up and the app decides its own next screen.
|
||||
* Which lane to be in before the manoeuvre, either as the app's own picture ([Step.getLanesImage])
|
||||
* or as a row of arrows built from its structured lane data - whichever it sent. The recommended
|
||||
* lane, or lanes, stand out in the theme's own colour; the rest sit on the same muted, solid tile
|
||||
* everything else uses, never dimmed by transparency.
|
||||
*/
|
||||
@Composable
|
||||
private fun TripRow(estimate: TravelEstimate, showStop: Boolean, modifier: Modifier) {
|
||||
private fun LaneGuidance(step: Step, modifier: Modifier = Modifier) {
|
||||
step.lanesImage?.let {
|
||||
JunctionImage(it, modifier)
|
||||
return
|
||||
}
|
||||
val lanes = step.lanes.orEmpty()
|
||||
if (lanes.isEmpty()) return
|
||||
Row(modifier, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
lanes.forEach { lane -> LaneTile(lane) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LaneTile(lane: Lane) {
|
||||
val directions = lane.directions.orEmpty()
|
||||
val shown = directions.firstOrNull { it.isRecommended } ?: directions.firstOrNull()
|
||||
val recommended = shown?.isRecommended == true
|
||||
Box(
|
||||
Modifier
|
||||
.size(36.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(
|
||||
if (recommended) MaterialTheme.colorScheme.primaryContainer
|
||||
else MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.KeyboardArrowUp,
|
||||
contentDescription = stringResource(if (recommended) R.string.nav_lane_recommended else R.string.nav_lane),
|
||||
tint = if (recommended) MaterialTheme.colorScheme.onPrimaryContainer
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(24.dp).rotate(shown.turnDegrees()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The one arrow glyph this app draws, turned to stand in for every shape a lane can permit. */
|
||||
private fun LaneDirection?.turnDegrees(): Float = when (this?.shape) {
|
||||
LaneDirection.SHAPE_SLIGHT_RIGHT -> 30f
|
||||
LaneDirection.SHAPE_NORMAL_RIGHT -> 90f
|
||||
LaneDirection.SHAPE_SHARP_RIGHT -> 135f
|
||||
LaneDirection.SHAPE_U_TURN_RIGHT -> 170f
|
||||
LaneDirection.SHAPE_SLIGHT_LEFT -> -30f
|
||||
LaneDirection.SHAPE_NORMAL_LEFT -> -90f
|
||||
LaneDirection.SHAPE_SHARP_LEFT -> -135f
|
||||
LaneDirection.SHAPE_U_TURN_LEFT -> -170f
|
||||
else -> 0f
|
||||
}
|
||||
|
||||
/**
|
||||
* The trip at a glance, stacked: time left on top, then the clock time it ends at and the
|
||||
* distance, with Gearslip's own stop button beside them. The close button is Gearslip's, not the
|
||||
* app's: an actionStrip's actions carry no flag saying which one (if any) means "stop
|
||||
* navigating" - the first button turned out to be a mute toggle, not an exit - so this ends
|
||||
* navigation the way going Home already does rather than guess at the app's own controls.
|
||||
*/
|
||||
@Composable
|
||||
private fun TravelEstimateCard(estimate: TravelEstimate, modifier: Modifier = Modifier) {
|
||||
val connection = LocalCarAppConnection.current
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val time = remainingTime(estimate.remainingTimeSeconds)
|
||||
val distance = estimate.remainingDistance.display()
|
||||
val arrival = estimate.arrivalTimeAtDestination.clockTime()
|
||||
val trip = estimate.tripText.text()
|
||||
if (time.isEmpty() && distance.isEmpty() && arrival.isEmpty() && trip.isEmpty()) return
|
||||
val dark = scheme.surface.luminanceIsDark()
|
||||
Column(modifier.padding(start = 18.dp, end = 12.dp, top = 12.dp, bottom = 12.dp)) {
|
||||
Row(Modifier.height(androidx.compose.foundation.layout.IntrinsicSize.Min), verticalAlignment = Alignment.CenterVertically) {
|
||||
// The time leads, large and coloured; arrival and distance are smaller, each in its
|
||||
// own column behind a thin rule, with what it is under it.
|
||||
TripFigure(time, null, estimate.remainingTimeColor.color(dark, scheme.onSurface), ChromeType.headline, Modifier.weight(1.1f))
|
||||
TripRule()
|
||||
TripFigure(arrival, stringResource(R.string.nav_arrival), scheme.onSurface, ChromeType.body, Modifier.weight(1f))
|
||||
TripRule()
|
||||
TripFigure(distance, stringResource(R.string.nav_to_go), scheme.onSurface, ChromeType.body, Modifier.weight(1f))
|
||||
if (showStop) {
|
||||
GsIconButton(
|
||||
Icons.Filled.Close,
|
||||
stringResource(R.string.nav_stop),
|
||||
{ connection.stopNavigating() },
|
||||
colors = GsColors(scheme.surfaceContainerHighest, scheme.onSurface),
|
||||
size = 52.dp,
|
||||
iconSize = 26.dp,
|
||||
shape = CircleShape,
|
||||
)
|
||||
|
||||
ChromeSurface(modifier) {
|
||||
val dark = MaterialTheme.colorScheme.surface.luminanceIsDark()
|
||||
Row(
|
||||
Modifier.padding(start = 18.dp, end = 12.dp, top = 12.dp, bottom = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
if (time.isNotEmpty()) {
|
||||
Text(
|
||||
time,
|
||||
style = ChromeType.headline,
|
||||
color = estimate.remainingTimeColor.color(dark, MaterialTheme.colorScheme.onSurface),
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
val detail = listOfNotNull(
|
||||
arrival.takeIf { it.isNotEmpty() }?.let { stringResource(R.string.nav_arrive, it) },
|
||||
distance.takeIf { it.isNotEmpty() },
|
||||
).joinToString(" • ")
|
||||
if (detail.isNotEmpty()) {
|
||||
Text(detail, style = ChromeType.body, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
if (trip.isNotEmpty()) {
|
||||
Text(trip, style = ChromeType.body, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (trip.isNotEmpty()) {
|
||||
Text(
|
||||
trip,
|
||||
Modifier.padding(top = 6.dp),
|
||||
style = ChromeType.body,
|
||||
color = scheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
// Not a disconnect: the connection stays up and the app decides its own next screen.
|
||||
// Ending navigation is its own call, separate from a back press - a back press only
|
||||
// pops the on-screen card, leaving the app still believing it is navigating.
|
||||
GsIconButton(
|
||||
Icons.Filled.Close,
|
||||
stringResource(R.string.nav_stop),
|
||||
{ connection.stopNavigating() },
|
||||
colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.colorScheme.onSurface),
|
||||
size = 52.dp,
|
||||
iconSize = 26.dp,
|
||||
shape = CircleShape,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TripRule() {
|
||||
Box(
|
||||
Modifier.padding(horizontal = 12.dp, vertical = 4.dp).width(1.dp).fillMaxHeight()
|
||||
.background(MaterialTheme.colorScheme.outlineVariant),
|
||||
)
|
||||
}
|
||||
|
||||
/** One of the trip's figures with its label under it, shrinking to stay on one line in a narrow column. */
|
||||
@Composable
|
||||
private fun TripFigure(value: String, label: String?, color: Color, style: androidx.compose.ui.text.TextStyle, modifier: Modifier) {
|
||||
Column(modifier) {
|
||||
if (value.isEmpty()) return@Column
|
||||
var size by androidx.compose.runtime.remember(value, style) { androidx.compose.runtime.mutableStateOf(style.fontSize) }
|
||||
Text(
|
||||
value,
|
||||
style = style.copy(fontSize = size, fontWeight = FontWeight.Bold),
|
||||
color = color,
|
||||
maxLines = 1,
|
||||
softWrap = false,
|
||||
onTextLayout = { if (it.hasVisualOverflow && size.value > 12f) size *= 0.9f },
|
||||
)
|
||||
if (label != null) {
|
||||
Text(label, style = ChromeType.label, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the app's own buttons include one that ends the drive: a red primary button, as
|
||||
* Vela's is, or one named for it. Actions carry no flag that says so, so this is the best sign.
|
||||
*/
|
||||
private fun androidx.car.app.model.ActionStrip?.hasStop(): Boolean = this?.actions.orEmpty().any { action ->
|
||||
val red = action.flags and androidx.car.app.model.Action.FLAG_PRIMARY != 0 &&
|
||||
action.backgroundColor?.type == androidx.car.app.model.CarColor.TYPE_RED
|
||||
red || action.title?.toString()?.let { STOP_WORDS.containsMatchIn(it) } == true
|
||||
}
|
||||
|
||||
private val STOP_WORDS = Regex("\\b(stop|end|exit|quit|cancel)\\b", RegexOption.IGNORE_CASE)
|
||||
|
||||
// --- map with a side pane ----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
@@ -540,9 +497,6 @@ private fun MapPaneLayout(
|
||||
if (pan) null else actionStrip,
|
||||
mapActionStrip,
|
||||
Modifier.align(if (atEnd) Alignment.TopStart else Alignment.TopEnd).padding(12.dp),
|
||||
// Counted as covering the map, as in navigation, or Waze's speedometer slid under
|
||||
// them and off the screen's edge whenever a card was up.
|
||||
mapActionsModifier = Modifier.covers("pane-buttons", buttonEdge),
|
||||
alignment = if (atEnd) Alignment.Start else Alignment.End,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,8 +7,6 @@ import app.seb3thehacker.gearslip.car.theme.*
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.foundation.focusGroup
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
@@ -54,9 +52,6 @@ import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import androidx.compose.ui.graphics.drawscope.clipRect
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
@@ -248,23 +243,13 @@ internal fun RightEdgeControls(
|
||||
val hasPan = (actions + mapActions).any { it.type == Action.TYPE_PAN }
|
||||
androidx.compose.runtime.LaunchedEffect(hasPan) { if (!hasPan) connection.setPanMode(false) }
|
||||
|
||||
// Faded out when the screen has been left alone, to leave the map clear, but still buttons:
|
||||
// a tap on a faded one presses it straight away (and wakes the rest), as Android Auto's do.
|
||||
// Faded out when the screen has been left alone; then they are only a memory of buttons, so
|
||||
// the first touch on one wakes them rather than pressing it.
|
||||
val awake = LocalMapControlsWake.current.awake
|
||||
val pan by connection.panMode.collectAsState()
|
||||
// Kept up while the knob is on one of them, or the ring would fade out along with the button.
|
||||
var holdsFocus by remember { mutableStateOf(false) }
|
||||
// Pressing the knob on the map hands it to these buttons (see MapKnob).
|
||||
val knobOpen by MapKnob.buttonsOpen.collectAsState()
|
||||
val knobFocus = remember { androidx.compose.ui.focus.FocusRequester() }
|
||||
androidx.compose.runtime.DisposableEffect(Unit) {
|
||||
MapKnob.controls++
|
||||
onDispose { MapKnob.controls-- }
|
||||
}
|
||||
androidx.compose.runtime.LaunchedEffect(knobOpen) {
|
||||
if (knobOpen) runCatching { knobFocus.requestFocus() }
|
||||
}
|
||||
val visible = awake || pan || holdsFocus || knobOpen
|
||||
val visible = awake || pan || holdsFocus
|
||||
// With the player beside the map there is no room for the app's row and the map controls
|
||||
// both, so the app's own buttons fold behind one; opening it swaps them in for the map's.
|
||||
val folded = LocalSidePanelInset.current > 0.dp && actions.size > 1
|
||||
@@ -278,9 +263,16 @@ internal fun RightEdgeControls(
|
||||
Column(
|
||||
modifier
|
||||
.alpha(alpha)
|
||||
.focusRequester(knobFocus)
|
||||
.focusGroup()
|
||||
.onFocusChanged { holdsFocus = it.hasFocus },
|
||||
.onFocusChanged { holdsFocus = it.hasFocus }
|
||||
.let { base ->
|
||||
if (visible) base else base.pointerInput(Unit) {
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
awaitPointerEvent(PointerEventPass.Initial).changes.forEach { it.consume() }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
horizontalAlignment = alignment,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
@@ -354,10 +346,6 @@ internal fun ActionButton(action: Action, large: Boolean = false, modifier: Modi
|
||||
AppIconBadge()
|
||||
return
|
||||
}
|
||||
// On an app's first screen, opened from the launcher, Gearslip's own close button is already
|
||||
// the way out; a back arrow beside it did the same thing twice. Deeper in, it stays.
|
||||
val atRoot by connection.atRoot.collectAsState()
|
||||
if (action.type == Action.TYPE_BACK && atRoot && connection === CarServices.browse) return
|
||||
val dark = MaterialTheme.colorScheme.surface.luminanceIsDark()
|
||||
val title = action.title.text()
|
||||
val panOn by connection.panMode.collectAsState()
|
||||
@@ -467,30 +455,16 @@ internal fun AutoStartButton(action: Action, modifier: Modifier = Modifier) {
|
||||
.background(background),
|
||||
)
|
||||
}
|
||||
// The label twice, each clipped to its own side of the fill: dark on the filled part
|
||||
// and light on the empty track. One colour for both was unreadable on the track until
|
||||
// the fill reached it (Vela's Start).
|
||||
val label = action.title.text().ifEmpty { stringResource(R.string.template_start) }
|
||||
val trackContent = MaterialTheme.colorScheme.onSurface
|
||||
for (filled in listOf(false, true)) {
|
||||
androidx.compose.foundation.layout.Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.drawWithContent {
|
||||
val edge = size.width * fraction
|
||||
if (filled) clipRect(right = edge) { this@drawWithContent.drawContent() }
|
||||
else clipRect(left = edge) { this@drawWithContent.drawContent() }
|
||||
}
|
||||
.padding(horizontal = 20.dp, vertical = 16.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
androidx.compose.runtime.CompositionLocalProvider(
|
||||
LocalContentColor provides if (filled) content else trackContent,
|
||||
) {
|
||||
if (hasGlyph) CarGlyph(action.icon, Modifier.size(24.dp).align(Alignment.CenterStart))
|
||||
Text(label, style = ChromeType.title, maxLines = 1, color = LocalContentColor.current)
|
||||
}
|
||||
}
|
||||
androidx.compose.foundation.layout.Box(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 16.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (hasGlyph) CarGlyph(action.icon, Modifier.size(24.dp).align(Alignment.CenterStart))
|
||||
Text(
|
||||
action.title.text().ifEmpty { stringResource(R.string.template_start) },
|
||||
style = ChromeType.title,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -543,11 +517,7 @@ internal fun CarGlyph(icon: CarIcon?, modifier: Modifier = Modifier, standard: I
|
||||
// app's own process for a content:// icon, plus a bitmap decode - done synchronously on the
|
||||
// composition thread for every tile on screen, that's what made a shelf of ten covers feel
|
||||
// like it was hanging rather than loading.
|
||||
// CarIcon.equals calls any two bitmap icons equal, so keyed on the CarIcon alone a turn arrow
|
||||
// drawn as a bitmap (Vela's, Organic Maps' lanes) never replaced the first one of the drive.
|
||||
// Those are keyed on the IconCompat itself, which every new template brings fresh.
|
||||
val key: Any? = icon?.icon?.takeIf { it.type != IconCompat.TYPE_RESOURCE && it.type != IconCompat.TYPE_URI } ?: icon
|
||||
val bitmapState by produceState<ImageBitmap?>(null, key) {
|
||||
val bitmapState by produceState<ImageBitmap?>(null, icon) {
|
||||
value = withContext(Dispatchers.IO) { icon.image(context) }
|
||||
}
|
||||
val bitmap = bitmapState
|
||||
|
||||
@@ -174,20 +174,9 @@ internal fun GridTile(item: GridItem, modifier: Modifier = Modifier, iconSize: a
|
||||
/**
|
||||
* [autoStart] is for a pane floating over a map - a place or a route the app is proposing - where
|
||||
* a single action counts itself down rather than waiting to be pressed, on the theory that a
|
||||
* driver glancing at the car screen has already decided. See [autoStartLead] for when: a pane
|
||||
* offering a real choice (Start vs. something else) always waits to be tapped.
|
||||
* driver glancing at the car screen has already decided. It never applies to more than one
|
||||
* action: a pane offering a real choice (Start vs. something else) always waits to be tapped.
|
||||
*/
|
||||
/**
|
||||
* The action that counts itself down, if the pane is really proposing one thing: its only action,
|
||||
* or the app's primary one when everything beside it is an icon with no words (Waze's "Go now"
|
||||
* next to its routes button). Two worded choices ("New drive", "Add a stop") wait for a tap.
|
||||
*/
|
||||
internal fun autoStartLead(actions: List<Action>): Action? {
|
||||
actions.singleOrNull()?.let { return it }
|
||||
val primary = actions.filter { it.flags and Action.FLAG_PRIMARY != 0 }.singleOrNull() ?: return null
|
||||
return primary.takeIf { actions.all { it === primary || it.title.text().isBlank() } }
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun PaneRows(pane: Pane?, modifier: Modifier = Modifier, autoStart: Boolean = false) {
|
||||
// Some apps pad a pane with a row of blank strings; drawn, it is just a gap.
|
||||
@@ -197,16 +186,8 @@ internal fun PaneRows(pane: Pane?, modifier: Modifier = Modifier, autoStart: Boo
|
||||
Column(modifier) {
|
||||
card.heading?.let { PlaceHeading(it) }
|
||||
if (card.buttons.isNotEmpty()) RowButtons(card.buttons, Modifier.padding(horizontal = 14.dp, vertical = 6.dp))
|
||||
val lead = if (autoStart) autoStartLead(actions) else null
|
||||
if (lead != null) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
AutoStartButton(lead, Modifier.weight(1f))
|
||||
actions.filter { it !== lead }.forEach { ActionButton(it, large = true, modifier = Modifier.weight(1f)) }
|
||||
}
|
||||
if (autoStart && actions.size == 1) {
|
||||
AutoStartButton(actions.single(), Modifier.padding(horizontal = 14.dp, vertical = 10.dp))
|
||||
} else {
|
||||
ActionRow(actions, Modifier.padding(horizontal = 14.dp, vertical = 10.dp), large = true)
|
||||
}
|
||||
|
||||
@@ -10,11 +10,6 @@ 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
|
||||
@@ -38,29 +33,14 @@ 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;
|
||||
* 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 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 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
|
||||
@@ -87,29 +67,6 @@ 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() } }
|
||||
@@ -121,18 +78,15 @@ fun WebApp(content: String) {
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(web, atStart) {
|
||||
DisposableEffect(web) {
|
||||
navigator.backInterceptor = {
|
||||
val view = web
|
||||
if (CarFullscreen.active) {
|
||||
CarFullscreen.exit()
|
||||
true
|
||||
} else if (!atStart && view != null && view.canGoBack()) {
|
||||
} else if (view != null && view.canGoBack()) {
|
||||
view.goBack()
|
||||
true
|
||||
} else if (!atStart && content.isBlank()) {
|
||||
goHome()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
@@ -143,39 +97,19 @@ fun WebApp(content: String) {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
// Back steps through the page's history first (the interceptor above), then leaves.
|
||||
if (!fullscreen) {
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
ScreenHeader(pageTitle.ifEmpty { stringResource(R.string.app_browser) }, Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
|
||||
GsIconButton(androidx.compose.material.icons.Icons.Filled.Search, stringResource(R.string.web_search), {
|
||||
fieldText = null
|
||||
address = ""
|
||||
})
|
||||
if (canGoForward && !atStart) {
|
||||
if (canGoForward) {
|
||||
GsIconButton(androidx.compose.material.icons.Icons.AutoMirrored.Filled.ArrowForward, stringResource(R.string.web_forward), { web?.goForward() })
|
||||
}
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
Box(Modifier.weight(1f).fillMaxSize()) {
|
||||
@@ -183,23 +117,12 @@ 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
|
||||
// A video starts when the page asks, as in a normal browser. Left at Android's
|
||||
// default, YouTube's player waits for a second tap before it will play.
|
||||
settings.mediaPlaybackRequiresUserGesture = false
|
||||
// Draws the page before it's on screen, so the first frame is ready sooner.
|
||||
settings.offscreenPreRaster = true
|
||||
webViewClient = LoggingClient(ring, documentUrl, onStarted = { pageUrl = it }) { view ->
|
||||
webViewClient = LoggingClient(ring) { view ->
|
||||
canGoForward = view.canGoForward()
|
||||
pageUrl = view.url
|
||||
pageTitle = view.title.orEmpty().takeUnless { it.startsWith("http") || it == "about:blank" }.orEmpty()
|
||||
}
|
||||
webChromeClient = object : WebChromeClient() {
|
||||
@@ -207,12 +130,6 @@ 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
|
||||
@@ -229,7 +146,7 @@ fun WebApp(content: String) {
|
||||
FieldFocusBridge(onFocus = { fieldText = it }, onBlur = { fieldText = null }),
|
||||
"GearslipField",
|
||||
)
|
||||
if (content.isNotBlank()) loadContent(this, content)
|
||||
loadContent(this, content)
|
||||
web = this
|
||||
}
|
||||
},
|
||||
@@ -237,18 +154,6 @@ 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(
|
||||
@@ -270,32 +175,12 @@ fun WebApp(content: String) {
|
||||
text = typed,
|
||||
onTextChange = { address = it },
|
||||
onSubmit = {
|
||||
open(BrowserSettings.addressToUrl(typed))
|
||||
web?.loadUrl(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,
|
||||
@@ -322,29 +207,19 @@ private fun loadContent(view: WebView, content: String) {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
/** 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 onLoaded: (WebView) -> Unit) : WebViewClient() {
|
||||
override fun onPageFinished(view: WebView?, url: String?) {
|
||||
documentUrl.set(url)
|
||||
GearslipLog.i("car web page loaded: $url")
|
||||
AdBlockManager.logPageCost(url)
|
||||
view?.let(onLoaded)
|
||||
view?.evaluateJavascript(FOCUS_TRACKING_JS, null)
|
||||
view?.evaluateJavascript(focusRingJs(ring), null)
|
||||
|
||||
@@ -44,6 +44,7 @@ object CarAppCatalog {
|
||||
.asSequence()
|
||||
.filter { it.serviceInfo.packageName != context.packageName }
|
||||
.filterNot { AndroidAuto.matches(it.serviceInfo.packageName, it.loadLabel(manager).toString()) }
|
||||
.filterNot { KnownApps.isHidden(it.serviceInfo.packageName) }
|
||||
.map { resolved ->
|
||||
val info = resolved.serviceInfo
|
||||
TemplateApp(
|
||||
|
||||
@@ -18,8 +18,6 @@ import android.view.Surface
|
||||
import androidx.car.app.CarAppService
|
||||
import androidx.car.app.CarContext
|
||||
import androidx.car.app.HandshakeInfo
|
||||
import androidx.car.app.SessionInfo
|
||||
import androidx.car.app.SessionInfoIntentEncoder
|
||||
import androidx.car.app.IAppHost
|
||||
import androidx.car.app.IAppManager
|
||||
import androidx.car.app.ICarApp
|
||||
@@ -58,12 +56,8 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||
*
|
||||
* Every binder callback lands on a binder thread and is hopped to the main thread, because
|
||||
* the state here feeds Compose.
|
||||
*
|
||||
* With a cluster [session], this is the same app's second session, for the car's instrument
|
||||
* cluster: a bind of its own, which the app answers with a session of its own. It leans on the
|
||||
* main session for location, so it holds none itself.
|
||||
*/
|
||||
class CarAppConnection(private val context: Context, private val session: SessionInfo? = null) {
|
||||
class CarAppConnection(private val context: Context) {
|
||||
|
||||
enum class Phase { IDLE, BINDING, HANDSHAKE, RUNNING, REJECTED, FAILED }
|
||||
|
||||
@@ -132,7 +126,6 @@ class CarAppConnection(private val context: Context, private val session: Sessio
|
||||
update { Status(phase = Phase.BINDING, app = app.label) }
|
||||
|
||||
val intent = Intent(CarAppService.SERVICE_INTERFACE).setComponent(app.component)
|
||||
session?.let { SessionInfoIntentEncoder.encode(it, intent) }
|
||||
val connection = object : ServiceConnection {
|
||||
override fun onServiceConnected(name: ComponentName, service: IBinder) {
|
||||
main.post { onBound(service) }
|
||||
@@ -186,7 +179,7 @@ class CarAppConnection(private val context: Context, private val session: Sessio
|
||||
// BIND_INCLUDE_CAPABILITIES only lends the location capability Gearslip holds at bind
|
||||
// time, so the keep-alive has to be running first - a nav app that starts its own
|
||||
// location service before then is refused, and MapQuest crashes on that.
|
||||
if (holdsLocation || session != null) {
|
||||
if (holdsLocation) {
|
||||
bind()
|
||||
} else {
|
||||
holdsLocation = true
|
||||
@@ -451,19 +444,12 @@ class CarAppConnection(private val context: Context, private val session: Sessio
|
||||
return@call
|
||||
}
|
||||
GearslipLog.i("host: template = ${template.javaClass.simpleName}")
|
||||
// The app's ScreenManager lists every screen on its stack; one or none is its first.
|
||||
_atRoot.value = ((value as? TemplateWrapper)?.templateInfosForScreenStack?.size ?: 1) <= 1
|
||||
_template.value = template
|
||||
update { it.copy(template = template.javaClass.simpleName) }
|
||||
autoRoute.onTemplate(template)
|
||||
}) { manager.getTemplate(it) }
|
||||
}
|
||||
|
||||
private val _atRoot = MutableStateFlow(true)
|
||||
|
||||
/** True while the app shows its first screen, where its own back would only close it. */
|
||||
val atRoot: StateFlow<Boolean> = _atRoot
|
||||
|
||||
private val _panMode = MutableStateFlow(false)
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
package app.seb3thehacker.gearslip.host
|
||||
|
||||
import android.content.Context
|
||||
import app.seb3thehacker.gearslip.AppSettings
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* Apps the driver added to the car by hand, for apps that never declared Android Auto support
|
||||
* (issue #84). A music app is driven through its playback session, the way Spotify's player is;
|
||||
* a messenger's notifications show on Gearslip's Messages screen, as any car messenger's do.
|
||||
* Neither needs anything from the app's developer.
|
||||
*/
|
||||
object CustomCarApps {
|
||||
|
||||
enum class Kind(val id: String) { MEDIA("media"), MESSAGING("messaging") }
|
||||
|
||||
data class App(val packageName: String, val kind: Kind)
|
||||
|
||||
private const val KEY = "custom_car_apps"
|
||||
|
||||
private val flow = MutableStateFlow<List<App>?>(null)
|
||||
|
||||
/** Null until [all] has read the settings once. */
|
||||
val state: StateFlow<List<App>?> = flow
|
||||
|
||||
fun all(context: Context): List<App> = flow.value ?: read(context).also { flow.value = it }
|
||||
|
||||
fun of(context: Context, kind: Kind): List<App> = all(context).filter { it.kind == kind }
|
||||
|
||||
fun isCustom(context: Context, packageName: String): Boolean = all(context).any { it.packageName == packageName }
|
||||
|
||||
fun add(context: Context, packageName: String, kind: Kind) = save(context, all(context).filterNot { it.packageName == packageName } + App(packageName, kind))
|
||||
|
||||
fun remove(context: Context, packageName: String) = save(context, all(context).filterNot { it.packageName == packageName })
|
||||
|
||||
private fun save(context: Context, apps: List<App>) {
|
||||
flow.value = apps
|
||||
AppSettings.putString(context, KEY, apps.joinToString(",") { "${it.packageName}:${it.kind.id}" })
|
||||
}
|
||||
|
||||
private fun read(context: Context): List<App> =
|
||||
AppSettings.getString(context, KEY, "").split(",").mapNotNull { item ->
|
||||
val pkg = item.substringBefore(':').takeIf { it.isNotBlank() } ?: return@mapNotNull null
|
||||
val kind = Kind.entries.firstOrNull { it.id == item.substringAfter(':') } ?: return@mapNotNull null
|
||||
App(pkg, kind)
|
||||
}
|
||||
}
|
||||
@@ -52,34 +52,27 @@ object KnownApps {
|
||||
fun screenBroken(packageName: String): Boolean = packageName in brokenScreen
|
||||
|
||||
/**
|
||||
* Google's own car apps, which never give Gearslip a usable screen. They used to be left off
|
||||
* the launcher, which looked like a bug to anyone who had them; now they're listed like any
|
||||
* other broken app, starting unticked (see [startsHidden]).
|
||||
* Registers a car template service but never gives Gearslip a usable screen - left off the
|
||||
* launcher entirely rather than shown as a tile that goes nowhere.
|
||||
*/
|
||||
private val google = setOf(
|
||||
private val hidden = setOf(
|
||||
"com.google.android.dialer", // Google Phone
|
||||
"com.google.android.googlequicksearchbox", // Google Assistant driving surfaces
|
||||
"com.google.android.apps.maps", // Google Maps: shows as a messenger, and its car UI is Android Auto's own
|
||||
)
|
||||
|
||||
/** Shown on the launcher with a red X, and unticked to start with. */
|
||||
private val broken = google + setOf(
|
||||
fun isHidden(packageName: String): Boolean = packageName in hidden
|
||||
|
||||
/** Shown on the launcher with a red X: known broken, but expected enough that hiding it outright would look like a bug. */
|
||||
private val broken = setOf(
|
||||
"com.google.android.apps.messaging", // Google Messages
|
||||
"com.waze", // Waze: the official app refuses Gearslip; the Morphe-patched one works (see patched())
|
||||
"com.waze", // Waze
|
||||
"com.generalmagic.magicearth", // Magic Earth: never gets past connecting
|
||||
)
|
||||
|
||||
fun isBroken(context: Context, packageName: String): Boolean =
|
||||
packageName in broken && !patched(context, packageName)
|
||||
|
||||
/**
|
||||
* Whether a tile starts unticked in the phone's list of apps to show, so the drawer opens
|
||||
* with only what works. Gearslip's own Messages screen works for any messenger, so a broken
|
||||
* app's messaging tile still shows, except Google Maps', which has no messages worth reading.
|
||||
*/
|
||||
fun startsHidden(context: Context, packageName: String, messaging: Boolean): Boolean =
|
||||
isBroken(context, packageName) && (!messaging || packageName in google)
|
||||
|
||||
/**
|
||||
* The certificate each broken app's own publisher signs it with. A copy signed with another
|
||||
* key has been patched to accept Gearslip, like Waze with the waze-gearslip Morphe patch, and
|
||||
|
||||
@@ -4,7 +4,6 @@ import app.seb3thehacker.gearslip.R
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.view.KeyEvent
|
||||
import android.graphics.Bitmap
|
||||
import android.media.MediaMetadata
|
||||
import android.media.Rating
|
||||
@@ -189,9 +188,6 @@ class CarMedia(private val context: Context) {
|
||||
/** The music app Gearslip is attached to, for the assistant to name; null when there's none. */
|
||||
val appLabel: String? get() = connecting?.label?.takeIf { controller != null }
|
||||
|
||||
/** Called once an app is connected and can be played, library or not. */
|
||||
var onReady: ((MediaApp) -> Unit)? = null
|
||||
|
||||
private val _phase = MutableStateFlow(Phase.IDLE)
|
||||
val phase: StateFlow<Phase> = _phase.asStateFlow()
|
||||
|
||||
@@ -282,14 +278,6 @@ class CarMedia(private val context: Context) {
|
||||
attachToSession(app)
|
||||
return
|
||||
}
|
||||
// Added by hand: no library at all. Wake the app with its Play key if it isn't running,
|
||||
// then wait for its session as after opening it.
|
||||
if (app.sessionOnly) {
|
||||
val running = hasSession(app)
|
||||
if (!running) pressPlay(app)
|
||||
if (running) attachToSession(app) else main.postDelayed({ attachToSession(app, launched = true) }, PLAY_WAKE_MS)
|
||||
return
|
||||
}
|
||||
val mb = MediaBrowser(context, app.component, object : MediaBrowser.ConnectionCallback() {
|
||||
override fun onConnected() = this@CarMedia.onConnected(app)
|
||||
override fun onConnectionFailed() {
|
||||
@@ -353,7 +341,7 @@ class CarMedia(private val context: Context) {
|
||||
GearslipLog.w("media: ${app.label} has no active session to control")
|
||||
// A session-only app (Spotify) just isn't running - say so and offer to open it,
|
||||
// rather than calling it unsupported.
|
||||
_rejection.value = if (app.sessionOnly || KnownApps.playerWorks(app.component.packageName)) {
|
||||
_rejection.value = if (KnownApps.playerWorks(app.component.packageName)) {
|
||||
Rejection.NEEDS_APP_RUNNING
|
||||
} else {
|
||||
Rejection.REFUSED
|
||||
@@ -366,7 +354,6 @@ class CarMedia(private val context: Context) {
|
||||
attach(session)
|
||||
_browse.value = BrowseState(loading = false, unavailable = true)
|
||||
_phase.value = Phase.READY
|
||||
onReady?.invoke(app)
|
||||
}
|
||||
|
||||
private fun onConnected(app: MediaApp) {
|
||||
@@ -379,7 +366,6 @@ class CarMedia(private val context: Context) {
|
||||
mb.extras?.let { childStyles[mb.root] = it.getInt(STYLE_BROWSABLE, 0) to it.getInt(STYLE_PLAYABLE, 0) }
|
||||
path.clear()
|
||||
_phase.value = Phase.READY
|
||||
onReady?.invoke(app)
|
||||
open(mb.root)
|
||||
}
|
||||
|
||||
@@ -432,9 +418,6 @@ class CarMedia(private val context: Context) {
|
||||
checkSearch()
|
||||
}
|
||||
|
||||
/** The package of the app Gearslip controls now, or null. */
|
||||
val packageName: String? get() = controller?.packageName
|
||||
|
||||
private var appResources: Pair<String, android.content.res.Resources?>? = null
|
||||
|
||||
/**
|
||||
@@ -843,33 +826,6 @@ class CarMedia(private val context: Context) {
|
||||
main.postDelayed({ attachToSession(app, launched = true) }, LAUNCH_WAIT_MS)
|
||||
}
|
||||
|
||||
private fun hasSession(app: MediaApp): Boolean = runCatching {
|
||||
context.getSystemService(MediaSessionManager::class.java)
|
||||
.getActiveSessions(ComponentName(context, GearslipNotificationListener::class.java))
|
||||
.any { it.packageName == app.component.packageName }
|
||||
}.getOrDefault(false)
|
||||
|
||||
/**
|
||||
* Sends [app] a Play key press, as a headset button would. Most players start their service
|
||||
* for it, even when closed, and pick up where they left off; one that doesn't is offered to
|
||||
* open on the phone once the wait runs out.
|
||||
*/
|
||||
private fun pressPlay(app: MediaApp) {
|
||||
val pkg = app.component.packageName
|
||||
val receiver = context.packageManager.queryBroadcastReceivers(Intent(Intent.ACTION_MEDIA_BUTTON).setPackage(pkg), 0)
|
||||
.firstOrNull()?.activityInfo ?: run {
|
||||
GearslipLog.i("media: ${app.label} takes no media keys, so it has to be opened on the phone")
|
||||
return
|
||||
}
|
||||
GearslipLog.i("media: pressing Play for ${app.label}, which isn't running")
|
||||
for (action in listOf(KeyEvent.ACTION_DOWN, KeyEvent.ACTION_UP)) {
|
||||
val press = Intent(Intent.ACTION_MEDIA_BUTTON)
|
||||
.setClassName(pkg, receiver.name)
|
||||
.putExtra(Intent.EXTRA_KEY_EVENT, KeyEvent(action, KeyEvent.KEYCODE_MEDIA_PLAY))
|
||||
runCatching { context.sendBroadcast(press) }.onFailure { GearslipLog.w("media: Play for ${app.label} failed: ${it.message}") }
|
||||
}
|
||||
}
|
||||
|
||||
/** Brings [app] to the foreground on the phone so it publishes its session. */
|
||||
private fun openApp(app: MediaApp): Boolean {
|
||||
val intent = context.packageManager.getLaunchIntentForPackage(app.component.packageName) ?: return false
|
||||
@@ -889,8 +845,6 @@ class CarMedia(private val context: Context) {
|
||||
const val SESSION_RETRY_MS = 500L
|
||||
/** After opening a cold app, how long to wait before the first look for its session. */
|
||||
const val LAUNCH_WAIT_MS = 1_500L
|
||||
/** How long a player woken by its Play key gets before its session is looked for. */
|
||||
const val PLAY_WAKE_MS = 1_000L
|
||||
/** Retries once the app has been opened: a cold start can take a few seconds to publish. */
|
||||
const val SESSION_RETRIES_LAUNCHED = 16
|
||||
/** How long an app gets to start playing a search before Gearslip says it didn't. */
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.content.Context
|
||||
import android.util.Base64
|
||||
import android.util.Xml
|
||||
import app.seb3thehacker.gearslip.GearslipLog
|
||||
import app.seb3thehacker.gearslip.car.CarSettings
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
@@ -47,7 +48,8 @@ class LyricsResult(val lines: List<LyricLine>, val synced: Boolean, val source:
|
||||
*
|
||||
* Media apps almost never publish lyrics in their session metadata, so these are the only general
|
||||
* source. Each lookup sends the track's title, artist, album and length to these services, which
|
||||
* is why it happens only when the driver opens the lyrics view, never in the background.
|
||||
* is why it happens only when the driver opens the lyrics view, never in the background. With
|
||||
* [CarSettings.moreLyrics] off, only LRCLIB is asked.
|
||||
*
|
||||
* Results are cached twice over: in memory for the life of the process, and on disk under the
|
||||
* cache directory so a song looked up on a past drive doesn't need the network again on this one.
|
||||
@@ -77,12 +79,13 @@ object Lyrics {
|
||||
private class Source(val name: String, val best: Int, val lookup: () -> LyricsResult?)
|
||||
|
||||
private fun sources(title: String, artist: String, album: String, durationMs: Long): List<Source> {
|
||||
val more = CarSettings.moreLyrics.value
|
||||
val seconds = (durationMs / 1000).toInt()
|
||||
return listOf(
|
||||
Source("BetterLyrics", BEST) { betterLyrics(title, artist, album, seconds) },
|
||||
Source("LRCLIB", 2) { exact(title, artist, album, durationMs) ?: search(title, artist) },
|
||||
Source("KuGou", 2) { kuGou(title, artist, album, seconds) },
|
||||
)
|
||||
return buildList {
|
||||
if (more) add(Source("BetterLyrics", BEST) { betterLyrics(title, artist, album, seconds) })
|
||||
add(Source("LRCLIB", 2) { exact(title, artist, album, durationMs) ?: search(title, artist) })
|
||||
if (more) add(Source("KuGou", 2) { kuGou(title, artist, album, seconds) })
|
||||
}
|
||||
}
|
||||
|
||||
private fun key(title: String, artist: String, album: String, durationMs: Long) = "$title|$artist|$album|$durationMs"
|
||||
@@ -90,7 +93,8 @@ object Lyrics {
|
||||
suspend fun find(context: Context, title: String, artist: String, album: String, durationMs: Long): LyricsResult? {
|
||||
if (title.isBlank()) return null
|
||||
val key = key(title, artist, album, durationMs)
|
||||
val memoryKey = key
|
||||
// A miss with only LRCLIB isn't a miss once more sources are on, so the mode is part of it.
|
||||
val memoryKey = "${CarSettings.moreLyrics.value}|$key"
|
||||
synchronized(cache) { if (cache.containsKey(memoryKey)) return cache[memoryKey] }
|
||||
|
||||
val fromDisk = withContext(Dispatchers.IO) { readDisk(context, key) }
|
||||
@@ -125,7 +129,7 @@ object Lyrics {
|
||||
withContext(Dispatchers.IO) { writeDisk(context, key, better ?: current, System.currentTimeMillis()) }
|
||||
if (better == null) return null
|
||||
GearslipLog.i("lyrics: replaced ${current.source.ifEmpty { "cached" }} lyrics with ${better.source}'s")
|
||||
synchronized(cache) { cache[key] = better }
|
||||
synchronized(cache) { cache["${CarSettings.moreLyrics.value}|$key"] = better }
|
||||
return better
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
package app.seb3thehacker.gearslip.media
|
||||
|
||||
import app.seb3thehacker.gearslip.host.CustomCarApps
|
||||
|
||||
import app.seb3thehacker.gearslip.host.AndroidAuto
|
||||
import app.seb3thehacker.gearslip.host.CarDescriptor
|
||||
import app.seb3thehacker.gearslip.host.KnownApps
|
||||
@@ -17,11 +15,6 @@ class MediaApp(
|
||||
val label: String,
|
||||
/** Whose audio to capture: playback capture is scoped to one uid so nothing else leaks in. */
|
||||
val uid: Int,
|
||||
/**
|
||||
* Added by the driver rather than found (issue #84): there's no library to connect to, so
|
||||
* the player goes straight to the app's playback session, as it does for Spotify.
|
||||
*/
|
||||
val sessionOnly: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -40,6 +33,7 @@ object MediaCatalog {
|
||||
.asSequence()
|
||||
.filter { it.serviceInfo.packageName != context.packageName }
|
||||
.filterNot { AndroidAuto.matches(it.serviceInfo.packageName, it.loadLabel(manager).toString()) }
|
||||
.filterNot { KnownApps.isHidden(it.serviceInfo.packageName) }
|
||||
.filter { declaresMedia(manager, it.serviceInfo) }
|
||||
.map {
|
||||
val info = it.serviceInfo
|
||||
@@ -50,29 +44,10 @@ object MediaCatalog {
|
||||
)
|
||||
}
|
||||
.distinctBy { it.component.packageName }
|
||||
.toList()
|
||||
.let { found -> found + added(context, found.map { it.component.packageName }.toSet()) }
|
||||
.sortedBy { it.label.lowercase() }
|
||||
.toList()
|
||||
}
|
||||
|
||||
/** The music apps the driver added by hand, unless the app has since become a car app itself. */
|
||||
private fun added(context: Context, found: Set<String>): List<MediaApp> {
|
||||
val manager = context.packageManager
|
||||
return CustomCarApps.of(context, CustomCarApps.Kind.MEDIA).filter { it.packageName !in found }.mapNotNull { custom ->
|
||||
val info = runCatching { manager.getApplicationInfo(custom.packageName, 0) }.getOrNull() ?: return@mapNotNull null
|
||||
MediaApp(
|
||||
// No service to bind: the class name only keeps the component unique and readable.
|
||||
component = ComponentName(custom.packageName, SESSION_ONLY_CLASS),
|
||||
label = info.loadLabel(manager).toString(),
|
||||
uid = info.uid,
|
||||
sessionOnly = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** What an added app's component names, since it has no browser service. */
|
||||
const val SESSION_ONLY_CLASS = "gearslip.session-only"
|
||||
|
||||
/** A descriptor that can't be read keeps the app, rather than dropping a real player over an odd manifest. */
|
||||
private fun declaresMedia(manager: PackageManager, service: android.content.pm.ServiceInfo): Boolean {
|
||||
if (CarDescriptor.meta(manager, service.packageName, service) == null) return false
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
package app.seb3thehacker.gearslip.notify
|
||||
|
||||
import app.seb3thehacker.gearslip.host.CustomCarApps
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import app.seb3thehacker.gearslip.host.AndroidAuto
|
||||
@@ -27,7 +25,7 @@ object MessagingCatalog {
|
||||
.map { it.activityInfo.packageName to it.loadLabel(manager).toString() }
|
||||
.distinctBy { it.first }
|
||||
.filter { (pkg, _) -> pkg != context.packageName }
|
||||
.filterNot { (pkg, label) -> AndroidAuto.matches(pkg, label) }
|
||||
.filterNot { (pkg, label) -> AndroidAuto.matches(pkg, label) || KnownApps.isHidden(pkg) }
|
||||
.filter { (pkg, _) -> CarDescriptor.uses(manager, pkg)?.contains("notification") == true }
|
||||
.map { (pkg, _) ->
|
||||
val label = runCatching {
|
||||
@@ -35,17 +33,7 @@ object MessagingCatalog {
|
||||
}.getOrDefault(pkg)
|
||||
MessagingApp(pkg, label)
|
||||
}
|
||||
.toList()
|
||||
.let { found -> found + added(context, found.map { it.packageName }.toSet()) }
|
||||
.sortedBy { it.label.lowercase() }
|
||||
}
|
||||
|
||||
/** The messengers the driver added by hand (issue #84); their notifications are all it takes. */
|
||||
private fun added(context: Context, found: Set<String>): List<MessagingApp> {
|
||||
val manager = context.packageManager
|
||||
return CustomCarApps.of(context, CustomCarApps.Kind.MESSAGING).filter { it.packageName !in found }.mapNotNull { custom ->
|
||||
val info = runCatching { manager.getApplicationInfo(custom.packageName, 0) }.getOrNull() ?: return@mapNotNull null
|
||||
MessagingApp(custom.packageName, info.loadLabel(manager).toString())
|
||||
}
|
||||
.toList()
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import androidx.compose.foundation.clickable
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.Build
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.Favorite
|
||||
@@ -48,7 +47,6 @@ internal object GearslipLinks {
|
||||
const val MATRIX = "https://matrix.to/#/!mf0_mgBd5KqKpc9FfX_wHp1jFCcwAtPrndIn_oGIyMk?via=matrix.org"
|
||||
const val SOURCE = "https://github.com/twinksoftware/gearslip"
|
||||
const val CARS = "$SOURCE/blob/main/CARS.md"
|
||||
const val PRIVACY = "$SOURCE/blob/main/PRIVACY.md"
|
||||
const val TRANSLATE = "https://hosted.weblate.org/engage/gearslip/"
|
||||
|
||||
/** This version's release notes on GitHub; every release is tagged v plus its version name. */
|
||||
@@ -197,14 +195,6 @@ internal fun AboutLinkRows() {
|
||||
trailing = arrow,
|
||||
)
|
||||
SettingsDivider()
|
||||
SettingsRow(
|
||||
stringResource(R.string.privacy_policy),
|
||||
Modifier.clickable { GearslipLinks.open(context, GearslipLinks.PRIVACY) },
|
||||
subtitle = stringResource(R.string.privacy_policy_sub),
|
||||
icon = Icons.Filled.Lock,
|
||||
trailing = arrow,
|
||||
)
|
||||
SettingsDivider()
|
||||
SettingsRow(
|
||||
stringResource(R.string.about_voice_credits),
|
||||
subtitle = stringResource(R.string.about_voice_credits_sub),
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
package app.seb3thehacker.gearslip.ui
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
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.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import app.seb3thehacker.gearslip.R
|
||||
|
||||
/** One app as its page shows it. [url] is where to get it; apps found by their flag have none. */
|
||||
class PageApp(val name: String, val packageName: String, val url: String?, val about: String?)
|
||||
|
||||
/**
|
||||
* One app's page, over whatever opened it: its icon, its own sentence, the Works with Gearslip
|
||||
* mark when it declares the flag, and one button, to open it or to get it.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AppPage(app: PageApp, onDismiss: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val icon = remember(app.packageName) { runCatching { context.packageManager.getApplicationIcon(app.packageName) }.getOrNull() }
|
||||
val declares = remember(app.packageName) { WorksWith.declares(context.packageManager, app.packageName) }
|
||||
val about = remember(app.packageName) { app.about ?: WorksWith.about(context.packageManager, app.packageName) }
|
||||
val installed = icon != null
|
||||
Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false)) {
|
||||
Scaffold(
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onDismiss) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(horizontal = 24.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Box(
|
||||
Modifier.size(96.dp).clip(CircleShape).background(MaterialTheme.colorScheme.surfaceContainerHigh),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (icon != null) {
|
||||
Image(icon.toBitmap(192, 192).asImageBitmap(), contentDescription = null, Modifier.fillMaxSize())
|
||||
} else {
|
||||
Text(app.name.take(1), style = MaterialTheme.typography.headlineLarge, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text(app.name, style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.Center)
|
||||
if (declares) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
WorksWithMark()
|
||||
}
|
||||
if (about != null) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text(about, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center)
|
||||
}
|
||||
Spacer(Modifier.height(24.dp))
|
||||
if (installed) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Filled.CheckCircle, contentDescription = null, tint = INSTALLED_GREEN, modifier = Modifier.size(18.dp))
|
||||
Text(
|
||||
stringResource(R.string.recommended_installed),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = INSTALLED_GREEN,
|
||||
modifier = Modifier.padding(start = 6.dp),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
stringResource(R.string.app_page_on_car),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
val launch = remember(app.packageName) { context.packageManager.getLaunchIntentForPackage(app.packageName) }
|
||||
if (launch != null) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Button(onClick = { runCatching { context.startActivity(launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) } }, Modifier.fillMaxWidth()) {
|
||||
Text(stringResource(R.string.app_page_open, app.name))
|
||||
}
|
||||
}
|
||||
if (app.url != null) {
|
||||
TextButton(onClick = { openPage(context, app.url) }) { Text(stringResource(R.string.app_page_github)) }
|
||||
}
|
||||
} else if (app.url != null) {
|
||||
Text(
|
||||
stringResource(R.string.app_page_get_hint),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Button(onClick = { openPage(context, app.url) }, Modifier.fillMaxWidth()) {
|
||||
Text(stringResource(R.string.app_page_get, app.name))
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The Works with Gearslip mark: the logo's gear and the words, on a tinted pill. */
|
||||
@Composable
|
||||
fun WorksWithMark(modifier: Modifier = Modifier) {
|
||||
Row(
|
||||
modifier
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(MaterialTheme.colorScheme.primaryContainer)
|
||||
.padding(start = 8.dp, end = 10.dp, top = 3.dp, bottom = 3.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(5.dp),
|
||||
) {
|
||||
Icon(painterResource(R.drawable.works_with_gear), contentDescription = null, tint = MaterialTheme.colorScheme.onPrimaryContainer, modifier = Modifier.size(14.dp))
|
||||
Text(stringResource(R.string.works_with_gearslip), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onPrimaryContainer)
|
||||
}
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
package app.seb3thehacker.gearslip.ui
|
||||
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.provider.Settings
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
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.unit.dp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.LifecycleEventEffect
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import app.seb3thehacker.gearslip.car.CarBluetooth
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Asks for the phone's Bluetooth address on Home, once per car, after a car paired over
|
||||
* Bluetooth connected without it. Android keeps the address from apps, so the card opens About
|
||||
* phone for the driver to copy it, and when they come back offers what's on the clipboard.
|
||||
*/
|
||||
@Composable
|
||||
internal fun BluetoothAddressCard(modifier: Modifier = Modifier) {
|
||||
val context = LocalContext.current
|
||||
val car by CarBluetooth.addressWanted.collectAsState()
|
||||
// Off to About phone; on the way back, the clipboard is looked at once.
|
||||
var waiting by rememberSaveable { mutableStateOf(false) }
|
||||
var found by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var typing by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
|
||||
if (!waiting) return@LifecycleEventEffect
|
||||
waiting = false
|
||||
// Android hands an app the clipboard only once its window has focus, a moment after resume.
|
||||
scope.launch {
|
||||
delay(CLIPBOARD_WAIT_MS)
|
||||
val copied = copiedAddress(context)
|
||||
if (copied != null) found = copied else typing = ""
|
||||
}
|
||||
}
|
||||
|
||||
found?.let { address ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { found = null },
|
||||
title = { Text(stringResource(R.string.bt_address_use_title)) },
|
||||
text = {
|
||||
Column {
|
||||
Text(address, style = MaterialTheme.typography.headlineSmall, modifier = Modifier.padding(bottom = 12.dp))
|
||||
Text(stringResource(R.string.bt_address_use_body))
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { CarBluetooth.setTypedAddress(context, address); found = null }) { Text(stringResource(R.string.save)) }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = { typing = address; found = null }) { Text(stringResource(R.string.bt_address_type)) } },
|
||||
)
|
||||
}
|
||||
typing?.let { initial ->
|
||||
AddressDialog(initial, onDismiss = { typing = null }) { typed ->
|
||||
CarBluetooth.setTypedAddress(context, typed).also { if (it) typing = null }
|
||||
}
|
||||
}
|
||||
|
||||
val name = car ?: return
|
||||
if (CarBluetooth.typedAddress(context) != null) return
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
Column(modifier) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Surface(
|
||||
color = scheme.secondaryContainer,
|
||||
contentColor = scheme.onSecondaryContainer,
|
||||
shape = MaterialTheme.shapes.extraLarge,
|
||||
) {
|
||||
Column(Modifier.fillMaxWidth().padding(20.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Filled.Info, contentDescription = null)
|
||||
Text(
|
||||
stringResource(R.string.bt_address_card_title, name),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.padding(start = 12.dp),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
stringResource(R.string.bt_address_card_body),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(top = 12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
|
||||
) {
|
||||
TextButton(onClick = { CarBluetooth.addressAsked(context) }) { Text(stringResource(R.string.bt_address_not_now)) }
|
||||
Button(onClick = {
|
||||
waiting = true
|
||||
runCatching {
|
||||
context.startActivity(Intent(Settings.ACTION_DEVICE_INFO_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
}.onFailure { waiting = false; typing = "" }
|
||||
}) { Text(stringResource(R.string.bt_address_open)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A Bluetooth address on the clipboard, tidied up; null when there's none. */
|
||||
private fun copiedAddress(context: Context): String? {
|
||||
val clip = runCatching { context.getSystemService(ClipboardManager::class.java)?.primaryClip }.getOrNull() ?: return null
|
||||
if (clip.itemCount == 0) return null
|
||||
val text = clip.getItemAt(0).coerceToText(context)?.toString() ?: return null
|
||||
return ADDRESS.find(text)?.value?.uppercase()?.replace('-', ':')
|
||||
}
|
||||
|
||||
private val ADDRESS = Regex("""[0-9A-Fa-f]{2}([:-][0-9A-Fa-f]{2}){5}""")
|
||||
private const val CLIPBOARD_WAIT_MS = 400L
|
||||
@@ -1,264 +0,0 @@
|
||||
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"
|
||||
}
|
||||
@@ -226,6 +226,11 @@ private fun Fullscreen(
|
||||
onDispose { insets.show(WindowInsetsCompat.Type.systemBars()) }
|
||||
}
|
||||
|
||||
// Turning the phone back upright is what normally leaves fullscreen; back still leaves the
|
||||
// preview screen entirely rather than doing nothing, in case the phone's own rotation lock
|
||||
// is on and turning it sideways/back isn't an option.
|
||||
BackHandler(onBack = onBack)
|
||||
|
||||
androidx.compose.foundation.layout.BoxWithConstraints(
|
||||
Modifier.fillMaxSize().background(Color.Black),
|
||||
contentAlignment = Alignment.Center,
|
||||
@@ -244,14 +249,6 @@ private fun Fullscreen(
|
||||
maxHeight - frameHeight >= room * 2 -> Alignment.TopStart
|
||||
else -> null
|
||||
}
|
||||
// Back steps back inside the car UI, as it does in a car, so one press no longer ends
|
||||
// the preview and the drive's route with it. Leaving is the button beside the frame or
|
||||
// turning the phone upright. A frame with no room for that button keeps back as the
|
||||
// way out, in case the phone's rotation lock is on.
|
||||
BackHandler {
|
||||
if (corner == null) onBack()
|
||||
else app.seb3thehacker.gearslip.car.CarKeys.onKey(android.view.KeyEvent.KEYCODE_BACK, down = true, longPress = false)
|
||||
}
|
||||
if (corner != null) {
|
||||
FilledTonalIconButton(
|
||||
onClick = onBack,
|
||||
|
||||
@@ -72,9 +72,8 @@ fun GearslipApp(onDisconnect: () -> Unit, onRequestCallScreening: () -> Unit) {
|
||||
var carGroup by rememberSaveable { mutableStateOf(CarGroup.SCREEN) }
|
||||
BackHandler(enabled = screen != Screen.HOME) {
|
||||
screen = when (screen) {
|
||||
Screen.RECOMMENDED_APPS -> Screen.SETTINGS
|
||||
// The same place their own back arrows go: Home or Settings, whichever opened them.
|
||||
Screen.HELP, Screen.WHATS_NEW, Screen.LOGS, Screen.CAR_PREVIEW -> backTo
|
||||
Screen.RECOMMENDED_APPS, Screen.LOGS, Screen.CAR_PREVIEW -> Screen.SETTINGS
|
||||
Screen.HELP, Screen.WHATS_NEW -> backTo
|
||||
else -> Screen.HOME
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,8 +71,6 @@ private fun inTheCar(grapheneOs: Boolean, onRequestCallScreening: () -> Unit) =
|
||||
R.string.help_pair,
|
||||
R.string.help_pair_detail,
|
||||
),
|
||||
// A Renault Clio V wouldn't take Bluetooth audio when the cable went in first (#74).
|
||||
Step(R.string.help_bluetooth_first, R.string.help_bluetooth_first_detail),
|
||||
Step(
|
||||
R.string.help_answer,
|
||||
R.string.help_answer_detail,
|
||||
|
||||
@@ -21,17 +21,6 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import kotlinx.coroutines.launch
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.Icon
|
||||
@@ -70,51 +59,14 @@ 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),
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun sizeChoices() = listOf(1f to stringResource(R.string.choice_size_normal), 1.25f to stringResource(R.string.choice_size_large), 1.5f to stringResource(R.string.choice_size_xlarge))
|
||||
|
||||
/** Marks the menu's Custom entry, which opens [CustomDensityDialog] instead of choosing. */
|
||||
private const val DENSITY_CUSTOM = -1
|
||||
|
||||
/** The presets, a typed-in value when that's what's set, and Custom to type one. */
|
||||
@Composable
|
||||
private fun densityChoices(current: Int): List<Pair<Int, String>> {
|
||||
val values = CarSettings.DENSITIES.let { if (current != CarSettings.DENSITY_FROM_CAR && current !in it) (it + current).sorted() else it }
|
||||
return listOf(CarSettings.DENSITY_FROM_CAR to stringResource(R.string.choice_density_car)) +
|
||||
values.map { it to stringResource(R.string.choice_density_dpi, it) } +
|
||||
(DENSITY_CUSTOM to stringResource(R.string.choice_density_custom))
|
||||
}
|
||||
|
||||
/** Types in any density in [CarSettings.CUSTOM_DENSITIES] (issue #82). */
|
||||
@Composable
|
||||
private fun CustomDensityDialog(current: Int, onDismiss: () -> Unit, onSave: (Int) -> Unit) {
|
||||
var text by remember { mutableStateOf(current.takeIf { it != CarSettings.DENSITY_FROM_CAR }?.toString().orEmpty()) }
|
||||
val value = text.toIntOrNull()?.takeIf { it in CarSettings.CUSTOM_DENSITIES }
|
||||
val range = CarSettings.CUSTOM_DENSITIES
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.density_custom_title)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(stringResource(R.string.density_custom_note, range.first, range.last))
|
||||
OutlinedTextField(
|
||||
text, { text = it.filter(Char::isDigit).take(3) },
|
||||
label = { Text(stringResource(R.string.density_custom_label)) },
|
||||
singleLine = true,
|
||||
isError = text.isNotEmpty() && value == null,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = { value?.let(onSave) }, enabled = value != null) { Text(stringResource(R.string.save)) } },
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } },
|
||||
)
|
||||
}
|
||||
private fun densityChoices() = listOf(CarSettings.DENSITY_FROM_CAR to stringResource(R.string.choice_density_car)) + CarSettings.DENSITIES.map { it to stringResource(R.string.choice_density_dpi, it) }
|
||||
@Composable
|
||||
private fun themeChoices() = listOf(AppTheme.PHONE to stringResource(R.string.choice_theme_phone), AppTheme.LIGHT to stringResource(R.string.choice_theme_light), AppTheme.DARK to stringResource(R.string.choice_theme_dark))
|
||||
@Composable
|
||||
@@ -158,8 +110,6 @@ 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()
|
||||
@@ -189,11 +139,8 @@ 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(" · ")
|
||||
@@ -281,7 +228,7 @@ private fun GroupRows(group: CarGroup) {
|
||||
val autoplay by CarSettings.autoplay.collectAsState()
|
||||
val playerTitle by CarSettings.playerTitle.collectAsState()
|
||||
val playerLeft by CarSettings.playerLeft.collectAsState()
|
||||
val navMic by CarSettings.navMic.collectAsState()
|
||||
val moreLyrics by CarSettings.moreLyrics.collectAsState()
|
||||
val theme by CarSettings.appTheme.collectAsState()
|
||||
val uiTheme by CarSettings.uiTheme.collectAsState()
|
||||
val darkStyle by CarSettings.darkStyle.collectAsState()
|
||||
@@ -297,18 +244,7 @@ private fun GroupRows(group: CarGroup) {
|
||||
SettingsDivider()
|
||||
Choice(stringResource(R.string.car_size), sizeChoices(), scale, CarSettings::setScale)
|
||||
SettingsDivider()
|
||||
var customDensity by remember { mutableStateOf(false) }
|
||||
Choice(
|
||||
stringResource(R.string.car_density), densityChoices(density), density,
|
||||
{ if (it == DENSITY_CUSTOM) customDensity = true else CarSettings.setCarDensity(it) },
|
||||
note = stringResource(R.string.car_density_note),
|
||||
)
|
||||
if (customDensity) {
|
||||
CustomDensityDialog(density, onDismiss = { customDensity = false }) {
|
||||
CarSettings.setCarDensity(it)
|
||||
customDensity = false
|
||||
}
|
||||
}
|
||||
Choice(stringResource(R.string.car_density), densityChoices(), density, CarSettings::setCarDensity, note = stringResource(R.string.car_density_note))
|
||||
SettingsDivider()
|
||||
Choice(stringResource(R.string.car_app_theme), themeChoices(), theme, CarSettings::setAppTheme)
|
||||
SettingsDivider()
|
||||
@@ -325,13 +261,18 @@ private fun GroupRows(group: CarGroup) {
|
||||
CarGroup.NAV_BAR -> {
|
||||
Choice(stringResource(R.string.car_nav_bar), navBarChoices(), navBar, CarSettings::setNavBarMode)
|
||||
SettingsDivider()
|
||||
Toggle(stringResource(R.string.car_nav_mic), navMic, CarSettings::setNavMic, note = stringResource(R.string.car_nav_mic_note))
|
||||
SettingsDivider()
|
||||
Choice(stringResource(R.string.car_player_nav_bar), listOf(false to stringResource(R.string.car_skip_buttons), true to stringResource(R.string.car_song_title)), playerTitle, CarSettings::setPlayerTitle)
|
||||
SettingsDivider()
|
||||
Choice(stringResource(R.string.car_player_beside_map), listOf(false to stringResource(R.string.car_right), true to stringResource(R.string.car_left)), playerLeft, CarSettings::setPlayerLeft, note = stringResource(R.string.car_player_beside_map_note))
|
||||
Choice(stringResource(R.string.car_player_beside_map), listOf(false to stringResource(R.string.car_right), true to stringResource(R.string.car_left)), playerLeft, CarSettings::setPlayerLeft)
|
||||
SettingsDivider()
|
||||
Toggle(stringResource(R.string.car_autoplay), autoplay, CarSettings::setAutoplay, note = stringResource(R.string.car_autoplay_note))
|
||||
Toggle(stringResource(R.string.car_autoplay), autoplay, CarSettings::setAutoplay)
|
||||
SettingsDivider()
|
||||
Toggle(
|
||||
stringResource(R.string.car_more_lyrics),
|
||||
moreLyrics,
|
||||
CarSettings::setMoreLyrics,
|
||||
note = stringResource(R.string.car_more_lyrics_note),
|
||||
)
|
||||
}
|
||||
CarGroup.SOUND -> {
|
||||
Toggle(
|
||||
@@ -365,9 +306,10 @@ private fun GroupRows(group: CarGroup) {
|
||||
}
|
||||
Toggle(stringResource(R.string.car_warn_location), locationWarning, CarSettings::setLocationWarning)
|
||||
}
|
||||
CarGroup.BROWSER -> BrowserSettingsRows { WebPageRow() }
|
||||
CarGroup.MORE -> {
|
||||
Toggle(stringResource(R.string.car_experimental), experimental, CarSettings::setExperimentalFeaturesEnabled, note = stringResource(R.string.car_experimental_note))
|
||||
WebPageRow()
|
||||
SettingsDivider()
|
||||
Toggle(stringResource(R.string.car_experimental), experimental, CarSettings::setExperimentalFeaturesEnabled)
|
||||
if (experimental) {
|
||||
SettingsDivider()
|
||||
Toggle(
|
||||
@@ -405,131 +347,16 @@ private fun AppRows() {
|
||||
LaunchedEffect(Unit) { withContext(Dispatchers.IO) { LauncherCache.load(context, force = true) } }
|
||||
val rows = apps.orEmpty().mapNotNull { entry -> entry.componentId?.let { it to entry } }
|
||||
if (rows.isEmpty()) SettingsRow(stringResource(if (apps == null) R.string.car_apps_finding else R.string.car_apps_none))
|
||||
val custom by app.seb3thehacker.gearslip.host.CustomCarApps.state.collectAsState()
|
||||
val scope = androidx.compose.runtime.rememberCoroutineScope()
|
||||
var adding by remember { mutableStateOf(false) }
|
||||
fun rescan() = scope.launch { withContext(Dispatchers.IO) { LauncherCache.load(context, force = true) } }
|
||||
rows.forEachIndexed { i, (id, entry) ->
|
||||
val shown = id !in hidden
|
||||
val pkg = entry.media?.component?.packageName ?: entry.messaging?.packageName
|
||||
val added = pkg != null && custom.orEmpty().any { it.packageName == pkg }
|
||||
if (i > 0) SettingsDivider()
|
||||
SettingsRow(
|
||||
entry.label,
|
||||
Modifier.toggleable(shown, role = Role.Checkbox) { CarSettings.setAppHidden(id, !it) },
|
||||
subtitle = if (added) stringResource(R.string.car_apps_added_by_you) else null,
|
||||
leading = { entry.icon?.let { Image(it, contentDescription = null, Modifier.size(40.dp)) } },
|
||||
trailing = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (added && pkg != null) {
|
||||
androidx.compose.material3.IconButton({
|
||||
app.seb3thehacker.gearslip.host.CustomCarApps.remove(context, pkg)
|
||||
rescan()
|
||||
}) { Icon(androidx.compose.material.icons.Icons.Filled.Delete, stringResource(R.string.car_apps_remove, entry.label)) }
|
||||
}
|
||||
Checkbox(checked = shown, onCheckedChange = null)
|
||||
}
|
||||
},
|
||||
trailing = { Checkbox(checked = shown, onCheckedChange = null) },
|
||||
)
|
||||
}
|
||||
if (apps != null) {
|
||||
SettingsDivider()
|
||||
SettingsRow(
|
||||
stringResource(R.string.car_apps_add),
|
||||
Modifier.clickable { adding = true },
|
||||
subtitle = stringResource(R.string.car_apps_add_note),
|
||||
icon = androidx.compose.material.icons.Icons.Filled.Add,
|
||||
)
|
||||
}
|
||||
if (adding) {
|
||||
AddCarAppDialog(
|
||||
listed = rows.mapNotNull { (_, e) -> e.media?.component?.packageName ?: e.messaging?.packageName ?: e.template?.component?.packageName }.toSet(),
|
||||
onDismiss = { adding = false },
|
||||
) { pkg, kind ->
|
||||
app.seb3thehacker.gearslip.host.CustomCarApps.add(context, pkg, kind)
|
||||
adding = false
|
||||
rescan()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks an installed app the car doesn't list, then whether it's music or messages (issue #84).
|
||||
* Music plays through Gearslip's player from the app's playback session; messages show from its
|
||||
* notifications. Neither needs the app to support Android Auto.
|
||||
*/
|
||||
@Composable
|
||||
private fun AddCarAppDialog(
|
||||
listed: Set<String>,
|
||||
onDismiss: () -> Unit,
|
||||
onAdd: (String, app.seb3thehacker.gearslip.host.CustomCarApps.Kind) -> Unit,
|
||||
) {
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
var picked by remember { mutableStateOf<Pair<String, String>?>(null) }
|
||||
val candidates by androidx.compose.runtime.produceState<List<Triple<String, String, androidx.compose.ui.graphics.ImageBitmap?>>?>(null) {
|
||||
value = withContext(Dispatchers.IO) {
|
||||
val manager = context.packageManager
|
||||
manager.queryIntentActivities(android.content.Intent(android.content.Intent.ACTION_MAIN).addCategory(android.content.Intent.CATEGORY_LAUNCHER), 0)
|
||||
.map { it.activityInfo.packageName to it.loadLabel(manager).toString() }
|
||||
.distinctBy { it.first }
|
||||
.filter { (pkg, _) -> pkg != context.packageName && pkg !in listed }
|
||||
.sortedBy { it.second.lowercase() }
|
||||
.map { (pkg, label) ->
|
||||
val icon = runCatching { manager.getApplicationIcon(pkg).toBitmap(96, 96).asImageBitmap() }.getOrNull()
|
||||
Triple(pkg, label, icon)
|
||||
}
|
||||
}
|
||||
}
|
||||
androidx.compose.material3.AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(picked?.let { stringResource(R.string.car_apps_add_as, it.second) } ?: stringResource(R.string.car_apps_add_pick)) },
|
||||
text = {
|
||||
val choice = picked
|
||||
if (choice == null) {
|
||||
val list = candidates
|
||||
if (list == null) {
|
||||
Text(stringResource(R.string.car_apps_finding))
|
||||
} else {
|
||||
androidx.compose.foundation.lazy.LazyColumn(Modifier.heightIn(max = 420.dp)) {
|
||||
items(list.size) { i ->
|
||||
val (pkg, label, icon) = list[i]
|
||||
Row(
|
||||
Modifier.fillMaxWidth().clickable { picked = pkg to label }.padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
icon?.let { Image(it, contentDescription = null, Modifier.size(36.dp)) }
|
||||
Text(label, style = MaterialTheme.typography.bodyLarge)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
KindRow(stringResource(R.string.car_apps_as_media), stringResource(R.string.car_apps_as_media_note)) {
|
||||
onAdd(choice.first, app.seb3thehacker.gearslip.host.CustomCarApps.Kind.MEDIA)
|
||||
}
|
||||
KindRow(stringResource(R.string.car_apps_as_messaging), stringResource(R.string.car_apps_as_messaging_note)) {
|
||||
onAdd(choice.first, app.seb3thehacker.gearslip.host.CustomCarApps.Kind.MESSAGING)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {},
|
||||
dismissButton = {
|
||||
androidx.compose.material3.TextButton({ if (picked != null) picked = null else onDismiss() }) {
|
||||
Text(stringResource(if (picked != null) R.string.back else R.string.cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun KindRow(title: String, note: String, onClick: () -> Unit) {
|
||||
Column(Modifier.fillMaxWidth().clickable(onClick = onClick).padding(vertical = 12.dp)) {
|
||||
Text(title, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.primary)
|
||||
Text(note, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T> List<Pair<T, String>>.label(value: T): String = firstOrNull { it.first == value }?.second.orEmpty()
|
||||
|
||||
@@ -108,7 +108,6 @@ fun HomeScreen(
|
||||
Spacer(Modifier.height(16.dp))
|
||||
StatusCard(status)
|
||||
if (live) BluetoothMissingCard()
|
||||
BluetoothAddressCard()
|
||||
VoiceDownloadCard()
|
||||
// The first drive that worked asks once to share it, right under the result.
|
||||
val car = workedCar
|
||||
|
||||
@@ -9,7 +9,6 @@ import android.graphics.drawable.Drawable
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -35,10 +34,7 @@ import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
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
|
||||
@@ -109,60 +105,32 @@ fun RecommendedAppsScreen(onBack: () -> Unit) {
|
||||
internal const val SHOW_RECOMMENDED_APPS = false
|
||||
|
||||
/** The same green as the launcher's "works" check. */
|
||||
internal val INSTALLED_GREEN = Color(0xFF43A047)
|
||||
private val INSTALLED_GREEN = Color(0xFF43A047)
|
||||
|
||||
/**
|
||||
* The apps by section, each with a GitHub button; tapping the rest of a row opens the app's page.
|
||||
* Installed ones say so in green. Installed apps that declare the Works with Gearslip flag come
|
||||
* first, under their own heading, unless the list already has them.
|
||||
*/
|
||||
/** The apps by section, each with a GitHub button. Installed ones say so in green. */
|
||||
@Composable
|
||||
internal fun RecommendedAppsList(modifier: Modifier = Modifier) {
|
||||
val context = LocalContext.current
|
||||
var page by remember { mutableStateOf<PageApp?>(null) }
|
||||
val declared = remember {
|
||||
val listed = recommended.flatMap { (_, apps) -> apps.map { it.packageName } }.toSet()
|
||||
WorksWith.installed(context).filter { it.packageName !in listed }
|
||||
}
|
||||
Column(modifier, verticalArrangement = Arrangement.spacedBy(20.dp)) {
|
||||
if (declared.isNotEmpty()) {
|
||||
Section(stringResource(R.string.works_with_gearslip)) {
|
||||
for (app in declared) {
|
||||
AppRow(app.label, app.packageName, app.about, onPage = { page = PageApp(app.label, app.packageName, null, app.about) }, onGitHub = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
for ((section, apps) in recommended) {
|
||||
Section(stringResource(section)) {
|
||||
for (app in apps) {
|
||||
val text = stringResource(app.about)
|
||||
AppRow(app.name, app.packageName, text, onPage = { page = PageApp(app.name, app.packageName, app.url, text) }, onGitHub = { openPage(context, app.url) })
|
||||
}
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(
|
||||
stringResource(section),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
for (app in apps) AppRow(app) { openPage(context, app.url) }
|
||||
}
|
||||
}
|
||||
}
|
||||
page?.let { AppPage(it, onDismiss = { page = null }) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Section(title: String, content: @Composable () -> Unit) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AppRow(name: String, packageName: String, about: String?, onPage: () -> Unit, onGitHub: (() -> Unit)?) {
|
||||
private fun AppRow(app: RecommendedApp, onOpen: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val icon = remember(packageName) { installedIcon(context, packageName) }
|
||||
val declares = remember(packageName) { WorksWith.declares(context.packageManager, packageName) }
|
||||
Row(Modifier.fillMaxWidth().clip(MaterialTheme.shapes.medium).clickable(onClick = onPage), verticalAlignment = Alignment.CenterVertically) {
|
||||
val icon = remember(app.packageName) { installedIcon(context, app.packageName) }
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
Modifier.size(44.dp).clip(CircleShape).background(MaterialTheme.colorScheme.surfaceContainerHigh),
|
||||
contentAlignment = Alignment.Center,
|
||||
@@ -170,15 +138,14 @@ private fun AppRow(name: String, packageName: String, about: String?, onPage: ()
|
||||
if (icon != null) {
|
||||
Image(icon.toBitmap(96, 96).asImageBitmap(), contentDescription = null, Modifier.fillMaxSize())
|
||||
} else {
|
||||
Text(name.take(1), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface)
|
||||
Text(app.name.take(1), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface)
|
||||
}
|
||||
}
|
||||
Column(Modifier.weight(1f).padding(horizontal = 14.dp)) {
|
||||
// Colours set outright: the setup guide draws this on its own background, with no
|
||||
// Surface to hand down a content colour, and unset text came out dark on dark.
|
||||
Text(name, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface)
|
||||
if (about != null) Text(about, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
if (declares) WorksWithMark(Modifier.padding(top = 4.dp))
|
||||
Text(app.name, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface)
|
||||
Text(stringResource(app.about), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
if (icon != null) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 4.dp)) {
|
||||
Icon(Icons.Filled.CheckCircle, contentDescription = null, tint = INSTALLED_GREEN, modifier = Modifier.size(16.dp))
|
||||
@@ -191,14 +158,14 @@ private fun AppRow(name: String, packageName: String, about: String?, onPage: ()
|
||||
}
|
||||
}
|
||||
}
|
||||
if (onGitHub != null) OutlinedButton(onClick = onGitHub) { Text("GitHub") }
|
||||
OutlinedButton(onClick = onOpen) { Text("GitHub") }
|
||||
}
|
||||
}
|
||||
|
||||
private fun installedIcon(context: Context, packageName: String): Drawable? =
|
||||
runCatching { context.packageManager.getApplicationIcon(packageName) }.getOrNull()
|
||||
|
||||
internal fun openPage(context: Context, url: String) {
|
||||
private fun openPage(context: Context, url: String) {
|
||||
runCatching {
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
}
|
||||
|
||||
@@ -275,7 +275,7 @@ private fun ConnectionSection() {
|
||||
|
||||
/** The phone's Bluetooth address, typed in by hand: Android doesn't let apps read it. */
|
||||
@Composable
|
||||
internal fun AddressDialog(initial: String, onDismiss: () -> Unit, onSave: (String) -> Boolean) {
|
||||
private fun AddressDialog(initial: String, onDismiss: () -> Unit, onSave: (String) -> Boolean) {
|
||||
var text by remember { mutableStateOf(initial) }
|
||||
var invalid by remember { mutableStateOf(false) }
|
||||
AlertDialog(
|
||||
@@ -309,16 +309,6 @@ private fun DebugSection() {
|
||||
icon = Icons.AutoMirrored.Filled.Send,
|
||||
)
|
||||
SettingsDivider()
|
||||
SettingsRow(
|
||||
"Ask for the Bluetooth address",
|
||||
Modifier.clickable {
|
||||
CarBluetooth.setTypedAddress(context, "")
|
||||
CarBluetooth.askForAddressNow(context)
|
||||
},
|
||||
subtitle = "Clears the saved address and shows Home's card",
|
||||
icon = Icons.Filled.Info,
|
||||
)
|
||||
SettingsDivider()
|
||||
val badge by CarSettings.debugBadge.collectAsState()
|
||||
SettingsRow(
|
||||
"Debug badge on the car screen",
|
||||
|
||||
@@ -171,7 +171,6 @@ private fun UpdateDialog(
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var auto by remember { mutableStateOf(UpdateChecker.autoCheckEnabled(context)) }
|
||||
var silent by remember { mutableStateOf(UpdateChecker.silentEnabled(context)) }
|
||||
val actionLabels = listOf(R.string.update_download, R.string.update_install, R.string.update_try_again, R.string.update_check_now).map { stringResource(it) }
|
||||
val action: Pair<String, () -> Unit>? = when (state) {
|
||||
State.Checking, is State.Downloading -> null
|
||||
@@ -231,29 +230,6 @@ private fun UpdateDialog(
|
||||
}
|
||||
Switch(checked = auto, onCheckedChange = null)
|
||||
}
|
||||
// Silent installs ride on the automatic check, so the switch only shows with it on.
|
||||
if (auto && !BuildConfig.DEBUG) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.toggleable(silent, role = Role.Switch) {
|
||||
silent = it
|
||||
UpdateChecker.setSilentEnabled(context, it)
|
||||
}
|
||||
.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(stringResource(R.string.update_silent), style = MaterialTheme.typography.bodyLarge)
|
||||
Text(
|
||||
stringResource(R.string.update_silent_sub),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Switch(checked = silent, onCheckedChange = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
|
||||
@@ -21,7 +21,6 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.selection.toggleable
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||
@@ -139,14 +138,6 @@ internal fun ColumnScope.UsageStatsRows() {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Shown whether the notes are on or off, in setup and in Settings alike.
|
||||
SettingsDivider()
|
||||
SettingsRow(
|
||||
stringResource(R.string.privacy_policy),
|
||||
Modifier.clickable { GearslipLinks.open(context, GearslipLinks.PRIVACY) },
|
||||
subtitle = stringResource(R.string.privacy_policy_sub),
|
||||
icon = Icons.Filled.Lock,
|
||||
)
|
||||
}
|
||||
|
||||
/** A share choice: the label lines up with the rows' titles, the checkbox sits at the end. */
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
package app.seb3thehacker.gearslip.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Bundle
|
||||
|
||||
/**
|
||||
* Apps that say they work with Gearslip, by a line in their manifest's `<application>`:
|
||||
*
|
||||
* ```xml
|
||||
* <meta-data android:name="app.seb3thehacker.gearslip.works_with" android:value="true" />
|
||||
* <meta-data android:name="app.seb3thehacker.gearslip.about" android:resource="@string/gearslip_about" />
|
||||
* ```
|
||||
*
|
||||
* The second line is optional: one sentence for the app's page in Gearslip. Gearslip already sees
|
||||
* every app with a launcher icon (the manifest's MAIN/LAUNCHER query), so reading these needs
|
||||
* nothing more.
|
||||
*/
|
||||
object WorksWith {
|
||||
|
||||
const val FLAG = "app.seb3thehacker.gearslip.works_with"
|
||||
const val ABOUT = "app.seb3thehacker.gearslip.about"
|
||||
|
||||
class Declared(val packageName: String, val label: String, val about: String?)
|
||||
|
||||
private fun meta(manager: PackageManager, pkg: String): Bundle? =
|
||||
runCatching { manager.getApplicationInfo(pkg, PackageManager.GET_META_DATA).metaData }.getOrNull()
|
||||
|
||||
fun declares(manager: PackageManager, pkg: String): Boolean = meta(manager, pkg)?.getBoolean(FLAG) == true
|
||||
|
||||
/** The app's own sentence about itself, from a string resource or written in place. */
|
||||
fun about(manager: PackageManager, pkg: String): String? {
|
||||
val meta = meta(manager, pkg) ?: return null
|
||||
val res = meta.getInt(ABOUT)
|
||||
if (res != 0) return runCatching { manager.getResourcesForApplication(pkg).getString(res) }.getOrNull()
|
||||
return meta.getString(ABOUT)?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
/** Every installed app that declares the flag, by name. */
|
||||
fun installed(context: Context): List<Declared> {
|
||||
val manager = context.packageManager
|
||||
val launcher = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER)
|
||||
return manager.queryIntentActivities(launcher, 0)
|
||||
.map { it.activityInfo.packageName }
|
||||
.distinct()
|
||||
.filter { it != context.packageName && declares(manager, it) }
|
||||
.map { pkg ->
|
||||
val label = runCatching { manager.getApplicationInfo(pkg, 0).loadLabel(manager).toString() }.getOrDefault(pkg)
|
||||
Declared(pkg, label, about(manager, pkg))
|
||||
}
|
||||
.sortedBy { it.label.lowercase() }
|
||||
}
|
||||
}
|
||||
@@ -1,31 +1,25 @@
|
||||
package app.seb3thehacker.gearslip.update
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageInstaller
|
||||
import android.content.pm.PackageManager
|
||||
import android.hardware.usb.UsbManager
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.Uri
|
||||
import android.provider.Settings
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import app.seb3thehacker.gearslip.BuildConfig
|
||||
import app.seb3thehacker.gearslip.GearslipActivity
|
||||
import app.seb3thehacker.gearslip.GearslipLog
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import app.seb3thehacker.gearslip.SessionStatus
|
||||
import app.seb3thehacker.gearslip.notify.CarMotion
|
||||
import app.seb3thehacker.gearslip.notify.CarNotification
|
||||
import app.seb3thehacker.gearslip.notify.CarNotifications
|
||||
import app.seb3thehacker.gearslip.wireless.WirelessAuto
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@@ -48,10 +42,6 @@ import java.security.MessageDigest
|
||||
* Nothing runs in the background otherwise, and nothing else is sent: the request is the same one
|
||||
* a browser makes for the release page. Debug builds never check on their own.
|
||||
*
|
||||
* On Stable, a new version also installs by itself on Wi-Fi while no car is connected, without
|
||||
* Android asking, once Gearslip has installed itself before. Android allows that only to the app
|
||||
* that installed the current version, so the first update from a browser download still asks.
|
||||
*
|
||||
* The APK is checked against the size and SHA-256 GitHub lists for it, and Android refuses an
|
||||
* update signed with a different key, so a bad download can't replace the app.
|
||||
*/
|
||||
@@ -82,12 +72,6 @@ object UpdateChecker {
|
||||
private const val KEY_CHANNEL = "channel"
|
||||
private const val KEY_NOTIFIED = "notified_version"
|
||||
private const val KEY_CAR_NOTIFIED = "car_notified_version"
|
||||
private const val KEY_SILENT = "silent"
|
||||
/** The version installing without a prompt, so the new one can say it updated. */
|
||||
private const val KEY_SILENT_VERSION = "silent_version"
|
||||
/** A version Android wanted confirmed: it isn't tried quietly again, the card and notification take over. */
|
||||
private const val KEY_SILENT_REFUSED = "silent_refused"
|
||||
private const val UPDATED_NOTIFICATION_ID = 4101
|
||||
/** Lets the car screen settle after plug-in, so the card isn't lost behind the first frames. */
|
||||
private const val CAR_DELAY_MS = 8_000L
|
||||
private const val CHANNEL = "updates"
|
||||
@@ -113,13 +97,6 @@ object UpdateChecker {
|
||||
if (!on) NotificationManagerCompat.from(context).cancel(NOTIFICATION_ID)
|
||||
}
|
||||
|
||||
/** Whether a Stable update installs by itself on Wi-Fi with no car connected. On by default. */
|
||||
fun silentEnabled(context: Context): Boolean = prefs(context).getBoolean(KEY_SILENT, true)
|
||||
|
||||
fun setSilentEnabled(context: Context, on: Boolean) {
|
||||
prefs(context).edit().putBoolean(KEY_SILENT, on).apply()
|
||||
}
|
||||
|
||||
/** Which releases count as updates. Each channel also takes everything the one before it does. */
|
||||
enum class Channel { STABLE, BETA, ALPHA }
|
||||
|
||||
@@ -153,27 +130,10 @@ object UpdateChecker {
|
||||
install(context)
|
||||
return
|
||||
}
|
||||
// Opened for a car (plugged in, or wireless found it): installing now would close Gearslip
|
||||
// just as it connects.
|
||||
val forCar = (context as? Activity)?.intent?.action in
|
||||
setOf(UsbManager.ACTION_USB_ACCESSORY_ATTACHED, WirelessAuto.ACTION_CAR_NEARBY)
|
||||
if (current is State.Ready) {
|
||||
if (!forCar) installQuietly(context.applicationContext, current.release)
|
||||
return
|
||||
}
|
||||
if (current != State.Idle) return
|
||||
// A release found earlier still shows after Android closed the app.
|
||||
savedRelease(context)?.let { flow.value = State.Available(it) }
|
||||
val app = context.applicationContext
|
||||
if (dueForCheck(context)) {
|
||||
flow.value = State.Checking
|
||||
Thread({
|
||||
flow.value = checkNow(app)
|
||||
(flow.value as? State.Available)?.let { if (!forCar) installQuietly(app, it.release) }
|
||||
}, "gearslip-update").start()
|
||||
} else if (!forCar) {
|
||||
savedRelease(app)?.let { installQuietly(app, it) }
|
||||
}
|
||||
if (dueForCheck(context)) check(context)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -190,7 +150,6 @@ object UpdateChecker {
|
||||
flow.value = checkNow(app)
|
||||
}
|
||||
val release = foundRelease(app) ?: return@Thread
|
||||
if (installQuietly(app, release)) return@Thread
|
||||
if (prefs(app).getString(KEY_NOTIFIED, null) == release.version) return@Thread
|
||||
if (notify(app, release)) prefs(app).edit().putString(KEY_NOTIFIED, release.version).apply()
|
||||
}, "gearslip-update").start()
|
||||
@@ -291,34 +250,8 @@ object UpdateChecker {
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* Downloads and installs [release] without asking, when everything allows it: Stable, the
|
||||
* switch on, Wi-Fi, no car, and Android already letting Gearslip install. Returns false when it
|
||||
* didn't start, so the caller tells the driver the usual way.
|
||||
*/
|
||||
private fun installQuietly(app: Context, release: Release): Boolean {
|
||||
val ok = !BuildConfig.DEBUG && autoCheckEnabled(app) && silentEnabled(app) &&
|
||||
channel(app) == Channel.STABLE && !carConnected && onWifi(app) &&
|
||||
app.packageManager.canRequestPackageInstalls() &&
|
||||
prefs(app).getString(KEY_SILENT_REFUSED, null) != release.version
|
||||
if (!ok) return false
|
||||
GearslipLog.i("update: installing ${release.version} without asking")
|
||||
val current = flow.value
|
||||
if (current is State.Ready && current.release == release) install(app, quiet = true)
|
||||
else download(app, release, quiet = true)
|
||||
return true
|
||||
}
|
||||
|
||||
/** Not metered: Wi-Fi or ethernet, where 34 MB doesn't cost anyone. */
|
||||
private fun onWifi(context: Context): Boolean {
|
||||
val cm = context.getSystemService(ConnectivityManager::class.java) ?: return false
|
||||
val caps = cm.getNetworkCapabilities(cm.activeNetwork) ?: return false
|
||||
return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
|
||||
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
|
||||
}
|
||||
|
||||
/** Downloads [release], then installs it once the file checks out. */
|
||||
fun download(context: Context, release: Release, quiet: Boolean = false) {
|
||||
/** Downloads [release], then opens the installer once the file checks out. */
|
||||
fun download(context: Context, release: Release) {
|
||||
if (flow.value is State.Downloading) return
|
||||
val app = context.applicationContext
|
||||
flow.value = State.Downloading(release, 0f)
|
||||
@@ -334,23 +267,21 @@ object UpdateChecker {
|
||||
GearslipLog.w("update: download failed: ${it.message}")
|
||||
State.Failed(it.message ?: "The download failed. Try again.", release)
|
||||
}
|
||||
if (flow.value is State.Ready) install(app, quiet)
|
||||
if (flow.value is State.Ready) install(app)
|
||||
}, "gearslip-update").start()
|
||||
}
|
||||
|
||||
/**
|
||||
* Hands the downloaded APK to Android's installer. The first time, Android wants the driver to
|
||||
* allow installs from Gearslip; that opens its settings page, and the install carries on when
|
||||
* they come back. A [quiet] install never opens anything: if Android wants a confirmation, it
|
||||
* falls back to the card and the notification.
|
||||
* they come back.
|
||||
*/
|
||||
fun install(context: Context, quiet: Boolean = false) {
|
||||
fun install(context: Context) {
|
||||
val ready = flow.value as? State.Ready ?: return
|
||||
// Installing closes Gearslip, which would drop the car screen mid-drive.
|
||||
if (carConnected) return
|
||||
val pm = context.packageManager
|
||||
if (!pm.canRequestPackageInstalls()) {
|
||||
if (quiet) return
|
||||
installWhenAllowed = true
|
||||
context.startActivity(
|
||||
Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:${context.packageName}"))
|
||||
@@ -358,111 +289,14 @@ object UpdateChecker {
|
||||
)
|
||||
return
|
||||
}
|
||||
val app = context.applicationContext
|
||||
Thread({
|
||||
runCatching { commit(app, ready, quiet) }.onFailure {
|
||||
GearslipLog.w("update: install failed to start: ${it.message}")
|
||||
if (!quiet) flow.value = State.Failed("Android couldn't start the install. Try again.", ready.release)
|
||||
}
|
||||
}, "gearslip-update").start()
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the APK into an install session. Android skips its prompt when Gearslip installed the
|
||||
* version being replaced; otherwise [onInstallStatus] gets a screen to show for the driver.
|
||||
*/
|
||||
private fun commit(app: Context, ready: State.Ready, quiet: Boolean) {
|
||||
val installer = app.packageManager.packageInstaller
|
||||
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL).apply {
|
||||
setAppPackageName(app.packageName)
|
||||
setSize(ready.apk.length())
|
||||
setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED)
|
||||
}
|
||||
val id = installer.createSession(params)
|
||||
installer.openSession(id).use { session ->
|
||||
session.openWrite("gearslip.apk", 0, ready.apk.length()).use { out ->
|
||||
ready.apk.inputStream().use { it.copyTo(out) }
|
||||
session.fsync(out)
|
||||
}
|
||||
if (quiet) prefs(app).edit().putString(KEY_SILENT_VERSION, ready.release.version).commit()
|
||||
val status = Intent(app, UpdateReceiver::class.java)
|
||||
.setAction(UpdateReceiver.ACTION_STATUS)
|
||||
.putExtra(EXTRA_QUIET, quiet)
|
||||
.putExtra(EXTRA_VERSION, ready.release.version)
|
||||
// Mutable: the installer adds the outcome to it.
|
||||
val pending = PendingIntent.getBroadcast(app, id, status, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE)
|
||||
session.commit(pending.intentSender)
|
||||
}
|
||||
GearslipLog.i("update: handed ${ready.release.version} to Android's installer")
|
||||
}
|
||||
|
||||
/** What the installer said about a session; on success Gearslip is replaced and this never runs. */
|
||||
internal fun onInstallStatus(context: Context, intent: Intent) {
|
||||
val app = context.applicationContext
|
||||
val quiet = intent.getBooleanExtra(EXTRA_QUIET, false)
|
||||
val version = intent.getStringExtra(EXTRA_VERSION)
|
||||
val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE)
|
||||
when (status) {
|
||||
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
|
||||
if (quiet) {
|
||||
// Android wants a tap, maybe because a browser installed this version. Don't
|
||||
// pop a screen up unasked: drop it, and the card and notification take over.
|
||||
GearslipLog.i("update: Android wants $version confirmed; leaving it to the driver")
|
||||
val session = intent.getIntExtra(PackageInstaller.EXTRA_SESSION_ID, -1)
|
||||
if (session >= 0) runCatching { app.packageManager.packageInstaller.abandonSession(session) }
|
||||
prefs(app).edit().putString(KEY_SILENT_REFUSED, version).remove(KEY_SILENT_VERSION).apply()
|
||||
val release = foundRelease(app) ?: return
|
||||
if (prefs(app).getString(KEY_NOTIFIED, null) != release.version && notify(app, release)) {
|
||||
prefs(app).edit().putString(KEY_NOTIFIED, release.version).apply()
|
||||
}
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
val confirm = intent.getParcelableExtra<Intent>(Intent.EXTRA_INTENT) ?: return
|
||||
runCatching { app.startActivity(confirm.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) }
|
||||
}
|
||||
}
|
||||
PackageInstaller.STATUS_SUCCESS -> GearslipLog.i("update: $version installed")
|
||||
else -> {
|
||||
val message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
|
||||
GearslipLog.w("update: install of $version failed ($status, $message)")
|
||||
prefs(app).edit().remove(KEY_SILENT_VERSION).apply()
|
||||
if (quiet) prefs(app).edit().putString(KEY_SILENT_REFUSED, version).apply()
|
||||
else if (status != PackageInstaller.STATUS_FAILURE_ABORTED) {
|
||||
flow.value = State.Failed("Android didn't install it. Try again.", foundRelease(app))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The new version's first breath after updating itself: say so, since nobody was asked. */
|
||||
internal fun onReplaced(context: Context) {
|
||||
val app = context.applicationContext
|
||||
val version = prefs(app).getString(KEY_SILENT_VERSION, null) ?: return
|
||||
prefs(app).edit().remove(KEY_SILENT_VERSION).apply()
|
||||
if (version != BuildConfig.VERSION_NAME) return
|
||||
GearslipLog.i("update: now on $version, installed without asking")
|
||||
if (app.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) return
|
||||
app.getSystemService(NotificationManager::class.java).createNotificationChannel(
|
||||
NotificationChannel(CHANNEL, "Updates", NotificationManager.IMPORTANCE_DEFAULT),
|
||||
val uri = FileProvider.getUriForFile(context, "${context.packageName}.logshare", ready.apk)
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_VIEW)
|
||||
.setDataAndType(uri, "application/vnd.android.package-archive")
|
||||
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK),
|
||||
)
|
||||
val open = PendingIntent.getActivity(
|
||||
app, UPDATED_NOTIFICATION_ID,
|
||||
Intent(app, GearslipActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP),
|
||||
PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
val notification = NotificationCompat.Builder(app, CHANNEL)
|
||||
.setSmallIcon(android.R.drawable.stat_sys_download_done)
|
||||
.setContentTitle(app.getString(R.string.update_installed_title, version))
|
||||
.setContentText(app.getString(R.string.update_installed_text))
|
||||
.setContentIntent(open)
|
||||
.setAutoCancel(true)
|
||||
.build()
|
||||
runCatching { NotificationManagerCompat.from(app).notify(UPDATED_NOTIFICATION_ID, notification) }
|
||||
}
|
||||
|
||||
private const val EXTRA_QUIET = "quiet"
|
||||
private const val EXTRA_VERSION = "version"
|
||||
|
||||
/** The newest release with an APK that [channel] takes. Drafts never count. */
|
||||
private fun fetchNewest(channel: Channel): Release? {
|
||||
val stable = channel == Channel.STABLE
|
||||
@@ -578,14 +412,12 @@ object UpdateChecker {
|
||||
/**
|
||||
* "0.2.00-alpha" beats "0.1.10-alpha": the numbers before the dash compare one by one. With the
|
||||
* same numbers, a plain release beats a beta, and a beta beats an alpha, so "0.3.0" reaches
|
||||
* someone on "0.3.0-beta". Anything after a "+" only says which commit a test build came from,
|
||||
* so "0.3.0-beta+4.ba4374e" counts the same as "0.3.0-beta".
|
||||
* someone on "0.3.0-beta".
|
||||
*/
|
||||
internal fun isNewer(candidate: String, installed: String): Boolean {
|
||||
fun core(v: String) = v.removePrefix("v").substringBefore('+')
|
||||
fun parts(v: String) = core(v).substringBefore('-').split('.').map { it.toIntOrNull() ?: 0 }
|
||||
fun parts(v: String) = v.removePrefix("v").substringBefore('-').split('.').map { it.toIntOrNull() ?: 0 }
|
||||
fun stage(v: String): Int {
|
||||
val tag = core(v).substringAfter('-', "").lowercase()
|
||||
val tag = v.substringAfter('-', "").lowercase()
|
||||
return when {
|
||||
tag.isEmpty() -> 3
|
||||
"beta" in tag -> 2
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
package app.seb3thehacker.gearslip.update
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
|
||||
/** The installer's answer about an update, and the new version's first start after one. */
|
||||
class UpdateReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
when (intent.action) {
|
||||
ACTION_STATUS -> UpdateChecker.onInstallStatus(context, intent)
|
||||
Intent.ACTION_MY_PACKAGE_REPLACED -> UpdateChecker.onReplaced(context)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ACTION_STATUS = "app.seb3thehacker.gearslip.update.STATUS"
|
||||
}
|
||||
}
|
||||
@@ -80,9 +80,6 @@ object WirelessLink {
|
||||
/** Set by the activity: starts a session on the car's socket. */
|
||||
@Volatile var onConnected: ((Socket) -> Unit)? = null
|
||||
|
||||
/** Set by the activity: whether the session on the socket still waits for the car's first message. */
|
||||
@Volatile var sessionWaiting: (() -> Boolean)? = null
|
||||
|
||||
@Volatile private var rfcomm: BluetoothSocket? = null
|
||||
@Volatile private var listener: BluetoothServerSocket? = null
|
||||
@Volatile private var tcp: Socket? = null
|
||||
@@ -95,8 +92,6 @@ object WirelessLink {
|
||||
@Volatile private var lostNetwork: Network? = null
|
||||
/** The car network asked for or joined, until Android says it's gone or never came. */
|
||||
@Volatile private var activeSsid: String? = null
|
||||
/** The car's network once joined, for connecting again on it. */
|
||||
@Volatile private var joinedNet: Network? = null
|
||||
/** Bumped on each Wi-Fi join and on stop, so an older run of TCP tries ends instead of racing the new one. */
|
||||
@Volatile private var tcpRun = 0
|
||||
/** The socket reached the car's projection: whatever ends the start-up now, it isn't retried. */
|
||||
@@ -200,7 +195,6 @@ object WirelessLink {
|
||||
}
|
||||
network = null
|
||||
activeSsid = null
|
||||
joinedNet = null
|
||||
}
|
||||
|
||||
/** A stable id for this phone as this app sees it, standing in for the serial a real phone sends. */
|
||||
@@ -278,17 +272,6 @@ object WirelessLink {
|
||||
val port = Wire.varint(f, 2)?.toInt() ?: 0
|
||||
log.i("<- StartRequest: projection at $ip:$port")
|
||||
target = ip to port
|
||||
// The car asks again while the connection it got still hasn't heard from
|
||||
// it: it started its side over, and waits on a fresh connection. Issue #18:
|
||||
// a Bolt left Gearslip waiting 88 seconds on the old one, then hung up.
|
||||
// A session already under way is left alone.
|
||||
val net = joinedNet
|
||||
if (net != null && activeSsid != null && handedOver && sessionWaiting?.invoke() == true) {
|
||||
log.w("the car asked again while the connection still waits for it - connecting again")
|
||||
val run = ++tcpRun
|
||||
Thread({ connectTcp(context, output, name, net, ip, port, run) }, "gearslip-wireless-tcp").start()
|
||||
continue
|
||||
}
|
||||
val join = waitingJoin
|
||||
if (join != null) {
|
||||
waitingJoin = null
|
||||
@@ -440,7 +423,6 @@ object WirelessLink {
|
||||
val callback = object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(net: Network) {
|
||||
log.i("Wi-Fi: joined \"$ssid\"")
|
||||
joinedNet = net
|
||||
// Real phones report the join over Bluetooth before opening TCP, and some cars
|
||||
// only start listening once they hear it.
|
||||
val phoneIp = manager.getLinkProperties(net)?.linkAddresses
|
||||
@@ -481,7 +463,6 @@ object WirelessLink {
|
||||
|
||||
override fun onLost(net: Network) {
|
||||
lostNetwork = net
|
||||
if (joinedNet == net) joinedNet = null
|
||||
if (activeSsid == ssid) activeSsid = null
|
||||
log.w(
|
||||
"Wi-Fi: lost \"$ssid\" - Android dropped the car's network. Picking it by hand " +
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,17 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- The launcher icon's gear, cropped to its edges, for the "Works with Gearslip" mark. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="57"
|
||||
android:viewportHeight="57">
|
||||
<group android:translateX="-29.5" android:translateY="-25.5">
|
||||
<path
|
||||
android:pathData="M 54.00,36.18 L 58.64,30.70 L 67.20,34.24 L 66.60,41.40 L 71.82,54.00 L 77.30,58.64 L 73.76,67.20 L 66.60,66.60 L 66.60,66.60 L 67.20,73.76 L 58.64,77.30 L 54.00,71.82 L 54.00,71.82 L 49.36,77.30 L 40.80,73.76 L 41.40,66.60 L 41.40,66.60 L 34.24,67.20 L 30.70,58.64 L 36.18,54.00 L 36.18,54.00 L 30.70,49.36 L 34.24,40.80 L 41.40,41.40 L 41.40,41.40 L 40.80,34.24 L 49.36,30.70 L 54.00,36.18 Z M54,46.08 A7.92,7.92 0 1,1 53.99,46.08 Z"
|
||||
android:fillType="evenOdd"
|
||||
android:fillColor="#FF000000"/>
|
||||
<path
|
||||
android:pathData="M 78.61,40.70 L 85.31,43.29 L 84.74,52.55 L 77.78,54.31 Z"
|
||||
android:fillColor="#FF000000"/>
|
||||
</group>
|
||||
</vector>
|
||||
@@ -200,6 +200,8 @@
|
||||
<string name="car_right">يمين</string>
|
||||
<string name="car_left">يسار</string>
|
||||
<string name="car_autoplay">التشغيل التلقائي عند الاتصال</string>
|
||||
<string name="car_more_lyrics">مصادر أكثر لكلمات الأغاني</string>
|
||||
<string name="car_more_lyrics_note">يسأل أيضًا BetterLyrics وKuGou، فيحصلان على اسم الأغنية والفنان. عند الإيقاف يسأل LRCLIB فقط.</string>
|
||||
<string name="car_spoken_directions">الإرشادات الصوتية</string>
|
||||
<string name="car_spoken_directions_note">أوقفها إذا كان صوت تطبيق الخرائط نفسه يُسمع في السيارة بالفعل.</string>
|
||||
<string name="car_phone_speech">استخدام التعرّف على الكلام في الهاتف</string>
|
||||
@@ -211,7 +213,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">يعرضها تطبيق المتصفح في السيارة. إذا تُركت فارغة تُفتح صفحة البدء مع إشاراتك المرجعية. تُطبَّق عند الاتصال التالي.</string>
|
||||
<string name="web_page_note">يعرضها تطبيق المتصفح في السيارة. إذا تُركت فارغة يُفتح DuckDuckGo. يسري عند الاتصال التالي.</string>
|
||||
<string name="car_audio">صوت السيارة</string>
|
||||
<string name="car_audio_bt">بلوتوث (موصى به)</string>
|
||||
<string name="car_audio_bt_sub">لا سؤال في كل رحلة، ويصل صوت تطبيق الخرائط إلى السيارة</string>
|
||||
@@ -431,6 +433,8 @@
|
||||
<string name="carset_fit">ملاءمة الشاشة</string>
|
||||
<string name="off">متوقف</string>
|
||||
<string name="on">مفعّل</string>
|
||||
<string name="carset_lyrics_from">الكلمات من</string>
|
||||
<string name="carset_more_sources">مصادر أكثر</string>
|
||||
<string name="carset_hide">إخفاء</string>
|
||||
<string name="carset_show">إظهار</string>
|
||||
<string name="carset_phone">الهاتف</string>
|
||||
@@ -440,7 +444,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">صفحة البدء (افتراضي) - انقر لتعيين صفحة</string>
|
||||
<string name="carset_web_default">DuckDuckGo (افتراضي) - انقر لتعيين صفحة أخرى</string>
|
||||
<string name="carset_web_note">يعرضها تطبيق المتصفح في المشغّل. يسري عند الاتصال التالي.</string>
|
||||
<string name="carset_screen_fit">ملاءمة الشاشة</string>
|
||||
<string name="carset_top">الأعلى</string>
|
||||
@@ -459,6 +463,7 @@
|
||||
<string name="carset_px">%1$dpx</string>
|
||||
|
||||
<!-- Car media app -->
|
||||
<string name="media_open_on_phone">فتح على الهاتف</string>
|
||||
<string name="media_now_playing">قيد التشغيل</string>
|
||||
<string name="media_up_next">التالي</string>
|
||||
<string name="media_browse">تصفّح</string>
|
||||
@@ -468,7 +473,7 @@
|
||||
<string name="media_press_play">اضغط تشغيل لتكمل من حيث توقفت، أو ابحث عن شيء في «تصفّح».</string>
|
||||
|
||||
<string name="media_keeps_library">يحتفظ %1$s بمكتبته لنفسه.</string>
|
||||
<string name="media_needs_access">ما زال Gearslip قادرًا على تشغيله بمجرد حصوله على إذن الوصول إلى الإشعارات. اسمح بذلك على الهاتف عندما تركن السيارة.</string>
|
||||
<string name="media_needs_access">لا يزال بإمكان Gearslip التحكم به بعد منحه الوصول إلى الإشعارات على الهاتف. اسمح بذلك من الإعدادات > الإشعارات > الوصول إلى الإشعارات.</string>
|
||||
<string name="media_not_running">%1$s لا يعمل.</string>
|
||||
<string name="media_not_running_body">يُشغَّل %1$s عبر مشغّل Gearslip، لكن فقط أثناء فتحه على الهاتف. افتحه مرة ويواصل التشغيل هنا في السيارة.</string>
|
||||
<string name="media_open_app">فتح %1$s</string>
|
||||
@@ -663,7 +668,7 @@
|
||||
<string name="notif_title">الإشعارات</string>
|
||||
<string name="notif_clear_all">مسح الكل</string>
|
||||
<string name="notif_empty">لا شيء بعد. ستظهر هنا الإشعارات الجديدة من الهاتف.</string>
|
||||
<string name="notif_off">يحتاج Gearslip إلى إذن الوصول إلى الإشعارات لعرضها هنا. اسمح بذلك على الهاتف عندما تركن السيارة.</string>
|
||||
<string name="notif_off">الإشعارات متوقفة. اسمح لـ Gearslip من الإعدادات > الإشعارات > الوصول إلى الإشعارات على الهاتف.</string>
|
||||
<string name="notif_replied">تم الرد</string>
|
||||
|
||||
<!-- Messages -->
|
||||
@@ -861,135 +866,4 @@
|
||||
<string name="settings_language">اللغة</string>
|
||||
<string name="settings_language_phone">مثل لغة الهاتف</string>
|
||||
<string name="player_next_chapter">الفصل التالي</string>
|
||||
<string name="allow_on_phone">السماح على الهاتف</string>
|
||||
<string name="app_all_apps">كل التطبيقات</string>
|
||||
<string name="bt_address_car_note">أكمل خطوة إعداد واحدة على هاتفك عندما تركن السيارة.</string>
|
||||
<string name="bt_address_card_body">بعض السيارات لا تبقى متصلة إلا إذا عرفت عنوان بلوتوث الهاتف. يخفيه أندرويد عن التطبيقات، لذا انسخه من «لمحة عن الهاتف». اضغط مطولًا على عنوان البلوتوث هناك، ثم انقر على «نسخ» وعد إلى هنا.</string>
|
||||
<string name="bt_address_card_title">ساعد %1$s على التعرف على هذا الهاتف</string>
|
||||
<string name="bt_address_not_now">ليس الآن</string>
|
||||
<string name="bt_address_open">فتح «لمحة عن الهاتف»</string>
|
||||
<string name="bt_address_type">اكتبه بدلًا من ذلك</string>
|
||||
<string name="bt_address_use_body">يمرره Gearslip إلى سيارتك، فتعرف أن هذا هو الهاتف المقترن بها.</string>
|
||||
<string name="bt_address_use_title">هل تريد استخدام عنوان البلوتوث هذا؟</string>
|
||||
<string name="bt_address_your_car">سيارتك</string>
|
||||
<string name="car_apps_finding">جارٍ البحث عن التطبيقات…</string>
|
||||
<string name="car_apps_none">لم يُعثر على تطبيقات للسيارة</string>
|
||||
<string name="car_apps_sub">اختر التطبيقات التي تظهر في السيارة</string>
|
||||
<string name="car_audio_blocked">لا يسمح %1$s للتطبيقات الأخرى بتشغيل صوته، لذا لا يصل إلى السيارة عبر Gearslip. جرّب تطبيق موسيقى آخر.</string>
|
||||
<string name="car_audio_switched">تُطفئ هذه السيارة موسيقى البلوتوث أثناء عمل Gearslip، لذا تمر الموسيقى الآن عبر Gearslip. اسمح بالطلب على هاتفك.</string>
|
||||
<string name="car_autoplay_note">يستأنف الموسيقى من حيث توقفت بمجرد اتصال السيارة.</string>
|
||||
<string name="car_density">كثافة شاشة السيارة</string>
|
||||
<string name="car_density_note">نص وأزرار أكبر، حتى داخل التطبيقات. جرّب 200 DPI مع ضبط الحجم على عادي. أعد توصيل السيارة أو افتح المعاينة من جديد لتطبيقه. إذا اقتُطعت الأزرار، فاختر DPI أقل أو «من السيارة».</string>
|
||||
<string name="car_directions_over_music">الإرشادات عبر قناة الموسيقى</string>
|
||||
<string name="car_directions_over_music_note">لسيارة تفقد الاتصال أو تُعيد التشغيل بعد إرشاد منطوق. يمزج Gearslip كل إرشاد مع الموسيقى بدلًا من استخدام قناة الملاحة في السيارة.</string>
|
||||
<string name="car_experimental_note">ميزات ما زالت قيد الاختبار. قد لا تعمل في كل سيارة.</string>
|
||||
<string name="car_player_beside_map_note">جهة الخريطة التي يُثبَّت فيها المشغل عند النقر على زر التثبيت.</string>
|
||||
<string name="carset_advanced">متقدم</string>
|
||||
<string name="carset_experimental_heading">تجريبي</string>
|
||||
<string name="carset_fit_needs_car">وصّل سيارة لحفظ هذا الضبط لها.</string>
|
||||
<string name="choice_density_car">من السيارة</string>
|
||||
<string name="choice_density_dpi">%1$d DPI</string>
|
||||
<string name="choice_density_custom">مخصص…</string>
|
||||
<string name="dialer_missed">فائتة · %1$s</string>
|
||||
<string name="dialer_no_recents">لا توجد مكالمات حديثة.</string>
|
||||
<string name="dialer_recents">الأخيرة</string>
|
||||
<string name="dialer_recents_needs_permission">يحتاج Gearslip إلى إذن سجلات المكالمات لعرض المكالمات الأخيرة.</string>
|
||||
<string name="help_bluetooth_first">وصّل البلوتوث قبل الكابل.</string>
|
||||
<string name="help_bluetooth_first_detail">إذا لم تتصل الموسيقى عبر البلوتوث، فدع السيارة تجد الهاتف عبر البلوتوث أولًا، ثم وصّل الكابل. بعض السيارات تختلط عليها الأمور إذا جاء الكابل أولًا.</string>
|
||||
<string name="help_usb_data">اسمح ببيانات USB.</string>
|
||||
<string name="help_usb_data_detail">قد يحظر GrapheneOS البيانات على منفذ USB. إذا كان التحكم في منفذ USB-C مضبوطًا على «الشحن فقط»، فلن ترى السيارة الهاتف أبدًا، حتى مع كابل بيانات. اضبطه للسماح بالبيانات.</string>
|
||||
<string name="launcher_in_car">هذا الجهاز متصل بسيارة، لذا يظهر Gearslip على شاشة السيارة.</string>
|
||||
<string name="launcher_open_gearslip">فتح Gearslip</string>
|
||||
<string name="nav_arrival">الوصول</string>
|
||||
<string name="nav_lane_any">أي مسار</string>
|
||||
<string name="nav_lane_from_left">استخدم المسار %1$d من اليسار</string>
|
||||
<string name="nav_lane_from_right">استخدم المسار %1$d من اليمين</string>
|
||||
<plurals name="nav_lane_left">
|
||||
<item quantity="zero">استخدم المسارات اليسرى</item>
|
||||
<item quantity="one">استخدم المسار الأيسر</item>
|
||||
<item quantity="two">استخدم المسارين الأيسرين</item>
|
||||
<item quantity="few">استخدم المسارات الـ %d اليسرى</item>
|
||||
<item quantity="many">استخدم المسارات الـ %d اليسرى</item>
|
||||
<item quantity="other">استخدم المسارات الـ %d اليسرى</item>
|
||||
</plurals>
|
||||
<string name="nav_lane_middle">استخدم المسار الأوسط</string>
|
||||
<string name="nav_lane_middle_lanes">استخدم المسارات الوسطى</string>
|
||||
<plurals name="nav_lane_right">
|
||||
<item quantity="zero">استخدم المسارات اليمنى</item>
|
||||
<item quantity="one">استخدم المسار الأيمن</item>
|
||||
<item quantity="two">استخدم المسارين الأيمنين</item>
|
||||
<item quantity="few">استخدم المسارات الـ %d اليمنى</item>
|
||||
<item quantity="many">استخدم المسارات الـ %d اليمنى</item>
|
||||
<item quantity="other">استخدم المسارات الـ %d اليمنى</item>
|
||||
</plurals>
|
||||
<string name="nav_to_go">متبقية</string>
|
||||
<string name="privacy_policy">سياسة الخصوصية</string>
|
||||
<string name="privacy_policy_sub">ما يرسله Gearslip، ولمن، ولماذا</string>
|
||||
<string name="settings_bt_address">عنوان بلوتوث الهاتف</string>
|
||||
<string name="settings_bt_address_hint">انسخه من إعدادات أندرويد، ضمن «لمحة عن الهاتف». يمرره Gearslip إلى السيارة، فتعرف أن الهاتف الموصول بالكابل هو المقترن بها.</string>
|
||||
<string name="settings_bt_address_invalid">هذا ليس عنوان بلوتوث. يبدو مثل 12:34:56:78:9A:BC.</string>
|
||||
<string name="settings_bt_address_off">غير مضبوط. فقط لسيارة تقطع البلوتوث عندما يتصل Gearslip.</string>
|
||||
<string name="settings_use_as_launcher">الاستخدام كمشغّل</string>
|
||||
<string name="settings_use_as_launcher_off">لوحدة رأسية أو جهاز لوحي على لوحة القيادة. تصبح شاشة السيارة في Gearslip الشاشة الرئيسية لهذا الجهاز.</string>
|
||||
<string name="settings_use_as_launcher_on">يمكن أن يكون Gearslip الشاشة الرئيسية لهذا الجهاز. اختره ضمن «تطبيق الشاشة الرئيسية التلقائي».</string>
|
||||
<string name="status_no_car">الهاتف يُشحن، لكن لم تستجب أي سيارة. بعض الكابلات تشحن فقط؛ جرّب كابلًا ينقل البيانات.</string>
|
||||
<string name="status_no_car_graphene">الهاتف يُشحن، لكن لم تستجب أي سيارة. تحقّق من أن التحكم في منفذ USB-C، ضمن الأمان والخصوصية، يسمح بالبيانات وليس مضبوطًا على «الشحن فقط».</string>
|
||||
<string name="status_open_settings">فتح الإعدادات</string>
|
||||
<string name="status_stuck_cable">لم تعرض السيارة Gearslip بعد. افصل الكابل، وانتظر بضع ثوانٍ، ثم وصّله من جديد. إذا تكرر ذلك، فجرّب كابلًا أو منفذ USB آخر، وشارك السجل في بلاغ.</string>
|
||||
<string name="status_stuck_wireless">لم تعرض السيارة Gearslip بعد. أطفئ السيارة وشغّلها من جديد، أو اتصل بكابل. إذا تكرر ذلك، فشارك السجل في بلاغ.</string>
|
||||
<string name="update_installed_text">ثُبّت عبر Wi-Fi بينما لم تكن أي سيارة متصلة.</string>
|
||||
<string name="update_installed_title">حُدّث Gearslip إلى %1$s</string>
|
||||
<string name="update_silent">تثبيت التحديثات تلقائيًا</string>
|
||||
<string name="update_silent_sub">الإصدارات النهائية فقط، عبر Wi-Fi، عندما لا تكون أي سيارة متصلة</string>
|
||||
<string name="assist_listening">جارٍ الاستماع…</string>
|
||||
<string name="assist_one_moment">لحظة…</string>
|
||||
<string name="assist_examples">جرّب “navigate home” أو “play” ثم اسم فنان. قل “help” للمزيد.</string>
|
||||
<string name="assist_grant">منح الإذن</string>
|
||||
<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>
|
||||
<string name="car_nav_mic">زر الميكروفون</string>
|
||||
<string name="car_nav_mic_note">زر المساعد في شريط التنقل. يعمل زر الصوت في السيارة في كل الأحوال.</string>
|
||||
<string name="car_apps_add">إضافة تطبيق</string>
|
||||
<string name="car_apps_add_note">لتطبيق لا تعرضه السيارة: تُشغَّل موسيقاه عبر مشغّل Gearslip، أو تظهر رسائله في شاشة الرسائل.</string>
|
||||
<string name="car_apps_add_pick">اختر تطبيقًا</string>
|
||||
<string name="car_apps_add_as">عرض %1$s بوصفه</string>
|
||||
<string name="car_apps_as_media">الموسيقى والصوت</string>
|
||||
<string name="car_apps_as_media_note">ما يُشغَّل الآن، مع التشغيل والإيقاف المؤقت والتخطي. لا توجد مكتبة للتصفح.</string>
|
||||
<string name="car_apps_as_messaging">الرسائل</string>
|
||||
<string name="car_apps_as_messaging_note">إشعاراته، تُقرأ بصوت عالٍ، مع الردود.</string>
|
||||
<string name="car_apps_added_by_you">أضفته أنت</string>
|
||||
<string name="car_apps_remove">إزالة %1$s</string>
|
||||
</resources>
|
||||
@@ -200,6 +200,8 @@
|
||||
<string name="car_right">Vpravo</string>
|
||||
<string name="car_left">Vlevo</string>
|
||||
<string name="car_autoplay">Přehrát po připojení</string>
|
||||
<string name="car_more_lyrics">Více zdrojů textů</string>
|
||||
<string name="car_more_lyrics_note">Ptá se také BetterLyrics a KuGou, které pak dostanou název a interpreta skladby. Vypnuto se ptá jen LRCLIB.</string>
|
||||
<string name="car_spoken_directions">Hlasové pokyny</string>
|
||||
<string name="car_spoken_directions_note">Vypněte je, pokud hlas vaší mapové aplikace už v autě hraje.</string>
|
||||
<string name="car_phone_speech">Používat rozpoznávání řeči telefonu</string>
|
||||
@@ -211,7 +213,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é otevře úvodní stránku se záložkami. Platí od další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="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>
|
||||
@@ -429,6 +431,8 @@
|
||||
<string name="carset_fit">Přizpůsobit obrazovce</string>
|
||||
<string name="off">Vyp.</string>
|
||||
<string name="on">Zap.</string>
|
||||
<string name="carset_lyrics_from">Texty z</string>
|
||||
<string name="carset_more_sources">Více zdrojů</string>
|
||||
<string name="carset_hide">Skrýt</string>
|
||||
<string name="carset_show">Zobrazit</string>
|
||||
<string name="carset_phone">Telefon</string>
|
||||
@@ -438,7 +442,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">Úvodní stránka (výchozí) - klepnutím nastavíte stránku</string>
|
||||
<string name="carset_web_default">DuckDuckGo (výchozí) - klepnutím nastavíte jinou 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>
|
||||
@@ -457,6 +461,7 @@
|
||||
<string name="carset_px">%1$dpx</string>
|
||||
|
||||
<!-- Car media app -->
|
||||
<string name="media_open_on_phone">Otevřít v telefonu</string>
|
||||
<string name="media_now_playing">Právě hraje</string>
|
||||
<string name="media_up_next">Další na řadě</string>
|
||||
<string name="media_browse">Procházet</string>
|
||||
@@ -466,7 +471,7 @@
|
||||
<string name="media_press_play">Stiskněte přehrát a pokračujte, kde jste skončili, nebo něco najděte v Procházet.</string>
|
||||
|
||||
<string name="media_keeps_library">%1$s si svou knihovnu nechává pro sebe.</string>
|
||||
<string name="media_needs_access">Gearslip ho i tak dokáže přehrávat, jakmile bude mít přístup k oznámením. Povolte ho v telefonu, až zaparkujete.</string>
|
||||
<string name="media_needs_access">Gearslip ji přesto může ovládat, jakmile bude mít v telefonu přístup k oznámením. Povolte ho v Nastavení > Oznámení > Přístup k oznámením.</string>
|
||||
<string name="media_not_running">%1$s neběží.</string>
|
||||
<string name="media_not_running_body">%1$s hraje přes přehrávač Gearslipu, ale jen když je otevřená v telefonu. Jednou ji otevřete a bude hrát dál tady v autě.</string>
|
||||
<string name="media_open_app">Otevřít %1$s</string>
|
||||
@@ -659,7 +664,7 @@
|
||||
<string name="notif_title">Oznámení</string>
|
||||
<string name="notif_clear_all">Vymazat vše</string>
|
||||
<string name="notif_empty">Zatím nic. Nová oznámení z telefonu se objeví tady.</string>
|
||||
<string name="notif_off">Gearslip potřebuje přístup k oznámením, aby je tu mohl zobrazit. Povolte ho v telefonu, až zaparkujete.</string>
|
||||
<string name="notif_off">Oznámení jsou vypnutá. Povolte Gearslip v telefonu v Nastavení > Oznámení > Přístup k oznámením.</string>
|
||||
<string name="notif_replied">Odpovězeno</string>
|
||||
|
||||
<!-- Messages -->
|
||||
@@ -845,131 +850,4 @@
|
||||
<string name="settings_language">Jazyk</string>
|
||||
<string name="settings_language_phone">Stejný jako v telefonu</string>
|
||||
<string name="player_next_chapter">Další kapitola</string>
|
||||
<string name="allow_on_phone">Povolit v telefonu</string>
|
||||
<string name="app_all_apps">Všechny aplikace</string>
|
||||
<string name="bt_address_car_note">Až zaparkujete, dokončete v telefonu jeden krok nastavení.</string>
|
||||
<string name="bt_address_card_body">Některá auta zůstanou připojená, jen když znají adresu Bluetooth telefonu. Android ji před aplikacemi skrývá, proto ji zkopírujte v sekci Informace o telefonu. Podržte tam adresu Bluetooth, klepněte na Kopírovat a vraťte se.</string>
|
||||
<string name="bt_address_card_title">Pomozte %1$s poznat tento telefon</string>
|
||||
<string name="bt_address_not_now">Teď ne</string>
|
||||
<string name="bt_address_open">Otevřít Informace o telefonu</string>
|
||||
<string name="bt_address_type">Raději ji napsat</string>
|
||||
<string name="bt_address_use_body">Gearslip ji předá vašemu autu, aby vědělo, že je to spárovaný telefon.</string>
|
||||
<string name="bt_address_use_title">Použít tuto adresu Bluetooth?</string>
|
||||
<string name="bt_address_your_car">vašemu autu</string>
|
||||
<string name="car_apps_finding">Hledání aplikací…</string>
|
||||
<string name="car_apps_none">Nenašly se žádné aplikace do auta</string>
|
||||
<string name="car_apps_sub">Vyberte, které aplikace se v autě zobrazí</string>
|
||||
<string name="car_audio_blocked">%1$s nedovolí jiným aplikacím přehrávat svůj zvuk, takže se přes Gearslip do auta nedostane. Zkuste jinou hudební aplikaci.</string>
|
||||
<string name="car_audio_switched">Toto auto vypíná hudbu přes Bluetooth, když běží Gearslip, takže hudba teď jde přes Gearslip. Povolte výzvu v telefonu.</string>
|
||||
<string name="car_autoplay_note">Jakmile se auto připojí, pustí hudbu tam, kde skončila.</string>
|
||||
<string name="car_density">Hustota displeje auta</string>
|
||||
<string name="car_density_note">Větší text a ovládací prvky, i v aplikacích. Zkuste 200 DPI s Velikostí na Normální. Pro použití auto znovu připojte nebo znovu otevřete náhled. Pokud se ovládací prvky ořezávají, zvolte méně DPI nebo Z auta.</string>
|
||||
<string name="car_directions_over_music">Pokyny přes hudební kanál</string>
|
||||
<string name="car_directions_over_music_note">Pro auto, které po hlasovém pokynu ztratí spojení nebo se restartuje. Gearslip každý pokyn přimíchá do hudby místo kanálu navigace auta.</string>
|
||||
<string name="car_experimental_note">Funkce, které se ještě testují. Nemusí fungovat v každém autě.</string>
|
||||
<string name="car_player_beside_map_note">Strana mapy, ke které se přehrávač ukotví, když klepnete na jeho tlačítko ukotvení.</string>
|
||||
<string name="carset_advanced">Pokročilé</string>
|
||||
<string name="carset_experimental_heading">Experimentální</string>
|
||||
<string name="carset_fit_needs_car">Připojte auto, abyste pro něj tuto úpravu uložili.</string>
|
||||
<string name="choice_density_car">Z auta</string>
|
||||
<string name="choice_density_dpi">%1$d DPI</string>
|
||||
<string name="choice_density_custom">Vlastní…</string>
|
||||
<string name="dialer_missed">Zmeškaný · %1$s</string>
|
||||
<string name="dialer_no_recents">Žádné nedávné hovory.</string>
|
||||
<string name="dialer_recents">Nedávné</string>
|
||||
<string name="dialer_recents_needs_permission">Gearslip potřebuje oprávnění Seznamy hovorů, aby mohl zobrazit nedávné hovory.</string>
|
||||
<string name="help_bluetooth_first">Připojte Bluetooth dřív než kabel.</string>
|
||||
<string name="help_bluetooth_first_detail">Pokud se hudba přes Bluetooth nepřipojí, nechte auto nejdřív najít telefon přes Bluetooth a pak zapojte kabel. Některá auta se zmatou, když je kabel první.</string>
|
||||
<string name="help_usb_data">Povolte data přes USB.</string>
|
||||
<string name="help_usb_data_detail">GrapheneOS může blokovat data na portu USB. Pokud je ovládání portu USB-C nastavené na Pouze nabíjení, auto telefon nikdy neuvidí, ani s datovým kabelem. Nastavte povolení dat.</string>
|
||||
<string name="launcher_in_car">Toto zařízení je připojené k autu, takže Gearslip je na obrazovce auta.</string>
|
||||
<string name="launcher_open_gearslip">Otevřít Gearslip</string>
|
||||
<string name="nav_arrival">příjezd</string>
|
||||
<string name="nav_lane_any">Libovolný pruh</string>
|
||||
<string name="nav_lane_from_left">Jeďte %1$d. pruhem zleva</string>
|
||||
<string name="nav_lane_from_right">Jeďte %1$d. pruhem zprava</string>
|
||||
<plurals name="nav_lane_left">
|
||||
<item quantity="one">Jeďte levým pruhem</item>
|
||||
<item quantity="few">Jeďte jedním z %d levých pruhů</item>
|
||||
<item quantity="many">Jeďte jedním z %d levých pruhů</item>
|
||||
<item quantity="other">Jeďte jedním z %d levých pruhů</item>
|
||||
</plurals>
|
||||
<string name="nav_lane_middle">Jeďte prostředním pruhem</string>
|
||||
<string name="nav_lane_middle_lanes">Jeďte prostředními pruhy</string>
|
||||
<plurals name="nav_lane_right">
|
||||
<item quantity="one">Jeďte pravým pruhem</item>
|
||||
<item quantity="few">Jeďte jedním z %d pravých pruhů</item>
|
||||
<item quantity="many">Jeďte jedním z %d pravých pruhů</item>
|
||||
<item quantity="other">Jeďte jedním z %d pravých pruhů</item>
|
||||
</plurals>
|
||||
<string name="nav_to_go">zbývá</string>
|
||||
<string name="privacy_policy">Zásady ochrany soukromí</string>
|
||||
<string name="privacy_policy_sub">Co Gearslip odesílá, komu a proč</string>
|
||||
<string name="settings_bt_address">Adresa Bluetooth telefonu</string>
|
||||
<string name="settings_bt_address_hint">Zkopírujte ji z nastavení Androidu v sekci Informace o telefonu. Gearslip ji předá autu, aby vědělo, že telefon na kabelu je ten spárovaný.</string>
|
||||
<string name="settings_bt_address_invalid">To není adresa Bluetooth. Vypadá takto: 12:34:56:78:9A:BC.</string>
|
||||
<string name="settings_bt_address_off">Nenastaveno. Jen pro auto, které při připojení Gearslipu odpojí Bluetooth.</string>
|
||||
<string name="settings_use_as_launcher">Používat jako spouštěč</string>
|
||||
<string name="settings_use_as_launcher_off">Pro autorádio nebo tablet na palubní desce. Obrazovka auta z Gearslipu se stane domovskou obrazovkou tohoto zařízení.</string>
|
||||
<string name="settings_use_as_launcher_on">Gearslip může být domovskou obrazovkou tohoto zařízení. Vyberte ho v Výchozí aplikace plochy.</string>
|
||||
<string name="status_no_car">Telefon se nabíjí, ale žádné auto neodpovědělo. Některé kabely jen nabíjejí; zkuste kabel, který přenáší data.</string>
|
||||
<string name="status_no_car_graphene">Telefon se nabíjí, ale žádné auto neodpovědělo. Zkontrolujte, že ovládání portu USB-C v sekci Zabezpečení a ochrana soukromí povoluje data a není nastavené na Pouze nabíjení.</string>
|
||||
<string name="status_open_settings">Otevřít nastavení</string>
|
||||
<string name="status_stuck_cable">Auto zatím Gearslip nezobrazilo. Odpojte kabel, počkejte pár sekund a zapojte ho znovu. Pokud se to opakuje, zkuste jiný kabel nebo port USB a sdílejte protokol v issue.</string>
|
||||
<string name="status_stuck_wireless">Auto zatím Gearslip nezobrazilo. Vypněte a znovu zapněte auto, nebo se připojte kabelem. Pokud se to opakuje, sdílejte protokol v issue.</string>
|
||||
<string name="update_installed_text">Nainstalováno přes Wi-Fi, když nebylo připojené žádné auto.</string>
|
||||
<string name="update_installed_title">Gearslip aktualizován na %1$s</string>
|
||||
<string name="update_silent">Instalovat aktualizace samo</string>
|
||||
<string name="update_silent_sub">Jen hotová vydání, přes Wi-Fi, když není připojené žádné auto</string>
|
||||
<string name="assist_listening">Poslouchám…</string>
|
||||
<string name="assist_one_moment">Okamžik…</string>
|
||||
<string name="assist_examples">Zkuste „navigate home“ nebo „play“ a interpreta. Pro víc řekněte „help“.</string>
|
||||
<string name="assist_grant">Povolit</string>
|
||||
<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>
|
||||
<string name="car_nav_mic">Tlačítko mikrofonu</string>
|
||||
<string name="car_nav_mic_note">Tlačítko asistenta na navigační liště. Hlasové tlačítko auta funguje tak jako tak.</string>
|
||||
<string name="car_apps_add">Přidat aplikaci</string>
|
||||
<string name="car_apps_add_note">Pro aplikaci, kterou auto nezobrazuje: její hudba hraje přes přehrávač Gearslip, nebo se její zprávy zobrazí ve Zprávách.</string>
|
||||
<string name="car_apps_add_pick">Vyberte aplikaci</string>
|
||||
<string name="car_apps_add_as">Zobrazit %1$s jako</string>
|
||||
<string name="car_apps_as_media">Hudba a zvuk</string>
|
||||
<string name="car_apps_as_media_note">Právě hraje, s přehráním, pozastavením a přeskočením. Bez knihovny k procházení.</string>
|
||||
<string name="car_apps_as_messaging">Zprávy</string>
|
||||
<string name="car_apps_as_messaging_note">Její oznámení, předčítaná nahlas, s odpověďmi.</string>
|
||||
<string name="car_apps_added_by_you">Přidáno vámi</string>
|
||||
<string name="car_apps_remove">Odebrat %1$s</string>
|
||||
</resources>
|
||||
@@ -187,6 +187,8 @@
|
||||
<string name="car_right">Højre</string>
|
||||
<string name="car_left">Venstre</string>
|
||||
<string name="car_autoplay">Autoplay på forbindelse</string>
|
||||
<string name="car_more_lyrics">Flere tekstkilder</string>
|
||||
<string name="car_more_lyrics_note">Spørg også BetterLyrics og KuGou, som derefter får sangens navn og kunstner. Ellers spørges kun LRCLIB.</string>
|
||||
<string name="cert_import_button">Import</string>
|
||||
<string name="car_spoken_directions">Talte rutevejledniger</string>
|
||||
<string name="car_spoken_directions_note">Slå det fra, hvis dit navigations programs egen stemme allerede spiller i bilen.</string>
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
<string name="carset_fit">An Bildschirm anpassen</string>
|
||||
<string name="off">Aus</string>
|
||||
<string name="on">An</string>
|
||||
<string name="carset_lyrics_from">Songtexte von</string>
|
||||
<string name="carset_more_sources">Weitere Quellen</string>
|
||||
<string name="carset_hide">Ausblenden</string>
|
||||
<string name="carset_show">Anzeigen</string>
|
||||
<string name="carset_phone">Handy</string>
|
||||
@@ -20,7 +22,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">Startseite (Standard) – tippe, um eine Seite festzulegen</string>
|
||||
<string name="carset_web_default">DuckDuckGo (Standard) – tippe, um eine andere 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>
|
||||
@@ -36,6 +38,7 @@
|
||||
<string name="carset_saved_to">Gespeichert unter %1$s</string>
|
||||
<string name="carset_save_failed">Speichern fehlgeschlagen</string>
|
||||
<string name="carset_px">%1$d px</string>
|
||||
<string name="media_open_on_phone">Auf dem Handy öffnen</string>
|
||||
<string name="media_now_playing">Läuft gerade</string>
|
||||
<string name="media_up_next">Als Nächstes</string>
|
||||
<string name="media_browse">Durchsuchen</string>
|
||||
@@ -44,7 +47,7 @@
|
||||
<string name="media_nothing_playing">Es läuft nichts</string>
|
||||
<string name="media_press_play">Drücke auf Wiedergabe, um dort weiterzumachen, wo du aufgehört hast, oder finde etwas unter „Durchsuchen“.</string>
|
||||
<string name="media_keeps_library">%1$s gibt seine Mediathek nicht frei.</string>
|
||||
<string name="media_needs_access">Gearslip kann es trotzdem abspielen, sobald es Zugriff auf Benachrichtigungen hat. Erlaube das auf dem Handy, wenn du parkst.</string>
|
||||
<string name="media_needs_access">Gearslip kann die App trotzdem steuern, sobald es auf dem Handy Zugriff auf Benachrichtigungen hat. Erlaube das unter Einstellungen > Benachrichtigungen > Zugriff auf Benachrichtigungen.</string>
|
||||
<string name="media_not_running">%1$s läuft nicht.</string>
|
||||
<string name="media_not_running_body">%1$s spielt über den eigenen Player von Gearslip, aber nur, solange die App auf dem Handy geöffnet ist. Öffne sie einmal, dann läuft sie hier im Auto weiter.</string>
|
||||
<string name="media_open_app">%1$s öffnen</string>
|
||||
@@ -196,7 +199,7 @@
|
||||
<string name="notif_title">Benachrichtigungen</string>
|
||||
<string name="notif_clear_all">Alle löschen</string>
|
||||
<string name="notif_empty">Noch nichts da. Neue Benachrichtigungen vom Handy erscheinen hier.</string>
|
||||
<string name="notif_off">Gearslip braucht Zugriff auf Benachrichtigungen, um sie hier zu zeigen. Erlaube das auf dem Handy, wenn du parkst.</string>
|
||||
<string name="notif_off">Benachrichtigungen sind aus. Erlaube Gearslip auf dem Handy unter Einstellungen > Benachrichtigungen > Zugriff auf Benachrichtigungen.</string>
|
||||
<string name="notif_replied">Beantwortet</string>
|
||||
<string name="msg_mark_read">Als gelesen markieren</string>
|
||||
<string name="msg_messages">Nachrichten</string>
|
||||
@@ -507,6 +510,8 @@
|
||||
<string name="car_right">Rechts</string>
|
||||
<string name="car_left">Links</string>
|
||||
<string name="car_autoplay">Automatisch abspielen beim Verbinden</string>
|
||||
<string name="car_more_lyrics">Weitere Songtext-Quellen</string>
|
||||
<string name="car_more_lyrics_note">Fragt außerdem bei BetterLyrics und KuGou an, die dabei Titel und Interpret des Songs erfahren. Bei „Aus“ wird nur LRCLIB gefragt.</string>
|
||||
<string name="car_spoken_directions">Gesprochene Navigationsansagen</string>
|
||||
<string name="car_spoken_directions_note">Schalte das aus, wenn die Sprachansage deiner Karten-App bereits im Auto zu hören ist.</string>
|
||||
<string name="car_phone_speech">Spracherkennung des Handys verwenden</string>
|
||||
@@ -518,7 +523,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 die Startseite mit deinen Lesezeichen. 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="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>
|
||||
@@ -711,127 +716,4 @@
|
||||
<string name="settings_language">Sprache</string>
|
||||
<string name="settings_language_phone">Wie auf dem Telefon</string>
|
||||
<string name="player_next_chapter">Nächstes Kapitel</string>
|
||||
<string name="allow_on_phone">Auf dem Handy erlauben</string>
|
||||
<string name="app_all_apps">Alle Apps</string>
|
||||
<string name="bt_address_car_note">Erledige einen Einrichtungsschritt auf dem Handy, wenn du parkst.</string>
|
||||
<string name="bt_address_card_body">Manche Autos bleiben nur verbunden, wenn sie die Bluetooth-Adresse des Handys kennen. Android gibt sie Apps nicht, also kopiere sie unter „Über das Telefon“. Halte dort die Bluetooth-Adresse gedrückt, tippe auf Kopieren und komm zurück.</string>
|
||||
<string name="bt_address_card_title">Hilf %1$s, dieses Handy zu erkennen</string>
|
||||
<string name="bt_address_not_now">Nicht jetzt</string>
|
||||
<string name="bt_address_open">„Über das Telefon“ öffnen</string>
|
||||
<string name="bt_address_type">Stattdessen eintippen</string>
|
||||
<string name="bt_address_use_body">Gearslip gibt sie an dein Auto weiter, damit es weiß, dass dies das gekoppelte Handy ist.</string>
|
||||
<string name="bt_address_use_title">Diese Bluetooth-Adresse verwenden?</string>
|
||||
<string name="bt_address_your_car">deinem Auto</string>
|
||||
<string name="car_apps_finding">Apps werden gesucht…</string>
|
||||
<string name="car_apps_none">Keine Auto-Apps gefunden</string>
|
||||
<string name="car_apps_sub">Wähle, welche Apps im Auto erscheinen</string>
|
||||
<string name="car_audio_blocked">%1$s lässt andere Apps seinen Ton nicht abspielen, daher erreicht er das Auto nicht über Gearslip. Versuch eine andere Musik-App.</string>
|
||||
<string name="car_audio_switched">Dieses Auto schaltet Bluetooth-Musik ab, solange Gearslip läuft, daher läuft die Musik jetzt über Gearslip. Erlaube die Abfrage auf dem Handy.</string>
|
||||
<string name="car_autoplay_note">Setzt die Musik dort fort, wo sie aufgehört hat, sobald das Auto verbunden ist.</string>
|
||||
<string name="car_density">Pixeldichte im Auto</string>
|
||||
<string name="car_density_note">Größere Schrift und Bedienelemente, auch in Apps. Probier 200 DPI mit Größe auf Normal. Verbinde das Auto neu oder öffne die Vorschau erneut, damit es wirkt. Wenn Bedienelemente abgeschnitten sind, wähle weniger DPI oder Vom Auto.</string>
|
||||
<string name="car_directions_over_music">Ansagen über den Musikkanal</string>
|
||||
<string name="car_directions_over_music_note">Für ein Auto, das nach einer Ansage die Verbindung trennt oder neu startet. Gearslip mischt jede Ansage in die Musik, statt den Navigationskanal des Autos zu nutzen.</string>
|
||||
<string name="car_experimental_note">Funktionen, die noch getestet werden. Sie laufen vielleicht nicht in jedem Auto.</string>
|
||||
<string name="car_player_beside_map_note">Die Seite der Karte, an der der Player andockt, wenn du auf seine Andocken-Taste tippst.</string>
|
||||
<string name="carset_advanced">Erweitert</string>
|
||||
<string name="carset_experimental_heading">Experimentell</string>
|
||||
<string name="carset_fit_needs_car">Verbinde ein Auto, um diese Anpassung dafür zu speichern.</string>
|
||||
<string name="choice_density_car">Vom Auto</string>
|
||||
<string name="choice_density_dpi">%1$d DPI</string>
|
||||
<string name="choice_density_custom">Eigener Wert…</string>
|
||||
<string name="dialer_missed">Verpasst · %1$s</string>
|
||||
<string name="dialer_no_recents">Keine letzten Anrufe.</string>
|
||||
<string name="dialer_recents">Letzte</string>
|
||||
<string name="dialer_recents_needs_permission">Gearslip braucht die Berechtigung für Anruflisten, um letzte Anrufe zu zeigen.</string>
|
||||
<string name="help_bluetooth_first">Verbinde Bluetooth vor dem Kabel.</string>
|
||||
<string name="help_bluetooth_first_detail">Wenn Musik über Bluetooth nicht verbindet, lass das Auto das Handy zuerst über Bluetooth finden und steck dann ein. Manche Autos kommen durcheinander, wenn das Kabel zuerst kommt.</string>
|
||||
<string name="help_usb_data">USB-Daten erlauben.</string>
|
||||
<string name="help_usb_data_detail">GrapheneOS kann Daten am USB-Anschluss sperren. Steht die USB-C-Anschlusssteuerung auf „Nur Laden“, sieht das Auto das Handy nie, auch mit Datenkabel. Stell sie auf Daten erlauben.</string>
|
||||
<string name="launcher_in_car">Dieses Gerät ist mit einem Auto verbunden, daher ist Gearslip auf dem Bildschirm des Autos.</string>
|
||||
<string name="launcher_open_gearslip">Gearslip öffnen</string>
|
||||
<string name="nav_arrival">Ankunft</string>
|
||||
<string name="nav_lane_any">Beliebige Spur</string>
|
||||
<string name="nav_lane_from_left">Spur %1$d von links nehmen</string>
|
||||
<string name="nav_lane_from_right">Spur %1$d von rechts nehmen</string>
|
||||
<plurals name="nav_lane_left">
|
||||
<item quantity="one">Linke Spur nehmen</item>
|
||||
<item quantity="other">Die linken %d Spuren nehmen</item>
|
||||
</plurals>
|
||||
<string name="nav_lane_middle">Mittlere Spur nehmen</string>
|
||||
<string name="nav_lane_middle_lanes">Mittlere Spuren nehmen</string>
|
||||
<plurals name="nav_lane_right">
|
||||
<item quantity="one">Rechte Spur nehmen</item>
|
||||
<item quantity="other">Die rechten %d Spuren nehmen</item>
|
||||
</plurals>
|
||||
<string name="nav_to_go">verbleibend</string>
|
||||
<string name="privacy_policy">Datenschutzerklärung</string>
|
||||
<string name="privacy_policy_sub">Was Gearslip sendet, an wen und warum</string>
|
||||
<string name="settings_bt_address">Bluetooth-Adresse des Handys</string>
|
||||
<string name="settings_bt_address_hint">Kopiere sie aus den Android-Einstellungen unter „Über das Telefon“. Gearslip gibt sie an das Auto weiter, damit es weiß, dass das Handy am Kabel das gekoppelte ist.</string>
|
||||
<string name="settings_bt_address_invalid">Das ist keine Bluetooth-Adresse. Sie sieht so aus: 12:34:56:78:9A:BC.</string>
|
||||
<string name="settings_bt_address_off">Nicht festgelegt. Nur für ein Auto, das Bluetooth trennt, wenn Gearslip sich verbindet.</string>
|
||||
<string name="settings_use_as_launcher">Als Launcher verwenden</string>
|
||||
<string name="settings_use_as_launcher_off">Für ein Autoradio oder ein Tablet am Armaturenbrett. Der Autobildschirm von Gearslip wird zum Startbildschirm dieses Geräts.</string>
|
||||
<string name="settings_use_as_launcher_on">Gearslip kann der Startbildschirm dieses Geräts sein. Wähle es unter Standard-Startbildschirm-App.</string>
|
||||
<string name="status_no_car">Das Handy lädt, aber kein Auto hat geantwortet. Manche Kabel laden nur; versuch eins, das Daten überträgt.</string>
|
||||
<string name="status_no_car_graphene">Das Handy lädt, aber kein Auto hat geantwortet. Prüfe, ob die USB-C-Anschlusssteuerung unter Sicherheit & Datenschutz Daten erlaubt und nicht auf „Nur Laden“ steht.</string>
|
||||
<string name="status_open_settings">Einstellungen öffnen</string>
|
||||
<string name="status_stuck_cable">Das Auto zeigt Gearslip noch nicht. Zieh das Kabel ab, warte ein paar Sekunden und steck es wieder ein. Passiert es öfter, versuch ein anderes Kabel oder einen anderen USB-Anschluss und teile das Protokoll in einem Issue.</string>
|
||||
<string name="status_stuck_wireless">Das Auto zeigt Gearslip noch nicht. Schalte das Auto aus und wieder ein oder verbinde mit Kabel. Passiert es öfter, teile das Protokoll in einem Issue.</string>
|
||||
<string name="update_installed_text">Über WLAN installiert, während kein Auto verbunden war.</string>
|
||||
<string name="update_installed_title">Gearslip auf %1$s aktualisiert</string>
|
||||
<string name="update_silent">Updates selbst installieren</string>
|
||||
<string name="update_silent_sub">Nur fertige Versionen, über WLAN, wenn kein Auto verbunden ist</string>
|
||||
<string name="assist_listening">Ich höre zu…</string>
|
||||
<string name="assist_one_moment">Einen Moment…</string>
|
||||
<string name="assist_examples">Probier „navigate home“ oder „play“ und einen Künstler. Sag „help“ für mehr.</string>
|
||||
<string name="assist_grant">Erlauben</string>
|
||||
<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>
|
||||
<string name="car_nav_mic">Mikrofontaste</string>
|
||||
<string name="car_nav_mic_note">Die Assistent-Taste in der Navigationsleiste. Die Sprachtaste des Autos funktioniert so oder so.</string>
|
||||
<string name="car_apps_add">App hinzufügen</string>
|
||||
<string name="car_apps_add_note">Für eine App, die das Auto nicht anzeigt: Ihre Musik läuft über den Player von Gearslip, oder ihre Nachrichten erscheinen in Nachrichten.</string>
|
||||
<string name="car_apps_add_pick">App auswählen</string>
|
||||
<string name="car_apps_add_as">%1$s anzeigen als</string>
|
||||
<string name="car_apps_as_media">Musik und Audio</string>
|
||||
<string name="car_apps_as_media_note">Aktueller Titel mit Wiedergabe, Pause und Überspringen. Keine Mediathek zum Durchsuchen.</string>
|
||||
<string name="car_apps_as_messaging">Nachrichten</string>
|
||||
<string name="car_apps_as_messaging_note">Ihre Benachrichtigungen, vorgelesen, mit Antworten.</string>
|
||||
<string name="car_apps_added_by_you">Von dir hinzugefügt</string>
|
||||
<string name="car_apps_remove">%1$s entfernen</string>
|
||||
</resources>
|
||||
@@ -109,6 +109,8 @@
|
||||
<string name="car_right">Bien</string>
|
||||
<string name="car_left">Izquierda</string>
|
||||
<string name="car_autoplay">Autoplay en conexión</string>
|
||||
<string name="car_more_lyrics">Más fuentes de letras</string>
|
||||
<string name="car_more_lyrics_note">También pregunta BetterLyrics y KuGou, que luego consigue el nombre y artista de la canción. Fuera pide LRCLIB sólo.</string>
|
||||
<string name="car_spoken_directions">Instrucciones descritas</string>
|
||||
<string name="car_spoken_directions_note">Apágalo si la propia voz de su aplicación de mapa ya juega en el coche.</string>
|
||||
<string name="car_phone_speech">Use el reconocimiento del discurso del teléfono</string>
|
||||
@@ -120,7 +122,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">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="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="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>
|
||||
@@ -298,11 +300,14 @@
|
||||
<string name="carset_fit">Ajuste a la pantalla</string>
|
||||
<string name="off">Fuera</string>
|
||||
<string name="on">On</string>
|
||||
<string name="carset_lyrics_from">Letras de</string>
|
||||
<string name="carset_more_sources">Más fuentes</string>
|
||||
<string name="carset_hide">Oculto</string>
|
||||
<string name="carset_show">Show</string>
|
||||
<string name="carset_phone">Teléfono</string>
|
||||
<string name="carset_vehicle">Vehículo</string>
|
||||
<string name="carset_video">Video</string>
|
||||
<string name="media_open_on_phone">Abra el teléfono</string>
|
||||
<string name="media_now_playing">Ahora jugando</string>
|
||||
<string name="media_up_next">Siguiente</string>
|
||||
<string name="media_browse">Navega</string>
|
||||
@@ -311,7 +316,7 @@
|
||||
<string name="media_nothing_playing">Nada jugando</string>
|
||||
<string name="media_press_play">Presiona el juego para recoger donde te fuiste, o encuentra algo en Navega.</string>
|
||||
<string name="media_keeps_library">%1$s mantiene su biblioteca a sí misma.</string>
|
||||
<string name="media_needs_access">Gearslip aún puede reproducirlo cuando tenga acceso a las notificaciones. Permítelo en el teléfono cuando estés estacionado.</string>
|
||||
<string name="media_needs_access">Gearslip todavía puede controlarlo una vez que tenga acceso a la notificación por teléfono. Permítelo en Configuración > Notificaciones √ Acceso a notificaciones.</string>
|
||||
<string name="media_not_running">%1$s no funciona.</string>
|
||||
<string name="media_not_running_body">%1$s suena a través del propio reproductor de Gearslip, pero sólo mientras está abierto en el teléfono. Ábrelo una vez y sigue jugando aquí en el coche.</string>
|
||||
<string name="media_open_app">Abra %1$s</string>
|
||||
@@ -463,7 +468,7 @@
|
||||
<string name="notif_title">Notificaciones</string>
|
||||
<string name="notif_clear_all">Despejado todo</string>
|
||||
<string name="notif_empty">Nada todavía. Las nuevas notificaciones del teléfono aparecerán aquí.</string>
|
||||
<string name="notif_off">Gearslip necesita acceso a las notificaciones para mostrarlas aquí. Permítelo en el teléfono cuando estés estacionado.</string>
|
||||
<string name="notif_off">No hay notificaciones. Permitir Gearslip bajo Ajustes > Notifications > Notifications en el teléfono.</string>
|
||||
<string name="notif_replied">Replied</string>
|
||||
<string name="msg_mark_read">Mark leyó</string>
|
||||
<string name="msg_messages">Mensajes</string>
|
||||
@@ -669,7 +674,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">Página de inicio (predeterminada): toca para elegir una página</string>
|
||||
<string name="carset_web_default">DuckDuckGo (default) - pulsar para configurar una página diferente</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>
|
||||
@@ -711,129 +716,4 @@
|
||||
<string name="settings_language">Idioma</string>
|
||||
<string name="settings_language_phone">El mismo que el teléfono</string>
|
||||
<string name="player_next_chapter">Capítulo siguiente</string>
|
||||
<string name="allow_on_phone">Permitir en el teléfono</string>
|
||||
<string name="app_all_apps">Todas las aplicaciones</string>
|
||||
<string name="bt_address_car_note">Termina un paso de configuración en el teléfono cuando estés estacionado.</string>
|
||||
<string name="bt_address_card_body">Algunos coches solo se mantienen conectados si conocen la dirección Bluetooth del teléfono. Android la oculta a las aplicaciones, así que cópiala desde Información del teléfono. Mantén pulsada la dirección Bluetooth, toca Copiar y vuelve.</string>
|
||||
<string name="bt_address_card_title">Ayuda a %1$s a reconocer este teléfono</string>
|
||||
<string name="bt_address_not_now">Ahora no</string>
|
||||
<string name="bt_address_open">Abrir Información del teléfono</string>
|
||||
<string name="bt_address_type">Escribirla</string>
|
||||
<string name="bt_address_use_body">Gearslip se la pasa a tu coche, para que sepa que este es el teléfono emparejado.</string>
|
||||
<string name="bt_address_use_title">¿Usar esta dirección Bluetooth?</string>
|
||||
<string name="bt_address_your_car">tu coche</string>
|
||||
<string name="car_apps_finding">Buscando aplicaciones…</string>
|
||||
<string name="car_apps_none">No se encontraron aplicaciones para el coche</string>
|
||||
<string name="car_apps_sub">Elige qué aplicaciones aparecen en el coche</string>
|
||||
<string name="car_audio_blocked">%1$s no deja que otras aplicaciones reproduzcan su sonido, así que no llega al coche a través de Gearslip. Prueba con otra aplicación de música.</string>
|
||||
<string name="car_audio_switched">Este coche apaga la música por Bluetooth mientras Gearslip funciona, así que ahora la música pasa por Gearslip. Acepta el aviso en tu teléfono.</string>
|
||||
<string name="car_autoplay_note">Retoma la música donde se quedó en cuanto el coche se conecta.</string>
|
||||
<string name="car_density">Densidad de pantalla del coche</string>
|
||||
<string name="car_density_note">Texto y controles más grandes, también en las aplicaciones. Prueba 200 DPI con Tamaño en Normal. Vuelve a conectar el coche o reabre la vista previa para aplicarlo. Si los controles se cortan, elige menos DPI o Desde el coche.</string>
|
||||
<string name="car_directions_over_music">Indicaciones por el canal de música</string>
|
||||
<string name="car_directions_over_music_note">Para un coche que se desconecta o se reinicia tras una indicación hablada. Gearslip mezcla cada indicación en la música en vez de usar el canal de navegación del coche.</string>
|
||||
<string name="car_experimental_note">Funciones aún en pruebas. Puede que no funcionen en todos los coches.</string>
|
||||
<string name="car_player_beside_map_note">El lado del mapa donde se acopla el reproductor al tocar su botón de acoplar.</string>
|
||||
<string name="carset_advanced">Avanzado</string>
|
||||
<string name="carset_experimental_heading">Experimental</string>
|
||||
<string name="carset_fit_needs_car">Conecta un coche para guardar este ajuste para él.</string>
|
||||
<string name="choice_density_car">Desde el coche</string>
|
||||
<string name="choice_density_dpi">%1$d DPI</string>
|
||||
<string name="choice_density_custom">Personalizado…</string>
|
||||
<string name="dialer_missed">Perdida · %1$s</string>
|
||||
<string name="dialer_no_recents">No hay llamadas recientes.</string>
|
||||
<string name="dialer_recents">Recientes</string>
|
||||
<string name="dialer_recents_needs_permission">Gearslip necesita el permiso Registros de llamadas para mostrar las llamadas recientes.</string>
|
||||
<string name="help_bluetooth_first">Conecta el Bluetooth antes del cable.</string>
|
||||
<string name="help_bluetooth_first_detail">Si la música por Bluetooth no se conecta, deja que el coche encuentre primero el teléfono por Bluetooth y luego enchufa el cable. Algunos coches se confunden cuando el cable va primero.</string>
|
||||
<string name="help_usb_data">Permite los datos por USB.</string>
|
||||
<string name="help_usb_data_detail">GrapheneOS puede bloquear los datos en el puerto USB. Si el control del puerto USB-C está en Solo carga, el coche nunca ve el teléfono, ni con un cable de datos. Ponlo para permitir datos.</string>
|
||||
<string name="launcher_in_car">Este dispositivo está conectado a un coche, así que Gearslip está en la pantalla del coche.</string>
|
||||
<string name="launcher_open_gearslip">Abrir Gearslip</string>
|
||||
<string name="nav_arrival">llegada</string>
|
||||
<string name="nav_lane_any">Cualquier carril</string>
|
||||
<string name="nav_lane_from_left">Usa el carril %1$d desde la izquierda</string>
|
||||
<string name="nav_lane_from_right">Usa el carril %1$d desde la derecha</string>
|
||||
<plurals name="nav_lane_left">
|
||||
<item quantity="one">Usa el carril izquierdo</item>
|
||||
<item quantity="many">Usa los %d carriles de la izquierda</item>
|
||||
<item quantity="other">Usa los %d carriles de la izquierda</item>
|
||||
</plurals>
|
||||
<string name="nav_lane_middle">Usa el carril central</string>
|
||||
<string name="nav_lane_middle_lanes">Usa los carriles centrales</string>
|
||||
<plurals name="nav_lane_right">
|
||||
<item quantity="one">Usa el carril derecho</item>
|
||||
<item quantity="many">Usa los %d carriles de la derecha</item>
|
||||
<item quantity="other">Usa los %d carriles de la derecha</item>
|
||||
</plurals>
|
||||
<string name="nav_to_go">restantes</string>
|
||||
<string name="privacy_policy">Política de privacidad</string>
|
||||
<string name="privacy_policy_sub">Qué envía Gearslip, a quién y por qué</string>
|
||||
<string name="settings_bt_address">Dirección Bluetooth del teléfono</string>
|
||||
<string name="settings_bt_address_hint">Cópiala desde los ajustes de Android, en Información del teléfono. Gearslip se la pasa al coche, para que sepa que el teléfono del cable es el emparejado.</string>
|
||||
<string name="settings_bt_address_invalid">Eso no es una dirección Bluetooth. Se parece a 12:34:56:78:9A:BC.</string>
|
||||
<string name="settings_bt_address_off">Sin definir. Solo para un coche que corta el Bluetooth cuando Gearslip se conecta.</string>
|
||||
<string name="settings_use_as_launcher">Usar como lanzador</string>
|
||||
<string name="settings_use_as_launcher_off">Para una radio de coche o una tableta en el salpicadero. La pantalla de coche de Gearslip pasa a ser la pantalla de inicio de este dispositivo.</string>
|
||||
<string name="settings_use_as_launcher_on">Gearslip puede ser la pantalla de inicio de este dispositivo. Elígelo en Aplicación de inicio predeterminada.</string>
|
||||
<string name="status_no_car">El teléfono se está cargando, pero ningún coche ha respondido. Algunos cables solo cargan; prueba uno que transmita datos.</string>
|
||||
<string name="status_no_car_graphene">El teléfono se está cargando, pero ningún coche ha respondido. Comprueba que el control del puerto USB-C, en Seguridad y privacidad, permite datos y no está en Solo carga.</string>
|
||||
<string name="status_open_settings">Abrir ajustes</string>
|
||||
<string name="status_stuck_cable">El coche aún no muestra Gearslip. Desenchufa el cable, espera unos segundos y vuelve a enchufarlo. Si sigue pasando, prueba otro cable o puerto USB y comparte el registro en un issue.</string>
|
||||
<string name="status_stuck_wireless">El coche aún no muestra Gearslip. Apaga y enciende el coche o conéctate con un cable. Si sigue pasando, comparte el registro en un issue.</string>
|
||||
<string name="update_installed_text">Instalada por Wi-Fi mientras no había ningún coche conectado.</string>
|
||||
<string name="update_installed_title">Gearslip actualizado a %1$s</string>
|
||||
<string name="update_silent">Instalar actualizaciones solo</string>
|
||||
<string name="update_silent_sub">Solo versiones finales, por Wi-Fi, cuando no hay ningún coche conectado</string>
|
||||
<string name="assist_listening">Escuchando…</string>
|
||||
<string name="assist_one_moment">Un momento…</string>
|
||||
<string name="assist_examples">Prueba “navigate home” o “play” y un artista. Di “help” para ver más.</string>
|
||||
<string name="assist_grant">Permitir</string>
|
||||
<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>
|
||||
<string name="car_nav_mic">Botón del micrófono</string>
|
||||
<string name="car_nav_mic_note">El botón del asistente en la barra de navegación. El botón de voz del coche funciona igualmente.</string>
|
||||
<string name="car_apps_add">Añadir una app</string>
|
||||
<string name="car_apps_add_note">Para una app que el coche no muestra: su música suena con el reproductor de Gearslip, o sus mensajes aparecen en Mensajes.</string>
|
||||
<string name="car_apps_add_pick">Elige una app</string>
|
||||
<string name="car_apps_add_as">Mostrar %1$s como</string>
|
||||
<string name="car_apps_as_media">Música y audio</string>
|
||||
<string name="car_apps_as_media_note">Lo que suena, con reproducir, pausar y saltar. No hay biblioteca para explorar.</string>
|
||||
<string name="car_apps_as_messaging">Mensajes</string>
|
||||
<string name="car_apps_as_messaging_note">Sus notificaciones, leídas en voz alta, con respuestas.</string>
|
||||
<string name="car_apps_added_by_you">Añadida por ti</string>
|
||||
<string name="car_apps_remove">Quitar %1$s</string>
|
||||
</resources>
|
||||
@@ -200,6 +200,8 @@
|
||||
<string name="car_right">Droite</string>
|
||||
<string name="car_left">Gauche</string>
|
||||
<string name="car_autoplay">Lecture auto à la connexion</string>
|
||||
<string name="car_more_lyrics">Plus de sources de paroles</string>
|
||||
<string name="car_more_lyrics_note">Interroge aussi BetterLyrics et KuGou, qui reçoivent alors le titre et l’artiste du morceau. Désactivé, seul LRCLIB est interrogé.</string>
|
||||
<string name="car_spoken_directions">Guidage vocal</string>
|
||||
<string name="car_spoken_directions_note">Désactivez-le si la voix de votre appli de navigation passe déjà dans la voiture.</string>
|
||||
<string name="car_phone_speech">Utiliser la reconnaissance vocale du téléphone</string>
|
||||
@@ -211,7 +213,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 la page d’accueil avec vos favoris. 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="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>
|
||||
@@ -428,6 +430,8 @@
|
||||
<string name="carset_fit">Ajuster à l’écran</string>
|
||||
<string name="off">Désactivé</string>
|
||||
<string name="on">Activé</string>
|
||||
<string name="carset_lyrics_from">Paroles depuis</string>
|
||||
<string name="carset_more_sources">Plus de sources</string>
|
||||
<string name="carset_hide">Masquer</string>
|
||||
<string name="carset_show">Afficher</string>
|
||||
<string name="carset_phone">Téléphone</string>
|
||||
@@ -437,7 +441,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">Page d’accueil (par défaut) - touchez pour choisir une page</string>
|
||||
<string name="carset_web_default">DuckDuckGo (par défaut) - touchez pour choisir une autre 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>
|
||||
@@ -456,6 +460,7 @@
|
||||
<string name="carset_px">%1$dpx</string>
|
||||
|
||||
<!-- Car media app -->
|
||||
<string name="media_open_on_phone">Ouvrir sur le téléphone</string>
|
||||
<string name="media_now_playing">En cours de lecture</string>
|
||||
<string name="media_up_next">À suivre</string>
|
||||
<string name="media_browse">Parcourir</string>
|
||||
@@ -465,7 +470,7 @@
|
||||
<string name="media_press_play">Appuyez sur lecture pour reprendre où vous en étiez, ou trouvez quelque chose dans Parcourir.</string>
|
||||
|
||||
<string name="media_keeps_library">%1$s garde sa bibliothèque pour lui.</string>
|
||||
<string name="media_needs_access">Gearslip peut quand même le lire une fois qu\'il a l\'accès aux notifications. Autorisez-le sur le téléphone une fois garé.</string>
|
||||
<string name="media_needs_access">Gearslip peut quand même le contrôler s’il a l’accès aux notifications sur le téléphone. Autorisez-le sous Paramètres > Notifications > Accès aux notifications.</string>
|
||||
<string name="media_not_running">%1$s n’est pas lancé.</string>
|
||||
<string name="media_not_running_body">%1$s joue via le lecteur de Gearslip, mais seulement s’il est ouvert sur le téléphone. Ouvrez-le une fois et il continue de jouer ici, dans la voiture.</string>
|
||||
<string name="media_open_app">Ouvrir %1$s</string>
|
||||
@@ -657,7 +662,7 @@
|
||||
<string name="notif_title">Notifications</string>
|
||||
<string name="notif_clear_all">Tout effacer</string>
|
||||
<string name="notif_empty">Rien pour l’instant. Les nouvelles notifications du téléphone apparaîtront ici.</string>
|
||||
<string name="notif_off">Gearslip a besoin de l\'accès aux notifications pour les afficher ici. Autorisez-le sur le téléphone une fois garé.</string>
|
||||
<string name="notif_off">Les notifications sont désactivées. Autorisez Gearslip sous Paramètres > Notifications > Accès aux notifications sur le téléphone.</string>
|
||||
<string name="notif_replied">Répondu</string>
|
||||
|
||||
<!-- Messages -->
|
||||
@@ -837,129 +842,4 @@
|
||||
<string name="settings_language">Langue</string>
|
||||
<string name="settings_language_phone">Comme le téléphone</string>
|
||||
<string name="player_next_chapter">Chapitre suivant</string>
|
||||
<string name="allow_on_phone">Autoriser sur le téléphone</string>
|
||||
<string name="app_all_apps">Toutes les applis</string>
|
||||
<string name="bt_address_car_note">Terminez une étape de configuration sur votre téléphone une fois garé.</string>
|
||||
<string name="bt_address_card_body">Certaines voitures ne restent connectées que si elles connaissent l\'adresse Bluetooth du téléphone. Android la cache aux applis, alors copiez-la depuis À propos du téléphone. Appuyez longuement sur l\'adresse Bluetooth, touchez Copier, puis revenez.</string>
|
||||
<string name="bt_address_card_title">Aidez %1$s à reconnaître ce téléphone</string>
|
||||
<string name="bt_address_not_now">Pas maintenant</string>
|
||||
<string name="bt_address_open">Ouvrir À propos du téléphone</string>
|
||||
<string name="bt_address_type">La saisir plutôt</string>
|
||||
<string name="bt_address_use_body">Gearslip la transmet à votre voiture, pour qu\'elle sache que c\'est le téléphone associé.</string>
|
||||
<string name="bt_address_use_title">Utiliser cette adresse Bluetooth ?</string>
|
||||
<string name="bt_address_your_car">votre voiture</string>
|
||||
<string name="car_apps_finding">Recherche des applis…</string>
|
||||
<string name="car_apps_none">Aucune appli pour voiture trouvée</string>
|
||||
<string name="car_apps_sub">Choisissez les applis affichées dans la voiture</string>
|
||||
<string name="car_audio_blocked">%1$s ne laisse pas d\'autres applis lire son son, il ne peut donc pas atteindre la voiture par Gearslip. Essayez une autre appli de musique.</string>
|
||||
<string name="car_audio_switched">Cette voiture coupe la musique Bluetooth pendant que Gearslip tourne, la musique passe donc maintenant par Gearslip. Acceptez la demande sur votre téléphone.</string>
|
||||
<string name="car_autoplay_note">Reprend la musique là où elle s\'était arrêtée dès que la voiture se connecte.</string>
|
||||
<string name="car_density">Densité de l\'écran de la voiture</string>
|
||||
<string name="car_density_note">Texte et commandes plus grands, y compris dans les applis. Essayez 200 DPI avec Taille sur Normale. Reconnectez la voiture ou rouvrez l\'aperçu pour l\'appliquer. Si des commandes sont coupées, choisissez moins de DPI ou Selon la voiture.</string>
|
||||
<string name="car_directions_over_music">Instructions sur le canal musique</string>
|
||||
<string name="car_directions_over_music_note">Pour une voiture qui se déconnecte ou redémarre après une instruction vocale. Gearslip mélange chaque instruction à la musique au lieu d\'utiliser le canal de navigation de la voiture.</string>
|
||||
<string name="car_experimental_note">Fonctions encore en test. Elles peuvent ne pas marcher dans toutes les voitures.</string>
|
||||
<string name="car_player_beside_map_note">Le côté de la carte où le lecteur s\'ancre quand vous touchez son bouton d\'ancrage.</string>
|
||||
<string name="carset_advanced">Avancé</string>
|
||||
<string name="carset_experimental_heading">Expérimental</string>
|
||||
<string name="carset_fit_needs_car">Connectez une voiture pour enregistrer ce réglage pour elle.</string>
|
||||
<string name="choice_density_car">Selon la voiture</string>
|
||||
<string name="choice_density_dpi">%1$d DPI</string>
|
||||
<string name="choice_density_custom">Personnalisé…</string>
|
||||
<string name="dialer_missed">Manqué · %1$s</string>
|
||||
<string name="dialer_no_recents">Aucun appel récent.</string>
|
||||
<string name="dialer_recents">Récents</string>
|
||||
<string name="dialer_recents_needs_permission">Gearslip a besoin de l\'autorisation Journaux d\'appels pour lister les appels récents.</string>
|
||||
<string name="help_bluetooth_first">Connectez le Bluetooth avant le câble.</string>
|
||||
<string name="help_bluetooth_first_detail">Si la musique en Bluetooth ne se connecte pas, laissez d\'abord la voiture trouver le téléphone en Bluetooth, puis branchez. Certaines voitures s\'embrouillent quand le câble arrive en premier.</string>
|
||||
<string name="help_usb_data">Autorisez les données USB.</string>
|
||||
<string name="help_usb_data_detail">GrapheneOS peut bloquer les données sur le port USB. Si le contrôle du port USB-C est réglé sur Recharge uniquement, la voiture ne voit jamais le téléphone, même avec un câble de données. Autorisez les données.</string>
|
||||
<string name="launcher_in_car">Cet appareil est connecté à une voiture, Gearslip est donc sur l\'écran de la voiture.</string>
|
||||
<string name="launcher_open_gearslip">Ouvrir Gearslip</string>
|
||||
<string name="nav_arrival">arrivée</string>
|
||||
<string name="nav_lane_any">N\'importe quelle voie</string>
|
||||
<string name="nav_lane_from_left">Prenez la voie %1$d en partant de la gauche</string>
|
||||
<string name="nav_lane_from_right">Prenez la voie %1$d en partant de la droite</string>
|
||||
<plurals name="nav_lane_left">
|
||||
<item quantity="one">Prenez la voie de gauche</item>
|
||||
<item quantity="many">Prenez les %d voies de gauche</item>
|
||||
<item quantity="other">Prenez les %d voies de gauche</item>
|
||||
</plurals>
|
||||
<string name="nav_lane_middle">Prenez la voie du milieu</string>
|
||||
<string name="nav_lane_middle_lanes">Prenez les voies du milieu</string>
|
||||
<plurals name="nav_lane_right">
|
||||
<item quantity="one">Prenez la voie de droite</item>
|
||||
<item quantity="many">Prenez les %d voies de droite</item>
|
||||
<item quantity="other">Prenez les %d voies de droite</item>
|
||||
</plurals>
|
||||
<string name="nav_to_go">restants</string>
|
||||
<string name="privacy_policy">Politique de confidentialité</string>
|
||||
<string name="privacy_policy_sub">Ce que Gearslip envoie, à qui et pourquoi</string>
|
||||
<string name="settings_bt_address">Adresse Bluetooth du téléphone</string>
|
||||
<string name="settings_bt_address_hint">Copiez-la depuis les paramètres d\'Android, dans À propos du téléphone. Gearslip la transmet à la voiture, pour qu\'elle sache que le téléphone sur le câble est celui associé.</string>
|
||||
<string name="settings_bt_address_invalid">Ce n\'est pas une adresse Bluetooth. Elle ressemble à 12:34:56:78:9A:BC.</string>
|
||||
<string name="settings_bt_address_off">Non définie. Seulement pour une voiture qui coupe le Bluetooth quand Gearslip se connecte.</string>
|
||||
<string name="settings_use_as_launcher">Utiliser comme lanceur</string>
|
||||
<string name="settings_use_as_launcher_off">Pour un autoradio ou une tablette sur le tableau de bord. L\'écran voiture de Gearslip devient l\'écran d\'accueil de cet appareil.</string>
|
||||
<string name="settings_use_as_launcher_on">Gearslip peut être l\'écran d\'accueil de cet appareil. Choisissez-le dans Application d\'accueil par défaut.</string>
|
||||
<string name="status_no_car">Le téléphone charge, mais aucune voiture n\'a répondu. Certains câbles ne font que charger ; essayez-en un qui transmet les données.</string>
|
||||
<string name="status_no_car_graphene">Le téléphone charge, mais aucune voiture n\'a répondu. Vérifiez que le contrôle du port USB-C, dans Sécurité et confidentialité, autorise les données et n\'est pas sur Recharge uniquement.</string>
|
||||
<string name="status_open_settings">Ouvrir les paramètres</string>
|
||||
<string name="status_stuck_cable">La voiture n\'affiche pas encore Gearslip. Débranchez le câble, attendez quelques secondes et rebranchez-le. Si cela continue, essayez un autre câble ou port USB, et partagez le journal dans un ticket.</string>
|
||||
<string name="status_stuck_wireless">La voiture n\'affiche pas encore Gearslip. Éteignez et rallumez la voiture, ou connectez-vous avec un câble. Si cela continue, partagez le journal dans un ticket.</string>
|
||||
<string name="update_installed_text">Installée en Wi-Fi pendant qu\'aucune voiture n\'était connectée.</string>
|
||||
<string name="update_installed_title">Gearslip mis à jour en %1$s</string>
|
||||
<string name="update_silent">Installer les mises à jour tout seul</string>
|
||||
<string name="update_silent_sub">Versions finales uniquement, en Wi-Fi, quand aucune voiture n\'est connectée</string>
|
||||
<string name="assist_listening">J\'écoute…</string>
|
||||
<string name="assist_one_moment">Un instant…</string>
|
||||
<string name="assist_examples">Essayez « navigate home » ou « play » suivi d\'un artiste. Dites « help » pour en savoir plus.</string>
|
||||
<string name="assist_grant">Autoriser</string>
|
||||
<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>
|
||||
<string name="car_nav_mic">Bouton micro</string>
|
||||
<string name="car_nav_mic_note">Le bouton de l’assistant dans la barre de navigation. Le bouton vocal de la voiture fonctionne dans tous les cas.</string>
|
||||
<string name="car_apps_add">Ajouter une appli</string>
|
||||
<string name="car_apps_add_note">Pour une appli que la voiture n’affiche pas : sa musique passe par le lecteur de Gearslip, ou ses messages s’affichent dans Messages.</string>
|
||||
<string name="car_apps_add_pick">Choisir une appli</string>
|
||||
<string name="car_apps_add_as">Afficher %1$s comme</string>
|
||||
<string name="car_apps_as_media">Musique et audio</string>
|
||||
<string name="car_apps_as_media_note">Lecture en cours, avec lecture, pause et piste suivante. Pas de bibliothèque à parcourir.</string>
|
||||
<string name="car_apps_as_messaging">Messages</string>
|
||||
<string name="car_apps_as_messaging_note">Ses notifications, lues à voix haute, avec réponses.</string>
|
||||
<string name="car_apps_added_by_you">Ajoutée par vous</string>
|
||||
<string name="car_apps_remove">Retirer %1$s</string>
|
||||
</resources>
|
||||
@@ -200,6 +200,8 @@
|
||||
<string name="car_right">Destra</string>
|
||||
<string name="car_left">Sinistra</string>
|
||||
<string name="car_autoplay">Riproduci alla connessione</string>
|
||||
<string name="car_more_lyrics">Più fonti per i testi</string>
|
||||
<string name="car_more_lyrics_note">Chiede anche a BetterLyrics e KuGou, che ricevono così titolo e artista del brano. Se disattivato, chiede solo a LRCLIB.</string>
|
||||
<string name="car_spoken_directions">Indicazioni vocali</string>
|
||||
<string name="car_spoken_directions_note">Disattivale se la voce della tua app di navigazione si sente già in auto.</string>
|
||||
<string name="car_phone_speech">Usa il riconoscimento vocale del telefono</string>
|
||||
@@ -211,7 +213,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 in auto. Se vuota, apre la pagina iniziale con i tuoi segnalibri. Si applica alla 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="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>
|
||||
@@ -428,6 +430,8 @@
|
||||
<string name="carset_fit">Adatta allo schermo</string>
|
||||
<string name="off">Off</string>
|
||||
<string name="on">On</string>
|
||||
<string name="carset_lyrics_from">Testi da</string>
|
||||
<string name="carset_more_sources">Più fonti</string>
|
||||
<string name="carset_hide">Nascondi</string>
|
||||
<string name="carset_show">Mostra</string>
|
||||
<string name="carset_phone">Telefono</string>
|
||||
@@ -437,7 +441,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">Pagina iniziale (predefinita): tocca per impostare una pagina</string>
|
||||
<string name="carset_web_default">DuckDuckGo (predefinito) - tocca per impostare un’altra 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>
|
||||
@@ -456,6 +460,7 @@
|
||||
<string name="carset_px">%1$dpx</string>
|
||||
|
||||
<!-- Car media app -->
|
||||
<string name="media_open_on_phone">Apri sul telefono</string>
|
||||
<string name="media_now_playing">In riproduzione</string>
|
||||
<string name="media_up_next">A seguire</string>
|
||||
<string name="media_browse">Sfoglia</string>
|
||||
@@ -465,7 +470,7 @@
|
||||
<string name="media_press_play">Premi play per riprendere da dove eri, o trova qualcosa in Sfoglia.</string>
|
||||
|
||||
<string name="media_keeps_library">%1$s tiene la sua libreria per sé.</string>
|
||||
<string name="media_needs_access">Gearslip può comunque riprodurlo appena ha l\'accesso alle notifiche. Consentilo sul telefono quando sei parcheggiato.</string>
|
||||
<string name="media_needs_access">Gearslip può comunque controllarla se ha l’accesso alle notifiche sul telefono. Consentilo in Impostazioni > Notifiche > Accesso alle notifiche.</string>
|
||||
<string name="media_not_running">%1$s non è in esecuzione.</string>
|
||||
<string name="media_not_running_body">%1$s suona tramite il lettore di Gearslip, ma solo mentre è aperta sul telefono. Aprila una volta e continua a suonare qui in auto.</string>
|
||||
<string name="media_open_app">Apri %1$s</string>
|
||||
@@ -657,7 +662,7 @@
|
||||
<string name="notif_title">Notifiche</string>
|
||||
<string name="notif_clear_all">Cancella tutto</string>
|
||||
<string name="notif_empty">Ancora niente. Le nuove notifiche del telefono compariranno qui.</string>
|
||||
<string name="notif_off">Gearslip ha bisogno dell\'accesso alle notifiche per mostrarle qui. Consentilo sul telefono quando sei parcheggiato.</string>
|
||||
<string name="notif_off">Le notifiche sono disattivate. Consenti Gearslip in Impostazioni > Notifiche > Accesso alle notifiche sul telefono.</string>
|
||||
<string name="notif_replied">Risposto</string>
|
||||
|
||||
<!-- Messages -->
|
||||
@@ -837,129 +842,4 @@
|
||||
<string name="settings_language">Lingua</string>
|
||||
<string name="settings_language_phone">Come il telefono</string>
|
||||
<string name="player_next_chapter">Capitolo successivo</string>
|
||||
<string name="allow_on_phone">Consenti sul telefono</string>
|
||||
<string name="app_all_apps">Tutte le app</string>
|
||||
<string name="bt_address_car_note">Completa un passaggio di configurazione sul telefono quando sei parcheggiato.</string>
|
||||
<string name="bt_address_card_body">Alcune auto restano connesse solo se conoscono l\'indirizzo Bluetooth del telefono. Android lo nasconde alle app, quindi copialo da Informazioni sul telefono. Tieni premuto l\'indirizzo Bluetooth, tocca Copia e torna qui.</string>
|
||||
<string name="bt_address_card_title">Aiuta %1$s a riconoscere questo telefono</string>
|
||||
<string name="bt_address_not_now">Non ora</string>
|
||||
<string name="bt_address_open">Apri Informazioni sul telefono</string>
|
||||
<string name="bt_address_type">Scrivilo a mano</string>
|
||||
<string name="bt_address_use_body">Gearslip lo passa alla tua auto, così sa che questo è il telefono associato.</string>
|
||||
<string name="bt_address_use_title">Usare questo indirizzo Bluetooth?</string>
|
||||
<string name="bt_address_your_car">la tua auto</string>
|
||||
<string name="car_apps_finding">Ricerca delle app…</string>
|
||||
<string name="car_apps_none">Nessuna app per auto trovata</string>
|
||||
<string name="car_apps_sub">Scegli quali app compaiono in auto</string>
|
||||
<string name="car_audio_blocked">%1$s non lascia ad altre app riprodurre il suo audio, quindi non arriva all\'auto tramite Gearslip. Prova un\'altra app di musica.</string>
|
||||
<string name="car_audio_switched">Quest\'auto spegne la musica Bluetooth mentre Gearslip è attivo, quindi ora la musica passa da Gearslip. Consenti la richiesta sul telefono.</string>
|
||||
<string name="car_autoplay_note">Riprende la musica da dove si era fermata appena l\'auto si connette.</string>
|
||||
<string name="car_density">Densità dello schermo dell\'auto</string>
|
||||
<string name="car_density_note">Testo e comandi più grandi, anche nelle app. Prova 200 DPI con Dimensione su Normale. Ricollega l\'auto o riapri l\'anteprima per applicarlo. Se i comandi vengono tagliati, scegli meno DPI o Dall\'auto.</string>
|
||||
<string name="car_directions_over_music">Indicazioni sul canale musica</string>
|
||||
<string name="car_directions_over_music_note">Per un\'auto che si disconnette o si riavvia dopo un\'indicazione vocale. Gearslip mescola ogni indicazione nella musica invece di usare il canale di navigazione dell\'auto.</string>
|
||||
<string name="car_experimental_note">Funzioni ancora in prova. Potrebbero non funzionare su tutte le auto.</string>
|
||||
<string name="car_player_beside_map_note">Il lato della mappa a cui si aggancia il player quando tocchi il suo pulsante di aggancio.</string>
|
||||
<string name="carset_advanced">Avanzate</string>
|
||||
<string name="carset_experimental_heading">Sperimentale</string>
|
||||
<string name="carset_fit_needs_car">Collega un\'auto per salvare questa regolazione per lei.</string>
|
||||
<string name="choice_density_car">Dall\'auto</string>
|
||||
<string name="choice_density_dpi">%1$d DPI</string>
|
||||
<string name="choice_density_custom">Personalizzata…</string>
|
||||
<string name="dialer_missed">Persa · %1$s</string>
|
||||
<string name="dialer_no_recents">Nessuna chiamata recente.</string>
|
||||
<string name="dialer_recents">Recenti</string>
|
||||
<string name="dialer_recents_needs_permission">Gearslip ha bisogno dell\'autorizzazione Registri chiamate per mostrare le chiamate recenti.</string>
|
||||
<string name="help_bluetooth_first">Collega il Bluetooth prima del cavo.</string>
|
||||
<string name="help_bluetooth_first_detail">Se la musica via Bluetooth non si collega, lascia che l\'auto trovi prima il telefono via Bluetooth, poi collega il cavo. Alcune auto si confondono quando il cavo arriva per primo.</string>
|
||||
<string name="help_usb_data">Consenti i dati USB.</string>
|
||||
<string name="help_usb_data_detail">GrapheneOS può bloccare i dati sulla porta USB. Se il controllo della porta USB-C è su Solo ricarica, l\'auto non vede mai il telefono, anche con un cavo dati. Impostalo per consentire i dati.</string>
|
||||
<string name="launcher_in_car">Questo dispositivo è collegato a un\'auto, quindi Gearslip è sullo schermo dell\'auto.</string>
|
||||
<string name="launcher_open_gearslip">Apri Gearslip</string>
|
||||
<string name="nav_arrival">arrivo</string>
|
||||
<string name="nav_lane_any">Qualsiasi corsia</string>
|
||||
<string name="nav_lane_from_left">Usa la corsia %1$d da sinistra</string>
|
||||
<string name="nav_lane_from_right">Usa la corsia %1$d da destra</string>
|
||||
<plurals name="nav_lane_left">
|
||||
<item quantity="one">Usa la corsia di sinistra</item>
|
||||
<item quantity="many">Usa le %d corsie di sinistra</item>
|
||||
<item quantity="other">Usa le %d corsie di sinistra</item>
|
||||
</plurals>
|
||||
<string name="nav_lane_middle">Usa la corsia centrale</string>
|
||||
<string name="nav_lane_middle_lanes">Usa le corsie centrali</string>
|
||||
<plurals name="nav_lane_right">
|
||||
<item quantity="one">Usa la corsia di destra</item>
|
||||
<item quantity="many">Usa le %d corsie di destra</item>
|
||||
<item quantity="other">Usa le %d corsie di destra</item>
|
||||
</plurals>
|
||||
<string name="nav_to_go">mancanti</string>
|
||||
<string name="privacy_policy">Informativa sulla privacy</string>
|
||||
<string name="privacy_policy_sub">Cosa invia Gearslip, a chi e perché</string>
|
||||
<string name="settings_bt_address">Indirizzo Bluetooth del telefono</string>
|
||||
<string name="settings_bt_address_hint">Copialo dalle impostazioni di Android, in Informazioni sul telefono. Gearslip lo passa all\'auto, così sa che il telefono sul cavo è quello associato.</string>
|
||||
<string name="settings_bt_address_invalid">Questo non è un indirizzo Bluetooth. Somiglia a 12:34:56:78:9A:BC.</string>
|
||||
<string name="settings_bt_address_off">Non impostato. Solo per un\'auto che spegne il Bluetooth quando Gearslip si connette.</string>
|
||||
<string name="settings_use_as_launcher">Usa come launcher</string>
|
||||
<string name="settings_use_as_launcher_off">Per un\'autoradio o un tablet sul cruscotto. La schermata auto di Gearslip diventa la schermata Home di questo dispositivo.</string>
|
||||
<string name="settings_use_as_launcher_on">Gearslip può essere la schermata Home di questo dispositivo. Sceglilo in App Home predefinita.</string>
|
||||
<string name="status_no_car">Il telefono è in carica, ma nessuna auto ha risposto. Alcuni cavi caricano soltanto; prova un cavo che trasporta dati.</string>
|
||||
<string name="status_no_car_graphene">Il telefono è in carica, ma nessuna auto ha risposto. Controlla che il controllo della porta USB-C, in Sicurezza e privacy, consenta i dati e non sia su Solo ricarica.</string>
|
||||
<string name="status_open_settings">Apri impostazioni</string>
|
||||
<string name="status_stuck_cable">L\'auto non mostra ancora Gearslip. Scollega il cavo, aspetta qualche secondo e ricollegalo. Se succede ancora, prova un altro cavo o porta USB e condividi il log in una issue.</string>
|
||||
<string name="status_stuck_wireless">L\'auto non mostra ancora Gearslip. Spegni e riaccendi l\'auto, o collegati con un cavo. Se succede ancora, condividi il log in una issue.</string>
|
||||
<string name="update_installed_text">Installato via Wi-Fi mentre nessuna auto era collegata.</string>
|
||||
<string name="update_installed_title">Gearslip aggiornato a %1$s</string>
|
||||
<string name="update_silent">Installa gli aggiornamenti da solo</string>
|
||||
<string name="update_silent_sub">Solo versioni definitive, via Wi-Fi, quando nessuna auto è collegata</string>
|
||||
<string name="assist_listening">Ti ascolto…</string>
|
||||
<string name="assist_one_moment">Un momento…</string>
|
||||
<string name="assist_examples">Prova “navigate home” o “play” e un artista. Di’ “help” per altro.</string>
|
||||
<string name="assist_grant">Consenti</string>
|
||||
<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>
|
||||
<string name="car_nav_mic">Pulsante microfono</string>
|
||||
<string name="car_nav_mic_note">Il pulsante dell’assistente nella barra di navigazione. Il tasto vocale dell’auto funziona comunque.</string>
|
||||
<string name="car_apps_add">Aggiungi un’app</string>
|
||||
<string name="car_apps_add_note">Per un’app che l’auto non mostra: la sua musica passa dal lettore di Gearslip, oppure i suoi messaggi compaiono in Messaggi.</string>
|
||||
<string name="car_apps_add_pick">Scegli un’app</string>
|
||||
<string name="car_apps_add_as">Mostra %1$s come</string>
|
||||
<string name="car_apps_as_media">Musica e audio</string>
|
||||
<string name="car_apps_as_media_note">In riproduzione, con play, pausa e salto. Nessuna libreria da sfogliare.</string>
|
||||
<string name="car_apps_as_messaging">Messaggi</string>
|
||||
<string name="car_apps_as_messaging_note">Le sue notifiche, lette ad alta voce, con risposte.</string>
|
||||
<string name="car_apps_added_by_you">Aggiunta da te</string>
|
||||
<string name="car_apps_remove">Rimuovi %1$s</string>
|
||||
</resources>
|
||||
@@ -200,6 +200,8 @@
|
||||
<string name="car_right">右</string>
|
||||
<string name="car_left">左</string>
|
||||
<string name="car_autoplay">接続時に自動再生</string>
|
||||
<string name="car_more_lyrics">歌詞の取得元を増やす</string>
|
||||
<string name="car_more_lyrics_note">BetterLyrics と KuGou にも問い合わせ、曲名とアーティスト名が送られます。オフにすると LRCLIB のみに問い合わせます。</string>
|
||||
<string name="car_spoken_directions">音声案内</string>
|
||||
<string name="car_spoken_directions_note">地図アプリ自身の音声がすでに車で流れている場合はオフにしてください。</string>
|
||||
<string name="car_phone_speech">スマートフォンの音声認識を使う</string>
|
||||
@@ -211,7 +213,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">車のブラウザアプリで表示されます。空欄ならブックマーク付きのスタートページが開きます。次の接続から反映されます。</string>
|
||||
<string name="web_page_note">車のブラウザアプリで表示します。空欄の場合は DuckDuckGo を開きます。次回の接続から有効です。</string>
|
||||
<string name="car_audio">車のオーディオ</string>
|
||||
<string name="car_audio_bt">Bluetooth(おすすめ)</string>
|
||||
<string name="car_audio_bt_sub">運転のたびの確認がなく、地図アプリの音声も車から聞こえます</string>
|
||||
@@ -426,6 +428,8 @@
|
||||
<string name="carset_fit">画面に合わせる</string>
|
||||
<string name="off">オフ</string>
|
||||
<string name="on">オン</string>
|
||||
<string name="carset_lyrics_from">歌詞の取得元</string>
|
||||
<string name="carset_more_sources">取得元を増やす</string>
|
||||
<string name="carset_hide">隠す</string>
|
||||
<string name="carset_show">表示</string>
|
||||
<string name="carset_phone">電話</string>
|
||||
@@ -435,7 +439,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">スタートページ(デフォルト)- タップしてページを設定</string>
|
||||
<string name="carset_web_default">DuckDuckGo(デフォルト)- タップして別のページを設定</string>
|
||||
<string name="carset_web_note">ランチャーのブラウザアプリで表示します。次回の接続から有効です。</string>
|
||||
<string name="carset_screen_fit">画面の合わせ方</string>
|
||||
<string name="carset_top">上</string>
|
||||
@@ -454,6 +458,7 @@
|
||||
<string name="carset_px">%1$dpx</string>
|
||||
|
||||
<!-- Car media app -->
|
||||
<string name="media_open_on_phone">スマートフォンで開く</string>
|
||||
<string name="media_now_playing">再生中</string>
|
||||
<string name="media_up_next">次に再生</string>
|
||||
<string name="media_browse">ブラウズ</string>
|
||||
@@ -463,7 +468,7 @@
|
||||
<string name="media_press_play">再生を押すと続きから再生します。ブラウズから探すこともできます。</string>
|
||||
|
||||
<string name="media_keeps_library">%1$s はライブラリを公開していません。</string>
|
||||
<string name="media_needs_access">通知へのアクセスがあれば、Gearslip で再生できます。駐車中にスマホで許可してください。</string>
|
||||
<string name="media_needs_access">スマートフォンで通知へのアクセスを許可すれば、Gearslip で操作できます。[設定]>[通知]>[通知へのアクセス]で許可してください。</string>
|
||||
<string name="media_not_running">%1$s が起動していません。</string>
|
||||
<string name="media_not_running_body">%1$s は Gearslip のプレーヤーで再生されますが、スマートフォンで開いている間だけです。一度開けば、車でそのまま再生が続きます。</string>
|
||||
<string name="media_open_app">%1$s を開く</string>
|
||||
@@ -653,7 +658,7 @@
|
||||
<string name="notif_title">通知</string>
|
||||
<string name="notif_clear_all">すべて消去</string>
|
||||
<string name="notif_empty">まだ何もありません。スマートフォンの新しい通知がここに表示されます。</string>
|
||||
<string name="notif_off">ここに通知を表示するには、Gearslip に通知へのアクセスが必要です。駐車中にスマホで許可してください。</string>
|
||||
<string name="notif_off">通知がオフです。スマートフォンの[設定]>[通知]>[通知へのアクセス]で Gearslip を許可してください。</string>
|
||||
<string name="notif_replied">返信済み</string>
|
||||
|
||||
<!-- Messages -->
|
||||
@@ -821,125 +826,4 @@
|
||||
<string name="settings_language">言語</string>
|
||||
<string name="settings_language_phone">スマートフォンと同じ</string>
|
||||
<string name="player_next_chapter">次のチャプター</string>
|
||||
<string name="allow_on_phone">スマホで許可</string>
|
||||
<string name="app_all_apps">すべてのアプリ</string>
|
||||
<string name="bt_address_car_note">駐車中に、スマホで設定を 1 つ済ませてください。</string>
|
||||
<string name="bt_address_card_body">車によっては、スマホの Bluetooth アドレスを知っているときだけ接続が続きます。Android はアプリにこれを教えないため、「デバイス情報」からコピーしてください。そこで Bluetooth アドレスを長押しし、[コピー]をタップしてから戻ってきてください。</string>
|
||||
<string name="bt_address_card_title">%1$s にこのスマホを覚えてもらう</string>
|
||||
<string name="bt_address_not_now">後で</string>
|
||||
<string name="bt_address_open">「デバイス情報」を開く</string>
|
||||
<string name="bt_address_type">手入力する</string>
|
||||
<string name="bt_address_use_body">Gearslip がこれを車に渡すので、車はこれがペア設定済みのスマホだとわかります。</string>
|
||||
<string name="bt_address_use_title">この Bluetooth アドレスを使いますか?</string>
|
||||
<string name="bt_address_your_car">車</string>
|
||||
<string name="car_apps_finding">アプリを探しています…</string>
|
||||
<string name="car_apps_none">車用アプリが見つかりません</string>
|
||||
<string name="car_apps_sub">車に表示するアプリを選びます</string>
|
||||
<string name="car_audio_blocked">%1$s は他のアプリに音声の再生を許可していないため、Gearslip 経由では車に届きません。別の音楽アプリをお試しください。</string>
|
||||
<string name="car_audio_switched">この車は Gearslip の実行中に Bluetooth の音楽をオフにするため、音楽は Gearslip 経由で流れるようになりました。スマホに表示される確認を許可してください。</string>
|
||||
<string name="car_autoplay_note">車がつながったら、止まったところから音楽を再開します。</string>
|
||||
<string name="car_density">車の画面密度</string>
|
||||
<string name="car_density_note">アプリ内も含めて文字やボタンを大きくします。サイズを標準にして 200 DPI をお試しください。反映するには車を再接続するか、プレビューを開き直してください。ボタンが切れる場合は、DPI を下げるか「車に合わせる」を選んでください。</string>
|
||||
<string name="car_directions_over_music">音楽チャンネルで案内</string>
|
||||
<string name="car_directions_over_music_note">音声案内のあとに接続が切れたり再起動したりする車向けです。Gearslip は車の案内チャンネルを使わず、案内を音楽に重ねて流します。</string>
|
||||
<string name="car_experimental_note">テスト中の機能です。すべての車で動くとは限りません。</string>
|
||||
<string name="car_player_beside_map_note">ドッキングボタンをタップしたときに、プレーヤーが付く地図の側です。</string>
|
||||
<string name="carset_advanced">詳細設定</string>
|
||||
<string name="carset_experimental_heading">試験運用</string>
|
||||
<string name="carset_fit_needs_car">この調整を保存するには車を接続してください。</string>
|
||||
<string name="choice_density_car">車に合わせる</string>
|
||||
<string name="choice_density_dpi">%1$d DPI</string>
|
||||
<string name="choice_density_custom">カスタム…</string>
|
||||
<string name="dialer_missed">不在着信 · %1$s</string>
|
||||
<string name="dialer_no_recents">最近の通話はありません。</string>
|
||||
<string name="dialer_recents">履歴</string>
|
||||
<string name="dialer_recents_needs_permission">最近の通話を表示するには、Gearslip に通話履歴の権限が必要です。</string>
|
||||
<string name="help_bluetooth_first">ケーブルより先に Bluetooth をつなぐ。</string>
|
||||
<string name="help_bluetooth_first_detail">Bluetooth で音楽がつながらない場合は、先に車に Bluetooth でスマホを見つけさせてから、ケーブルを挿してください。ケーブルが先だと混乱する車があります。</string>
|
||||
<string name="help_usb_data">USB データを許可する。</string>
|
||||
<string name="help_usb_data_detail">GrapheneOS は USB ポートのデータをブロックできます。USB-C ポートの管理が「充電のみ」になっていると、データケーブルでも車はスマホを認識しません。データを許可する設定にしてください。</string>
|
||||
<string name="launcher_in_car">このデバイスは車に接続されているので、Gearslip は車の画面に表示されています。</string>
|
||||
<string name="launcher_open_gearslip">Gearslip を開く</string>
|
||||
<string name="nav_arrival">到着</string>
|
||||
<string name="nav_lane_any">どの車線でも可</string>
|
||||
<string name="nav_lane_from_left">左から %1$d 番目の車線へ</string>
|
||||
<string name="nav_lane_from_right">右から %1$d 番目の車線へ</string>
|
||||
<plurals name="nav_lane_left">
|
||||
<item quantity="other">左側の %d 車線へ</item>
|
||||
</plurals>
|
||||
<string name="nav_lane_middle">中央の車線へ</string>
|
||||
<string name="nav_lane_middle_lanes">中央の車線へ</string>
|
||||
<plurals name="nav_lane_right">
|
||||
<item quantity="other">右側の %d 車線へ</item>
|
||||
</plurals>
|
||||
<string name="nav_to_go">残り</string>
|
||||
<string name="privacy_policy">プライバシー ポリシー</string>
|
||||
<string name="privacy_policy_sub">Gearslip が何を、誰に、なぜ送るか</string>
|
||||
<string name="settings_bt_address">スマホの Bluetooth アドレス</string>
|
||||
<string name="settings_bt_address_hint">Android の設定の「デバイス情報」からコピーしてください。Gearslip がこれを車に渡すので、車はケーブルにつながったスマホがペア設定済みのものだとわかります。</string>
|
||||
<string name="settings_bt_address_invalid">Bluetooth アドレスではありません。12:34:56:78:9A:BC のような形式です。</string>
|
||||
<string name="settings_bt_address_off">未設定。Gearslip の接続時に Bluetooth を切る車でのみ使います。</string>
|
||||
<string name="settings_use_as_launcher">ランチャーとして使う</string>
|
||||
<string name="settings_use_as_launcher_off">カーナビやダッシュボードのタブレット向けです。Gearslip の車画面がこのデバイスのホーム画面になります。</string>
|
||||
<string name="settings_use_as_launcher_on">Gearslip をこのデバイスのホーム画面にできます。「デフォルトのホームアプリ」で選んでください。</string>
|
||||
<string name="status_no_car">スマホは充電中ですが、車から応答がありません。充電専用のケーブルもあるので、データを通すケーブルをお試しください。</string>
|
||||
<string name="status_no_car_graphene">スマホは充電中ですが、車から応答がありません。「セキュリティとプライバシー」の USB-C ポートの管理がデータを許可していて、「充電のみ」になっていないか確認してください。</string>
|
||||
<string name="status_open_settings">設定を開く</string>
|
||||
<string name="status_stuck_cable">車にまだ Gearslip が表示されていません。ケーブルを抜いて数秒待ち、挿し直してください。何度も起きる場合は、別のケーブルや USB ポートを試し、issue でログを共有してください。</string>
|
||||
<string name="status_stuck_wireless">車にまだ Gearslip が表示されていません。車の電源を入れ直すか、ケーブルで接続してください。何度も起きる場合は、issue でログを共有してください。</string>
|
||||
<string name="update_installed_text">車が接続されていないときに Wi-Fi でインストールしました。</string>
|
||||
<string name="update_installed_title">Gearslip を %1$s に更新しました</string>
|
||||
<string name="update_silent">アップデートを自動でインストール</string>
|
||||
<string name="update_silent_sub">正式版のみ、Wi-Fi 接続時、車が接続されていないときに</string>
|
||||
<string name="assist_listening">聞いています…</string>
|
||||
<string name="assist_one_moment">少々お待ちください…</string>
|
||||
<string name="assist_examples">「navigate home」や「play」+アーティスト名を試してください。「help」で一覧を表示します。</string>
|
||||
<string name="assist_grant">許可</string>
|
||||
<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>
|
||||
<string name="car_nav_mic">マイクボタン</string>
|
||||
<string name="car_nav_mic_note">ナビゲーションバーのアシスタントボタンです。車の音声ボタンはどちらでも使えます。</string>
|
||||
<string name="car_apps_add">アプリを追加</string>
|
||||
<string name="car_apps_add_note">車に表示されないアプリ向けです。音楽は Gearslip のプレーヤーで再生され、メッセージはメッセージ画面に表示されます。</string>
|
||||
<string name="car_apps_add_pick">アプリを選択</string>
|
||||
<string name="car_apps_add_as">%1$s の表示方法</string>
|
||||
<string name="car_apps_as_media">音楽とオーディオ</string>
|
||||
<string name="car_apps_as_media_note">再生中の曲と、再生・一時停止・スキップ。ライブラリの閲覧はできません。</string>
|
||||
<string name="car_apps_as_messaging">メッセージ</string>
|
||||
<string name="car_apps_as_messaging_note">通知を読み上げ、返信できます。</string>
|
||||
<string name="car_apps_added_by_you">自分で追加</string>
|
||||
<string name="car_apps_remove">%1$s を削除</string>
|
||||
</resources>
|
||||
@@ -200,6 +200,8 @@
|
||||
<string name="car_right">오른쪽</string>
|
||||
<string name="car_left">왼쪽</string>
|
||||
<string name="car_autoplay">연결 시 자동 재생</string>
|
||||
<string name="car_more_lyrics">가사 출처 더 보기</string>
|
||||
<string name="car_more_lyrics_note">BetterLyrics와 KuGou에도 요청하며, 이때 노래 제목과 아티스트가 전달됩니다. 끄면 LRCLIB에만 요청합니다.</string>
|
||||
<string name="car_spoken_directions">음성 안내</string>
|
||||
<string name="car_spoken_directions_note">지도 앱 자체의 음성이 이미 차량에서 나온다면 끄세요.</string>
|
||||
<string name="car_phone_speech">휴대전화의 음성 인식 사용</string>
|
||||
@@ -211,7 +213,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">차량의 브라우저 앱에 표시됩니다. 비워 두면 북마크가 있는 시작 페이지가 열립니다. 다음 연결부터 적용됩니다.</string>
|
||||
<string name="web_page_note">차량의 브라우저 앱에서 표시됩니다. 비워 두면 DuckDuckGo가 열립니다. 다음 연결부터 적용됩니다.</string>
|
||||
<string name="car_audio">차량 오디오</string>
|
||||
<string name="car_audio_bt">블루투스(권장)</string>
|
||||
<string name="car_audio_bt_sub">운전할 때마다 묻지 않으며, 지도 앱의 음성도 차량에서 들립니다</string>
|
||||
@@ -426,6 +428,8 @@
|
||||
<string name="carset_fit">화면에 맞추기</string>
|
||||
<string name="off">끄기</string>
|
||||
<string name="on">켜기</string>
|
||||
<string name="carset_lyrics_from">가사 출처</string>
|
||||
<string name="carset_more_sources">출처 더 보기</string>
|
||||
<string name="carset_hide">숨기기</string>
|
||||
<string name="carset_show">표시</string>
|
||||
<string name="carset_phone">휴대전화</string>
|
||||
@@ -435,7 +439,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">시작 페이지(기본값) - 탭하여 페이지 설정</string>
|
||||
<string name="carset_web_default">DuckDuckGo(기본값) - 탭하여 다른 페이지 설정</string>
|
||||
<string name="carset_web_note">런처의 브라우저 앱에서 표시됩니다. 다음 연결부터 적용됩니다.</string>
|
||||
<string name="carset_screen_fit">화면 맞춤</string>
|
||||
<string name="carset_top">위</string>
|
||||
@@ -454,6 +458,7 @@
|
||||
<string name="carset_px">%1$dpx</string>
|
||||
|
||||
<!-- Car media app -->
|
||||
<string name="media_open_on_phone">휴대전화에서 열기</string>
|
||||
<string name="media_now_playing">지금 재생 중</string>
|
||||
<string name="media_up_next">다음 곡</string>
|
||||
<string name="media_browse">둘러보기</string>
|
||||
@@ -463,7 +468,7 @@
|
||||
<string name="media_press_play">재생을 누르면 멈춘 곳부터 이어서 들을 수 있고, 둘러보기에서 다른 곡을 찾을 수도 있습니다.</string>
|
||||
|
||||
<string name="media_keeps_library">%1$s은(는) 라이브러리를 공개하지 않습니다.</string>
|
||||
<string name="media_needs_access">알림 액세스 권한이 있으면 Gearslip으로 재생할 수 있습니다. 주차했을 때 휴대전화에서 허용하세요.</string>
|
||||
<string name="media_needs_access">휴대전화에서 알림 액세스를 허용하면 Gearslip이 제어할 수 있습니다. 설정 > 알림 > 알림 액세스에서 허용하세요.</string>
|
||||
<string name="media_not_running">%1$s이(가) 실행 중이 아닙니다.</string>
|
||||
<string name="media_not_running_body">%1$s은(는) Gearslip 플레이어로 재생되지만, 휴대전화에서 열려 있을 때만 가능합니다. 한 번 열면 차량에서 계속 재생됩니다.</string>
|
||||
<string name="media_open_app">%1$s 열기</string>
|
||||
@@ -653,7 +658,7 @@
|
||||
<string name="notif_title">알림</string>
|
||||
<string name="notif_clear_all">모두 지우기</string>
|
||||
<string name="notif_empty">아직 아무것도 없습니다. 휴대전화의 새 알림이 여기에 표시됩니다.</string>
|
||||
<string name="notif_off">여기에 알림을 표시하려면 Gearslip에 알림 액세스 권한이 필요합니다. 주차했을 때 휴대전화에서 허용하세요.</string>
|
||||
<string name="notif_off">알림이 꺼져 있습니다. 휴대전화의 설정 > 알림 > 알림 액세스에서 Gearslip을 허용하세요.</string>
|
||||
<string name="notif_replied">답장함</string>
|
||||
|
||||
<!-- Messages -->
|
||||
@@ -821,125 +826,4 @@
|
||||
<string name="settings_language">언어</string>
|
||||
<string name="settings_language_phone">휴대전화와 동일</string>
|
||||
<string name="player_next_chapter">다음 챕터</string>
|
||||
<string name="allow_on_phone">휴대전화에서 허용</string>
|
||||
<string name="app_all_apps">모든 앱</string>
|
||||
<string name="bt_address_car_note">주차했을 때 휴대전화에서 설정 단계 하나를 마쳐 주세요.</string>
|
||||
<string name="bt_address_card_body">일부 차량은 휴대전화의 블루투스 주소를 알 때만 연결이 유지됩니다. Android는 앱에 이 주소를 숨기므로 휴대전화 정보에서 복사하세요. 그곳에서 블루투스 주소를 길게 누르고 복사를 탭한 다음 돌아오세요.</string>
|
||||
<string name="bt_address_card_title">%1$s이(가) 이 휴대전화를 알아보게 하기</string>
|
||||
<string name="bt_address_not_now">나중에</string>
|
||||
<string name="bt_address_open">휴대전화 정보 열기</string>
|
||||
<string name="bt_address_type">직접 입력</string>
|
||||
<string name="bt_address_use_body">Gearslip이 이 주소를 차량에 전달해, 차량이 이 휴대전화가 페어링된 휴대전화임을 알 수 있습니다.</string>
|
||||
<string name="bt_address_use_title">이 블루투스 주소를 사용할까요?</string>
|
||||
<string name="bt_address_your_car">차량</string>
|
||||
<string name="car_apps_finding">앱을 찾는 중…</string>
|
||||
<string name="car_apps_none">차량용 앱을 찾을 수 없습니다</string>
|
||||
<string name="car_apps_sub">차량에 표시할 앱을 선택하세요</string>
|
||||
<string name="car_audio_blocked">%1$s은(는) 다른 앱이 소리를 재생하지 못하게 하므로 Gearslip을 통해 차량에 전달되지 않습니다. 다른 음악 앱을 사용해 보세요.</string>
|
||||
<string name="car_audio_switched">이 차량은 Gearslip이 실행되는 동안 블루투스 음악을 끄므로 이제 음악이 Gearslip을 통해 재생됩니다. 휴대전화에 표시되는 요청을 허용하세요.</string>
|
||||
<string name="car_autoplay_note">차량이 연결되면 멈춘 곳부터 음악을 다시 재생합니다.</string>
|
||||
<string name="car_density">차량 화면 밀도</string>
|
||||
<string name="car_density_note">앱을 포함해 글자와 버튼이 커집니다. 크기를 보통으로 두고 200 DPI를 사용해 보세요. 적용하려면 차량을 다시 연결하거나 미리보기를 다시 여세요. 버튼이 잘리면 더 낮은 DPI나 차량 기본값을 선택하세요.</string>
|
||||
<string name="car_directions_over_music">음악 채널로 길 안내</string>
|
||||
<string name="car_directions_over_music_note">음성 안내 후 연결이 끊기거나 다시 시작되는 차량용입니다. Gearslip은 차량의 길 안내 채널 대신 각 안내를 음악에 섞어 재생합니다.</string>
|
||||
<string name="car_experimental_note">아직 테스트 중인 기능입니다. 모든 차량에서 작동하지 않을 수 있습니다.</string>
|
||||
<string name="car_player_beside_map_note">도킹 버튼을 탭했을 때 플레이어가 붙는 지도 쪽입니다.</string>
|
||||
<string name="carset_advanced">고급</string>
|
||||
<string name="carset_experimental_heading">실험실</string>
|
||||
<string name="carset_fit_needs_car">이 조정을 저장하려면 차량을 연결하세요.</string>
|
||||
<string name="choice_density_car">차량 기본값</string>
|
||||
<string name="choice_density_dpi">%1$d DPI</string>
|
||||
<string name="choice_density_custom">사용자 지정…</string>
|
||||
<string name="dialer_missed">부재중 · %1$s</string>
|
||||
<string name="dialer_no_recents">최근 통화가 없습니다.</string>
|
||||
<string name="dialer_recents">최근 기록</string>
|
||||
<string name="dialer_recents_needs_permission">최근 통화를 표시하려면 Gearslip에 통화 기록 권한이 필요합니다.</string>
|
||||
<string name="help_bluetooth_first">케이블보다 블루투스를 먼저 연결하세요.</string>
|
||||
<string name="help_bluetooth_first_detail">블루투스로 음악이 연결되지 않으면 차량이 먼저 블루투스로 휴대전화를 찾게 한 다음 케이블을 연결하세요. 케이블이 먼저 연결되면 혼란을 겪는 차량이 있습니다.</string>
|
||||
<string name="help_usb_data">USB 데이터를 허용하세요.</string>
|
||||
<string name="help_usb_data_detail">GrapheneOS는 USB 포트의 데이터를 차단할 수 있습니다. USB-C 포트 제어가 충전 전용으로 설정되어 있으면 데이터 케이블을 써도 차량이 휴대전화를 인식하지 못합니다. 데이터를 허용하도록 설정하세요.</string>
|
||||
<string name="launcher_in_car">이 기기는 차량에 연결되어 있어 Gearslip이 차량 화면에 표시됩니다.</string>
|
||||
<string name="launcher_open_gearslip">Gearslip 열기</string>
|
||||
<string name="nav_arrival">도착</string>
|
||||
<string name="nav_lane_any">아무 차로</string>
|
||||
<string name="nav_lane_from_left">왼쪽에서 %1$d번째 차로 이용</string>
|
||||
<string name="nav_lane_from_right">오른쪽에서 %1$d번째 차로 이용</string>
|
||||
<plurals name="nav_lane_left">
|
||||
<item quantity="other">왼쪽 %d개 차로 이용</item>
|
||||
</plurals>
|
||||
<string name="nav_lane_middle">가운데 차로 이용</string>
|
||||
<string name="nav_lane_middle_lanes">가운데 차로들 이용</string>
|
||||
<plurals name="nav_lane_right">
|
||||
<item quantity="other">오른쪽 %d개 차로 이용</item>
|
||||
</plurals>
|
||||
<string name="nav_to_go">남음</string>
|
||||
<string name="privacy_policy">개인정보처리방침</string>
|
||||
<string name="privacy_policy_sub">Gearslip이 무엇을, 누구에게, 왜 보내는지</string>
|
||||
<string name="settings_bt_address">휴대전화 블루투스 주소</string>
|
||||
<string name="settings_bt_address_hint">Android 설정의 휴대전화 정보에서 복사하세요. Gearslip이 이 주소를 차량에 전달해, 케이블로 연결된 휴대전화가 페어링된 휴대전화임을 알립니다.</string>
|
||||
<string name="settings_bt_address_invalid">블루투스 주소가 아닙니다. 12:34:56:78:9A:BC와 같은 형식입니다.</string>
|
||||
<string name="settings_bt_address_off">설정 안 됨. Gearslip이 연결될 때 블루투스를 끊는 차량에만 필요합니다.</string>
|
||||
<string name="settings_use_as_launcher">런처로 사용</string>
|
||||
<string name="settings_use_as_launcher_off">헤드 유닛이나 대시보드 태블릿용입니다. Gearslip의 차량 화면이 이 기기의 홈 화면이 됩니다.</string>
|
||||
<string name="settings_use_as_launcher_on">Gearslip을 이 기기의 홈 화면으로 쓸 수 있습니다. 기본 홈 앱에서 선택하세요.</string>
|
||||
<string name="status_no_car">휴대전화가 충전 중이지만 응답한 차량이 없습니다. 충전만 되는 케이블도 있으니 데이터를 전송하는 케이블을 사용해 보세요.</string>
|
||||
<string name="status_no_car_graphene">휴대전화가 충전 중이지만 응답한 차량이 없습니다. 보안 및 개인정보 보호의 USB-C 포트 제어가 데이터를 허용하고 충전 전용이 아닌지 확인하세요.</string>
|
||||
<string name="status_open_settings">설정 열기</string>
|
||||
<string name="status_stuck_cable">차량에 아직 Gearslip이 표시되지 않았습니다. 케이블을 뽑고 몇 초 기다린 다음 다시 꽂으세요. 계속 그러면 다른 케이블이나 USB 포트를 사용해 보고 이슈에 로그를 공유하세요.</string>
|
||||
<string name="status_stuck_wireless">차량에 아직 Gearslip이 표시되지 않았습니다. 차량을 껐다가 다시 켜거나 케이블로 연결하세요. 계속 그러면 이슈에 로그를 공유하세요.</string>
|
||||
<string name="update_installed_text">차량이 연결되지 않은 동안 Wi-Fi로 설치했습니다.</string>
|
||||
<string name="update_installed_title">Gearslip이 %1$s(으)로 업데이트됨</string>
|
||||
<string name="update_silent">업데이트 자동 설치</string>
|
||||
<string name="update_silent_sub">정식 버전만, Wi-Fi에서, 차량이 연결되지 않았을 때</string>
|
||||
<string name="assist_listening">듣는 중…</string>
|
||||
<string name="assist_one_moment">잠시만요…</string>
|
||||
<string name="assist_examples">“navigate home” 또는 “play”와 아티스트 이름을 말해 보세요. 더 보려면 “help”라고 말하세요.</string>
|
||||
<string name="assist_grant">허용</string>
|
||||
<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>
|
||||
<string name="car_nav_mic">마이크 버튼</string>
|
||||
<string name="car_nav_mic_note">내비게이션 바의 어시스턴트 버튼입니다. 차량의 음성 버튼은 어느 쪽이든 작동합니다.</string>
|
||||
<string name="car_apps_add">앱 추가</string>
|
||||
<string name="car_apps_add_note">차량에 표시되지 않는 앱용입니다. 음악은 Gearslip 플레이어로 재생되거나 메시지가 메시지 화면에 표시됩니다.</string>
|
||||
<string name="car_apps_add_pick">앱 선택</string>
|
||||
<string name="car_apps_add_as">%1$s 표시 방식</string>
|
||||
<string name="car_apps_as_media">음악 및 오디오</string>
|
||||
<string name="car_apps_as_media_note">재생 중인 항목과 재생, 일시중지, 건너뛰기. 둘러볼 라이브러리는 없습니다.</string>
|
||||
<string name="car_apps_as_messaging">메시지</string>
|
||||
<string name="car_apps_as_messaging_note">알림을 소리 내어 읽고 답장할 수 있습니다.</string>
|
||||
<string name="car_apps_added_by_you">직접 추가함</string>
|
||||
<string name="car_apps_remove">%1$s 삭제</string>
|
||||
</resources>
|
||||
@@ -200,6 +200,8 @@
|
||||
<string name="car_right">Rechts</string>
|
||||
<string name="car_left">Links</string>
|
||||
<string name="car_autoplay">Automatisch afspelen bij verbinden</string>
|
||||
<string name="car_more_lyrics">Meer songtekstbronnen</string>
|
||||
<string name="car_more_lyrics_note">Vraagt ook BetterLyrics en KuGou, die dan de titel en artiest van het nummer krijgen. Uit vraagt alleen LRCLIB.</string>
|
||||
<string name="car_spoken_directions">Gesproken aanwijzingen</string>
|
||||
<string name="car_spoken_directions_note">Zet dit uit als de stem van je kaarten-app al in de auto klinkt.</string>
|
||||
<string name="car_phone_speech">Spraakherkenning van de telefoon gebruiken</string>
|
||||
@@ -211,7 +213,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 de startpagina met je bladwijzers. 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="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>
|
||||
@@ -427,6 +429,8 @@
|
||||
<string name="carset_fit">Passend maken voor het scherm</string>
|
||||
<string name="off">Uit</string>
|
||||
<string name="on">Aan</string>
|
||||
<string name="carset_lyrics_from">Songteksten van</string>
|
||||
<string name="carset_more_sources">Meer bronnen</string>
|
||||
<string name="carset_hide">Verbergen</string>
|
||||
<string name="carset_show">Tonen</string>
|
||||
<string name="carset_phone">Telefoon</string>
|
||||
@@ -436,7 +440,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">Startpagina (standaard) - tik om een pagina in te stellen</string>
|
||||
<string name="carset_web_default">DuckDuckGo (standaard) - tik om een andere 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>
|
||||
@@ -455,6 +459,7 @@
|
||||
<string name="carset_px">%1$dpx</string>
|
||||
|
||||
<!-- Car media app -->
|
||||
<string name="media_open_on_phone">Openen op de telefoon</string>
|
||||
<string name="media_now_playing">Speelt nu</string>
|
||||
<string name="media_up_next">Hierna</string>
|
||||
<string name="media_browse">Bladeren</string>
|
||||
@@ -464,7 +469,7 @@
|
||||
<string name="media_press_play">Druk op afspelen om verder te gaan waar je was, of zoek iets in Bladeren.</string>
|
||||
|
||||
<string name="media_keeps_library">%1$s houdt zijn bibliotheek voor zichzelf.</string>
|
||||
<string name="media_needs_access">Gearslip kan het toch afspelen zodra het toegang tot meldingen heeft. Sta het toe op de telefoon als je geparkeerd staat.</string>
|
||||
<string name="media_needs_access">Gearslip kan het toch bedienen zodra het meldingstoegang heeft op de telefoon. Sta dat toe onder Instellingen > Meldingen > Meldingstoegang.</string>
|
||||
<string name="media_not_running">%1$s draait niet.</string>
|
||||
<string name="media_not_running_body">%1$s speelt via de speler van Gearslip, maar alleen zolang het open is op de telefoon. Open het één keer en het blijft hier in de auto spelen.</string>
|
||||
<string name="media_open_app">%1$s openen</string>
|
||||
@@ -655,7 +660,7 @@
|
||||
<string name="notif_title">Meldingen</string>
|
||||
<string name="notif_clear_all">Alles wissen</string>
|
||||
<string name="notif_empty">Nog niets. Nieuwe meldingen van de telefoon verschijnen hier.</string>
|
||||
<string name="notif_off">Gearslip heeft toegang tot meldingen nodig om ze hier te tonen. Sta het toe op de telefoon als je geparkeerd staat.</string>
|
||||
<string name="notif_off">Meldingen staan uit. Sta Gearslip toe onder Instellingen > Meldingen > Meldingstoegang op de telefoon.</string>
|
||||
<string name="notif_replied">Beantwoord</string>
|
||||
|
||||
<!-- Messages -->
|
||||
@@ -829,127 +834,4 @@
|
||||
<string name="settings_language">Taal</string>
|
||||
<string name="settings_language_phone">Zelfde als de telefoon</string>
|
||||
<string name="player_next_chapter">Volgend hoofdstuk</string>
|
||||
<string name="allow_on_phone">Toestaan op telefoon</string>
|
||||
<string name="app_all_apps">Alle apps</string>
|
||||
<string name="bt_address_car_note">Rond één instelstap af op je telefoon als je geparkeerd staat.</string>
|
||||
<string name="bt_address_card_body">Sommige auto\'s blijven alleen verbonden als ze het bluetooth-adres van de telefoon kennen. Android houdt het voor apps verborgen, dus kopieer het uit Over de telefoon. Houd daar het bluetooth-adres ingedrukt, tik op Kopiëren en kom terug.</string>
|
||||
<string name="bt_address_card_title">Help %1$s deze telefoon te herkennen</string>
|
||||
<string name="bt_address_not_now">Niet nu</string>
|
||||
<string name="bt_address_open">Over de telefoon openen</string>
|
||||
<string name="bt_address_type">Zelf typen</string>
|
||||
<string name="bt_address_use_body">Gearslip geeft het door aan je auto, zodat die weet dat dit de gekoppelde telefoon is.</string>
|
||||
<string name="bt_address_use_title">Dit bluetooth-adres gebruiken?</string>
|
||||
<string name="bt_address_your_car">je auto</string>
|
||||
<string name="car_apps_finding">Apps zoeken…</string>
|
||||
<string name="car_apps_none">Geen auto-apps gevonden</string>
|
||||
<string name="car_apps_sub">Kies welke apps in de auto verschijnen</string>
|
||||
<string name="car_audio_blocked">%1$s laat andere apps zijn geluid niet afspelen, dus het bereikt de auto niet via Gearslip. Probeer een andere muziek-app.</string>
|
||||
<string name="car_audio_switched">Deze auto zet bluetooth-muziek uit zolang Gearslip draait, dus de muziek gaat nu via Gearslip. Sta de melding op je telefoon toe.</string>
|
||||
<string name="car_autoplay_note">Speelt de muziek verder waar die stopte zodra de auto verbindt.</string>
|
||||
<string name="car_density">Schermdichtheid van de auto</string>
|
||||
<string name="car_density_note">Grotere tekst en bediening, ook in apps. Probeer 200 DPI met Grootte op Normaal. Verbind de auto opnieuw of open de preview opnieuw om het toe te passen. Valt de bediening weg, kies dan minder DPI of Van de auto.</string>
|
||||
<string name="car_directions_over_music">Aanwijzingen via het muziekkanaal</string>
|
||||
<string name="car_directions_over_music_note">Voor een auto die de verbinding verbreekt of herstart na een gesproken aanwijzing. Gearslip mengt elke aanwijzing in de muziek in plaats van het navigatiekanaal van de auto te gebruiken.</string>
|
||||
<string name="car_experimental_note">Functies die nog getest worden. Ze werken misschien niet in elke auto.</string>
|
||||
<string name="car_player_beside_map_note">De kant van de kaart waar de speler aanmeert als je op de aanmeerknop tikt.</string>
|
||||
<string name="carset_advanced">Geavanceerd</string>
|
||||
<string name="carset_experimental_heading">Experimenteel</string>
|
||||
<string name="carset_fit_needs_car">Verbind een auto om deze aanpassing ervoor op te slaan.</string>
|
||||
<string name="choice_density_car">Van de auto</string>
|
||||
<string name="choice_density_dpi">%1$d DPI</string>
|
||||
<string name="choice_density_custom">Aangepast…</string>
|
||||
<string name="dialer_missed">Gemist · %1$s</string>
|
||||
<string name="dialer_no_recents">Geen recente oproepen.</string>
|
||||
<string name="dialer_recents">Recent</string>
|
||||
<string name="dialer_recents_needs_permission">Gearslip heeft de toestemming Gesprekslijsten nodig om recente oproepen te tonen.</string>
|
||||
<string name="help_bluetooth_first">Verbind bluetooth vóór de kabel.</string>
|
||||
<string name="help_bluetooth_first_detail">Als muziek via bluetooth niet verbindt, laat de auto de telefoon dan eerst via bluetooth vinden en steek daarna de kabel in. Sommige auto\'s raken in de war als de kabel eerst komt.</string>
|
||||
<string name="help_usb_data">Sta USB-data toe.</string>
|
||||
<string name="help_usb_data_detail">GrapheneOS kan data op de USB-poort blokkeren. Staat USB-C-poortbeheer op Alleen opladen, dan ziet de auto de telefoon nooit, zelfs niet met een datakabel. Zet het op data toestaan.</string>
|
||||
<string name="launcher_in_car">Dit apparaat is met een auto verbonden, dus Gearslip staat op het scherm van de auto.</string>
|
||||
<string name="launcher_open_gearslip">Gearslip openen</string>
|
||||
<string name="nav_arrival">aankomst</string>
|
||||
<string name="nav_lane_any">Elke rijstrook</string>
|
||||
<string name="nav_lane_from_left">Neem rijstrook %1$d van links</string>
|
||||
<string name="nav_lane_from_right">Neem rijstrook %1$d van rechts</string>
|
||||
<plurals name="nav_lane_left">
|
||||
<item quantity="one">Neem de linkerrijstrook</item>
|
||||
<item quantity="other">Neem de linker %d rijstroken</item>
|
||||
</plurals>
|
||||
<string name="nav_lane_middle">Neem de middelste rijstrook</string>
|
||||
<string name="nav_lane_middle_lanes">Neem de middelste rijstroken</string>
|
||||
<plurals name="nav_lane_right">
|
||||
<item quantity="one">Neem de rechterrijstrook</item>
|
||||
<item quantity="other">Neem de rechter %d rijstroken</item>
|
||||
</plurals>
|
||||
<string name="nav_to_go">te gaan</string>
|
||||
<string name="privacy_policy">Privacybeleid</string>
|
||||
<string name="privacy_policy_sub">Wat Gearslip verstuurt, naar wie en waarom</string>
|
||||
<string name="settings_bt_address">Bluetooth-adres van de telefoon</string>
|
||||
<string name="settings_bt_address_hint">Kopieer het uit de Android-instellingen, onder Over de telefoon. Gearslip geeft het door aan de auto, zodat die weet dat de telefoon aan de kabel de gekoppelde is.</string>
|
||||
<string name="settings_bt_address_invalid">Dat is geen bluetooth-adres. Het ziet eruit als 12:34:56:78:9A:BC.</string>
|
||||
<string name="settings_bt_address_off">Niet ingesteld. Alleen voor een auto die bluetooth verbreekt als Gearslip verbindt.</string>
|
||||
<string name="settings_use_as_launcher">Als launcher gebruiken</string>
|
||||
<string name="settings_use_as_launcher_off">Voor een autoradio of een tablet op het dashboard. Het autoscherm van Gearslip wordt het startscherm van dit apparaat.</string>
|
||||
<string name="settings_use_as_launcher_on">Gearslip kan het startscherm van dit apparaat zijn. Kies het onder Standaard startscherm-app.</string>
|
||||
<string name="status_no_car">De telefoon laadt, maar geen auto heeft geantwoord. Sommige kabels laden alleen op; probeer er een die data doorgeeft.</string>
|
||||
<string name="status_no_car_graphene">De telefoon laadt, maar geen auto heeft geantwoord. Controleer of USB-C-poortbeheer, onder Beveiliging en privacy, data toestaat en niet op Alleen opladen staat.</string>
|
||||
<string name="status_open_settings">Instellingen openen</string>
|
||||
<string name="status_stuck_cable">De auto toont Gearslip nog niet. Haal de kabel eruit, wacht een paar seconden en steek hem er weer in. Blijft het gebeuren, probeer dan een andere kabel of USB-poort en deel het logboek in een issue.</string>
|
||||
<string name="status_stuck_wireless">De auto toont Gearslip nog niet. Zet de auto uit en weer aan, of verbind met een kabel. Blijft het gebeuren, deel dan het logboek in een issue.</string>
|
||||
<string name="update_installed_text">Via wifi geïnstalleerd terwijl er geen auto verbonden was.</string>
|
||||
<string name="update_installed_title">Gearslip bijgewerkt naar %1$s</string>
|
||||
<string name="update_silent">Updates zelf installeren</string>
|
||||
<string name="update_silent_sub">Alleen definitieve versies, via wifi, als er geen auto verbonden is</string>
|
||||
<string name="assist_listening">Ik luister…</string>
|
||||
<string name="assist_one_moment">Even geduld…</string>
|
||||
<string name="assist_examples">Probeer “navigate home” of “play” en een artiest. Zeg “help” voor meer.</string>
|
||||
<string name="assist_grant">Toestaan</string>
|
||||
<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>
|
||||
<string name="car_nav_mic">Microfoonknop</string>
|
||||
<string name="car_nav_mic_note">De assistentknop in de navigatiebalk. De spraakknop van de auto werkt hoe dan ook.</string>
|
||||
<string name="car_apps_add">App toevoegen</string>
|
||||
<string name="car_apps_add_note">Voor een app die de auto niet toont: de muziek speelt via de speler van Gearslip, of de berichten verschijnen in Berichten.</string>
|
||||
<string name="car_apps_add_pick">Kies een app</string>
|
||||
<string name="car_apps_add_as">%1$s tonen als</string>
|
||||
<string name="car_apps_as_media">Muziek en audio</string>
|
||||
<string name="car_apps_as_media_note">Wat er speelt, met afspelen, pauzeren en overslaan. Geen bibliotheek om door te bladeren.</string>
|
||||
<string name="car_apps_as_messaging">Berichten</string>
|
||||
<string name="car_apps_as_messaging_note">De meldingen, voorgelezen, met antwoorden.</string>
|
||||
<string name="car_apps_added_by_you">Door jou toegevoegd</string>
|
||||
<string name="car_apps_remove">%1$s verwijderen</string>
|
||||
</resources>
|
||||
@@ -200,6 +200,8 @@
|
||||
<string name="car_right">Prawo</string>
|
||||
<string name="car_left">Lewo</string>
|
||||
<string name="car_autoplay">Odtwarzaj po połączeniu</string>
|
||||
<string name="car_more_lyrics">Więcej źródeł tekstów</string>
|
||||
<string name="car_more_lyrics_note">Pyta też BetterLyrics i KuGou, które dostają wtedy tytuł i wykonawcę utworu. Po wyłączeniu pyta tylko LRCLIB.</string>
|
||||
<string name="car_spoken_directions">Wskazówki głosowe</string>
|
||||
<string name="car_spoken_directions_note">Wyłącz, jeśli głos twojej aplikacji z mapami już gra w samochodzie.</string>
|
||||
<string name="car_phone_speech">Używaj rozpoznawania mowy telefonu</string>
|
||||
@@ -211,7 +213,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">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="web_page_note">Pokazywana przez aplikację Przeglądarka w samochodzie. Puste otwiera DuckDuckGo. Działa 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>
|
||||
@@ -429,6 +431,8 @@
|
||||
<string name="carset_fit">Dopasuj do ekranu</string>
|
||||
<string name="off">Wył.</string>
|
||||
<string name="on">Wł.</string>
|
||||
<string name="carset_lyrics_from">Teksty z</string>
|
||||
<string name="carset_more_sources">Więcej źródeł</string>
|
||||
<string name="carset_hide">Ukryj</string>
|
||||
<string name="carset_show">Pokaż</string>
|
||||
<string name="carset_phone">Telefon</string>
|
||||
@@ -438,7 +442,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">Strona startowa (domyślna) - dotknij, aby ustawić stronę</string>
|
||||
<string name="carset_web_default">DuckDuckGo (domyślna) - kliknij, by ustawić inną 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>
|
||||
@@ -457,6 +461,7 @@
|
||||
<string name="carset_px">%1$dpx</string>
|
||||
|
||||
<!-- Car media app -->
|
||||
<string name="media_open_on_phone">Otwórz w telefonie</string>
|
||||
<string name="media_now_playing">Teraz gra</string>
|
||||
<string name="media_up_next">Następne</string>
|
||||
<string name="media_browse">Przeglądaj</string>
|
||||
@@ -466,7 +471,7 @@
|
||||
<string name="media_press_play">Naciśnij odtwarzanie, by wrócić tam, gdzie skończyłeś, albo znajdź coś w Przeglądaj.</string>
|
||||
|
||||
<string name="media_keeps_library">%1$s nie udostępnia swojej biblioteki.</string>
|
||||
<string name="media_needs_access">Gearslip może go nadal odtwarzać, gdy dostanie dostęp do powiadomień. Zezwól na to na telefonie, gdy zaparkujesz.</string>
|
||||
<string name="media_needs_access">Gearslip może nią sterować, gdy ma dostęp do powiadomień w telefonie. Zezwól w Ustawienia > Powiadomienia > Dostęp do powiadomień.</string>
|
||||
<string name="media_not_running">%1$s nie jest uruchomiona.</string>
|
||||
<string name="media_not_running_body">%1$s gra przez odtwarzacz Gearslip, ale tylko wtedy, gdy jest otwarta w telefonie. Otwórz ją raz, a będzie grać tutaj w samochodzie.</string>
|
||||
<string name="media_open_app">Otwórz %1$s</string>
|
||||
@@ -659,7 +664,7 @@
|
||||
<string name="notif_title">Powiadomienia</string>
|
||||
<string name="notif_clear_all">Wyczyść wszystko</string>
|
||||
<string name="notif_empty">Na razie nic. Nowe powiadomienia z telefonu pojawią się tutaj.</string>
|
||||
<string name="notif_off">Gearslip potrzebuje dostępu do powiadomień, aby je tu pokazać. Zezwól na to na telefonie, gdy zaparkujesz.</string>
|
||||
<string name="notif_off">Powiadomienia są wyłączone. Zezwól Gearslip w Ustawienia > Powiadomienia > Dostęp do powiadomień w telefonie.</string>
|
||||
<string name="notif_replied">Odpowiedziano</string>
|
||||
|
||||
<!-- Messages -->
|
||||
@@ -845,131 +850,4 @@
|
||||
<string name="settings_language">Język</string>
|
||||
<string name="settings_language_phone">Taki sam jak w telefonie</string>
|
||||
<string name="player_next_chapter">Następny rozdział</string>
|
||||
<string name="allow_on_phone">Zezwól na telefonie</string>
|
||||
<string name="app_all_apps">Wszystkie aplikacje</string>
|
||||
<string name="bt_address_car_note">Dokończ jeden krok konfiguracji na telefonie, gdy zaparkujesz.</string>
|
||||
<string name="bt_address_card_body">Niektóre samochody pozostają połączone tylko wtedy, gdy znają adres Bluetooth telefonu. Android ukrywa go przed aplikacjami, więc skopiuj go z sekcji Informacje o telefonie. Przytrzymaj tam adres Bluetooth, dotknij Kopiuj i wróć.</string>
|
||||
<string name="bt_address_card_title">Pomóż %1$s rozpoznać ten telefon</string>
|
||||
<string name="bt_address_not_now">Nie teraz</string>
|
||||
<string name="bt_address_open">Otwórz Informacje o telefonie</string>
|
||||
<string name="bt_address_type">Wpisz go ręcznie</string>
|
||||
<string name="bt_address_use_body">Gearslip przekaże go samochodowi, aby wiedział, że to sparowany telefon.</string>
|
||||
<string name="bt_address_use_title">Użyć tego adresu Bluetooth?</string>
|
||||
<string name="bt_address_your_car">samochodowi</string>
|
||||
<string name="car_apps_finding">Szukanie aplikacji…</string>
|
||||
<string name="car_apps_none">Nie znaleziono aplikacji samochodowych</string>
|
||||
<string name="car_apps_sub">Wybierz, które aplikacje pojawią się w samochodzie</string>
|
||||
<string name="car_audio_blocked">%1$s nie pozwala innym aplikacjom odtwarzać swojego dźwięku, więc nie dotrze do samochodu przez Gearslip. Spróbuj innej aplikacji muzycznej.</string>
|
||||
<string name="car_audio_switched">Ten samochód wyłącza muzykę przez Bluetooth, gdy działa Gearslip, więc muzyka idzie teraz przez Gearslip. Zezwól na monit na telefonie.</string>
|
||||
<string name="car_autoplay_note">Wznawia muzykę tam, gdzie się zatrzymała, gdy tylko samochód się połączy.</string>
|
||||
<string name="car_density">Gęstość ekranu samochodu</string>
|
||||
<string name="car_density_note">Większy tekst i przyciski, także w aplikacjach. Spróbuj 200 DPI z Rozmiarem na Normalny. Połącz samochód ponownie lub otwórz podgląd od nowa, aby to zastosować. Jeśli przyciski są ucięte, wybierz mniej DPI lub Z samochodu.</string>
|
||||
<string name="car_directions_over_music">Wskazówki przez kanał muzyki</string>
|
||||
<string name="car_directions_over_music_note">Dla samochodu, który rozłącza się lub restartuje po wskazówce głosowej. Gearslip miksuje każdą wskazówkę z muzyką zamiast używać kanału nawigacji samochodu.</string>
|
||||
<string name="car_experimental_note">Funkcje wciąż testowane. Mogą nie działać w każdym samochodzie.</string>
|
||||
<string name="car_player_beside_map_note">Strona mapy, do której przypina się odtwarzacz po dotknięciu jego przycisku przypięcia.</string>
|
||||
<string name="carset_advanced">Zaawansowane</string>
|
||||
<string name="carset_experimental_heading">Eksperymentalne</string>
|
||||
<string name="carset_fit_needs_car">Połącz samochód, aby zapisać dla niego to dopasowanie.</string>
|
||||
<string name="choice_density_car">Z samochodu</string>
|
||||
<string name="choice_density_dpi">%1$d DPI</string>
|
||||
<string name="choice_density_custom">Własna…</string>
|
||||
<string name="dialer_missed">Nieodebrane · %1$s</string>
|
||||
<string name="dialer_no_recents">Brak ostatnich połączeń.</string>
|
||||
<string name="dialer_recents">Ostatnie</string>
|
||||
<string name="dialer_recents_needs_permission">Gearslip potrzebuje uprawnienia Rejestry połączeń, aby pokazać ostatnie połączenia.</string>
|
||||
<string name="help_bluetooth_first">Połącz Bluetooth przed kablem.</string>
|
||||
<string name="help_bluetooth_first_detail">Jeśli muzyka przez Bluetooth się nie łączy, pozwól samochodowi najpierw znaleźć telefon przez Bluetooth, a potem podłącz kabel. Niektóre samochody gubią się, gdy kabel jest pierwszy.</string>
|
||||
<string name="help_usb_data">Zezwól na dane USB.</string>
|
||||
<string name="help_usb_data_detail">GrapheneOS może blokować dane na porcie USB. Jeśli kontrola portu USB-C jest ustawiona na Tylko ładowanie, samochód nigdy nie zobaczy telefonu, nawet z kablem do danych. Ustaw zezwolenie na dane.</string>
|
||||
<string name="launcher_in_car">To urządzenie jest połączone z samochodem, więc Gearslip jest na ekranie samochodu.</string>
|
||||
<string name="launcher_open_gearslip">Otwórz Gearslip</string>
|
||||
<string name="nav_arrival">przyjazd</string>
|
||||
<string name="nav_lane_any">Dowolny pas</string>
|
||||
<string name="nav_lane_from_left">Zajmij pas %1$d od lewej</string>
|
||||
<string name="nav_lane_from_right">Zajmij pas %1$d od prawej</string>
|
||||
<plurals name="nav_lane_left">
|
||||
<item quantity="one">Zajmij lewy pas</item>
|
||||
<item quantity="few">Zajmij %d lewe pasy</item>
|
||||
<item quantity="many">Zajmij %d lewych pasów</item>
|
||||
<item quantity="other">Zajmij %d lewego pasa</item>
|
||||
</plurals>
|
||||
<string name="nav_lane_middle">Zajmij środkowy pas</string>
|
||||
<string name="nav_lane_middle_lanes">Zajmij środkowe pasy</string>
|
||||
<plurals name="nav_lane_right">
|
||||
<item quantity="one">Zajmij prawy pas</item>
|
||||
<item quantity="few">Zajmij %d prawe pasy</item>
|
||||
<item quantity="many">Zajmij %d prawych pasów</item>
|
||||
<item quantity="other">Zajmij %d prawego pasa</item>
|
||||
</plurals>
|
||||
<string name="nav_to_go">do celu</string>
|
||||
<string name="privacy_policy">Polityka prywatności</string>
|
||||
<string name="privacy_policy_sub">Co Gearslip wysyła, komu i dlaczego</string>
|
||||
<string name="settings_bt_address">Adres Bluetooth telefonu</string>
|
||||
<string name="settings_bt_address_hint">Skopiuj go z ustawień Androida, w sekcji Informacje o telefonie. Gearslip przekaże go samochodowi, aby wiedział, że telefon na kablu to ten sparowany.</string>
|
||||
<string name="settings_bt_address_invalid">To nie jest adres Bluetooth. Wygląda on tak: 12:34:56:78:9A:BC.</string>
|
||||
<string name="settings_bt_address_off">Nie ustawiono. Tylko dla samochodu, który rozłącza Bluetooth, gdy Gearslip się łączy.</string>
|
||||
<string name="settings_use_as_launcher">Używaj jako launchera</string>
|
||||
<string name="settings_use_as_launcher_off">Dla radia samochodowego lub tabletu na desce rozdzielczej. Ekran samochodowy Gearslip staje się ekranem głównym tego urządzenia.</string>
|
||||
<string name="settings_use_as_launcher_on">Gearslip może być ekranem głównym tego urządzenia. Wybierz go w Domyślna aplikacja ekranu głównego.</string>
|
||||
<string name="status_no_car">Telefon się ładuje, ale żaden samochód nie odpowiedział. Niektóre kable tylko ładują; spróbuj takiego, który przesyła dane.</string>
|
||||
<string name="status_no_car_graphene">Telefon się ładuje, ale żaden samochód nie odpowiedział. Sprawdź, czy kontrola portu USB-C w sekcji Bezpieczeństwo i prywatność zezwala na dane i nie jest ustawiona na Tylko ładowanie.</string>
|
||||
<string name="status_open_settings">Otwórz ustawienia</string>
|
||||
<string name="status_stuck_cable">Samochód jeszcze nie pokazał Gearslip. Odłącz kabel, odczekaj kilka sekund i podłącz go ponownie. Jeśli to się powtarza, spróbuj innego kabla lub portu USB i udostępnij dziennik w zgłoszeniu.</string>
|
||||
<string name="status_stuck_wireless">Samochód jeszcze nie pokazał Gearslip. Wyłącz i włącz samochód albo połącz się kablem. Jeśli to się powtarza, udostępnij dziennik w zgłoszeniu.</string>
|
||||
<string name="update_installed_text">Zainstalowano przez Wi-Fi, gdy żaden samochód nie był połączony.</string>
|
||||
<string name="update_installed_title">Gearslip zaktualizowany do %1$s</string>
|
||||
<string name="update_silent">Instaluj aktualizacje samodzielnie</string>
|
||||
<string name="update_silent_sub">Tylko wydania stabilne, przez Wi-Fi, gdy żaden samochód nie jest połączony</string>
|
||||
<string name="assist_listening">Słucham…</string>
|
||||
<string name="assist_one_moment">Chwileczkę…</string>
|
||||
<string name="assist_examples">Spróbuj „navigate home” albo „play” i wykonawcę. Powiedz „help”, aby zobaczyć więcej.</string>
|
||||
<string name="assist_grant">Zezwól</string>
|
||||
<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>
|
||||
<string name="car_nav_mic">Przycisk mikrofonu</string>
|
||||
<string name="car_nav_mic_note">Przycisk asystenta na pasku nawigacji. Przycisk głosowy w samochodzie działa niezależnie od tego.</string>
|
||||
<string name="car_apps_add">Dodaj aplikację</string>
|
||||
<string name="car_apps_add_note">Dla aplikacji, której samochód nie pokazuje: jej muzyka gra przez odtwarzacz Gearslip albo jej wiadomości pojawiają się w Wiadomościach.</string>
|
||||
<string name="car_apps_add_pick">Wybierz aplikację</string>
|
||||
<string name="car_apps_add_as">Pokaż %1$s jako</string>
|
||||
<string name="car_apps_as_media">Muzyka i audio</string>
|
||||
<string name="car_apps_as_media_note">Teraz odtwarzane, z odtwarzaniem, pauzą i przewijaniem. Bez biblioteki do przeglądania.</string>
|
||||
<string name="car_apps_as_messaging">Wiadomości</string>
|
||||
<string name="car_apps_as_messaging_note">Jej powiadomienia, czytane na głos, z odpowiedziami.</string>
|
||||
<string name="car_apps_added_by_you">Dodana przez Ciebie</string>
|
||||
<string name="car_apps_remove">Usuń %1$s</string>
|
||||
</resources>
|
||||
@@ -200,6 +200,8 @@
|
||||
<string name="car_right">Direita</string>
|
||||
<string name="car_left">Esquerda</string>
|
||||
<string name="car_autoplay">Tocar ao conectar</string>
|
||||
<string name="car_more_lyrics">Mais fontes de letras</string>
|
||||
<string name="car_more_lyrics_note">Também consulta o BetterLyrics e o KuGou, que recebem o nome e o artista da música. Desativado, consulta só o LRCLIB.</string>
|
||||
<string name="car_spoken_directions">Instruções faladas</string>
|
||||
<string name="car_spoken_directions_note">Desative se a voz do seu app de mapas já toca no carro.</string>
|
||||
<string name="car_phone_speech">Usar o reconhecimento de voz do celular</string>
|
||||
@@ -211,7 +213,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. Vazio abre a página inicial com seus favoritos. 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="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>
|
||||
@@ -428,6 +430,8 @@
|
||||
<string name="carset_fit">Ajustar à tela</string>
|
||||
<string name="off">Desativado</string>
|
||||
<string name="on">Ativado</string>
|
||||
<string name="carset_lyrics_from">Letras de</string>
|
||||
<string name="carset_more_sources">Mais fontes</string>
|
||||
<string name="carset_hide">Ocultar</string>
|
||||
<string name="carset_show">Mostrar</string>
|
||||
<string name="carset_phone">Celular</string>
|
||||
@@ -437,7 +441,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">Página inicial (padrão) - toque para definir uma página</string>
|
||||
<string name="carset_web_default">DuckDuckGo (padrão) - toque para definir outra 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>
|
||||
@@ -456,6 +460,7 @@
|
||||
<string name="carset_px">%1$dpx</string>
|
||||
|
||||
<!-- Car media app -->
|
||||
<string name="media_open_on_phone">Abrir no celular</string>
|
||||
<string name="media_now_playing">Tocando agora</string>
|
||||
<string name="media_up_next">A seguir</string>
|
||||
<string name="media_browse">Navegar</string>
|
||||
@@ -465,7 +470,7 @@
|
||||
<string name="media_press_play">Aperte o play para continuar de onde parou, ou encontre algo em Navegar.</string>
|
||||
|
||||
<string name="media_keeps_library">%1$s não compartilha a biblioteca.</string>
|
||||
<string name="media_needs_access">O Gearslip ainda pode tocar quando tiver acesso às notificações. Permita no celular quando estiver estacionado.</string>
|
||||
<string name="media_needs_access">O Gearslip ainda pode controlá-lo se tiver acesso às notificações no celular. Permita em Configurações > Notificações > Acesso a notificações.</string>
|
||||
<string name="media_not_running">%1$s não está aberto.</string>
|
||||
<string name="media_not_running_body">%1$s toca pelo player do Gearslip, mas só enquanto está aberto no celular. Abra uma vez e ele continua tocando aqui no carro.</string>
|
||||
<string name="media_open_app">Abrir %1$s</string>
|
||||
@@ -657,7 +662,7 @@
|
||||
<string name="notif_title">Notificações</string>
|
||||
<string name="notif_clear_all">Limpar tudo</string>
|
||||
<string name="notif_empty">Nada ainda. Novas notificações do celular aparecerão aqui.</string>
|
||||
<string name="notif_off">O Gearslip precisa de acesso às notificações para mostrá-las aqui. Permita no celular quando estiver estacionado.</string>
|
||||
<string name="notif_off">As notificações estão desativadas. Permita o Gearslip em Configurações > Notificações > Acesso a notificações no celular.</string>
|
||||
<string name="notif_replied">Respondida</string>
|
||||
|
||||
<!-- Messages -->
|
||||
@@ -837,129 +842,4 @@
|
||||
<string name="settings_language">Idioma</string>
|
||||
<string name="settings_language_phone">O mesmo do celular</string>
|
||||
<string name="player_next_chapter">Próximo capítulo</string>
|
||||
<string name="allow_on_phone">Permitir no celular</string>
|
||||
<string name="app_all_apps">Todos os apps</string>
|
||||
<string name="bt_address_car_note">Conclua uma etapa da configuração no celular quando estiver estacionado.</string>
|
||||
<string name="bt_address_card_body">Alguns carros só continuam conectados se souberem o endereço Bluetooth do celular. O Android esconde esse endereço dos apps, então copie em Sobre o telefone. Toque e segure o endereço Bluetooth, toque em Copiar e volte.</string>
|
||||
<string name="bt_address_card_title">Ajude %1$s a reconhecer este celular</string>
|
||||
<string name="bt_address_not_now">Agora não</string>
|
||||
<string name="bt_address_open">Abrir Sobre o telefone</string>
|
||||
<string name="bt_address_type">Digitar</string>
|
||||
<string name="bt_address_use_body">O Gearslip passa o endereço para o seu carro, para ele saber que este é o celular pareado.</string>
|
||||
<string name="bt_address_use_title">Usar este endereço Bluetooth?</string>
|
||||
<string name="bt_address_your_car">o seu carro</string>
|
||||
<string name="car_apps_finding">Procurando apps…</string>
|
||||
<string name="car_apps_none">Nenhum app para carro encontrado</string>
|
||||
<string name="car_apps_sub">Escolha quais apps aparecem no carro</string>
|
||||
<string name="car_audio_blocked">%1$s não deixa outros apps tocarem o som dele, então ele não chega ao carro pelo Gearslip. Tente outro app de música.</string>
|
||||
<string name="car_audio_switched">Este carro desliga a música por Bluetooth enquanto o Gearslip roda, então a música agora passa pelo Gearslip. Permita o aviso no seu celular.</string>
|
||||
<string name="car_autoplay_note">Retoma a música de onde parou assim que o carro conecta.</string>
|
||||
<string name="car_density">Densidade da tela do carro</string>
|
||||
<string name="car_density_note">Texto e controles maiores, inclusive nos apps. Tente 200 DPI com Tamanho em Normal. Reconecte o carro ou reabra a prévia para aplicar. Se os controles ficarem cortados, escolha menos DPI ou Do carro.</string>
|
||||
<string name="car_directions_over_music">Instruções pelo canal de música</string>
|
||||
<string name="car_directions_over_music_note">Para um carro que cai a conexão ou reinicia depois de uma instrução falada. O Gearslip mistura cada instrução na música em vez de usar o canal de navegação do carro.</string>
|
||||
<string name="car_experimental_note">Recursos ainda em teste. Podem não funcionar em todos os carros.</string>
|
||||
<string name="car_player_beside_map_note">O lado do mapa onde o player se encaixa quando você toca no botão de encaixar.</string>
|
||||
<string name="carset_advanced">Avançado</string>
|
||||
<string name="carset_experimental_heading">Experimental</string>
|
||||
<string name="carset_fit_needs_car">Conecte um carro para salvar este ajuste para ele.</string>
|
||||
<string name="choice_density_car">Do carro</string>
|
||||
<string name="choice_density_dpi">%1$d DPI</string>
|
||||
<string name="choice_density_custom">Personalizado…</string>
|
||||
<string name="dialer_missed">Perdida · %1$s</string>
|
||||
<string name="dialer_no_recents">Nenhuma chamada recente.</string>
|
||||
<string name="dialer_recents">Recentes</string>
|
||||
<string name="dialer_recents_needs_permission">O Gearslip precisa da permissão Registro de chamadas para listar as chamadas recentes.</string>
|
||||
<string name="help_bluetooth_first">Conecte o Bluetooth antes do cabo.</string>
|
||||
<string name="help_bluetooth_first_detail">Se a música por Bluetooth não conectar, deixe o carro achar o celular por Bluetooth primeiro e depois conecte o cabo. Alguns carros se confundem quando o cabo vem primeiro.</string>
|
||||
<string name="help_usb_data">Permita dados por USB.</string>
|
||||
<string name="help_usb_data_detail">O GrapheneOS pode bloquear dados na porta USB. Se o controle da porta USB-C estiver em Somente carregamento, o carro nunca vê o celular, mesmo com um cabo de dados. Ajuste para permitir dados.</string>
|
||||
<string name="launcher_in_car">Este aparelho está conectado a um carro, então o Gearslip está na tela do carro.</string>
|
||||
<string name="launcher_open_gearslip">Abrir Gearslip</string>
|
||||
<string name="nav_arrival">chegada</string>
|
||||
<string name="nav_lane_any">Qualquer faixa</string>
|
||||
<string name="nav_lane_from_left">Use a faixa %1$d a partir da esquerda</string>
|
||||
<string name="nav_lane_from_right">Use a faixa %1$d a partir da direita</string>
|
||||
<plurals name="nav_lane_left">
|
||||
<item quantity="one">Use a faixa da esquerda</item>
|
||||
<item quantity="many">Use as %d faixas da esquerda</item>
|
||||
<item quantity="other">Use as %d faixas da esquerda</item>
|
||||
</plurals>
|
||||
<string name="nav_lane_middle">Use a faixa do meio</string>
|
||||
<string name="nav_lane_middle_lanes">Use as faixas do meio</string>
|
||||
<plurals name="nav_lane_right">
|
||||
<item quantity="one">Use a faixa da direita</item>
|
||||
<item quantity="many">Use as %d faixas da direita</item>
|
||||
<item quantity="other">Use as %d faixas da direita</item>
|
||||
</plurals>
|
||||
<string name="nav_to_go">restantes</string>
|
||||
<string name="privacy_policy">Política de privacidade</string>
|
||||
<string name="privacy_policy_sub">O que o Gearslip envia, para quem e por quê</string>
|
||||
<string name="settings_bt_address">Endereço Bluetooth do celular</string>
|
||||
<string name="settings_bt_address_hint">Copie nas configurações do Android, em Sobre o telefone. O Gearslip passa o endereço para o carro, para ele saber que o celular no cabo é o pareado.</string>
|
||||
<string name="settings_bt_address_invalid">Isso não é um endereço Bluetooth. Ele se parece com 12:34:56:78:9A:BC.</string>
|
||||
<string name="settings_bt_address_off">Não definido. Só para um carro que desliga o Bluetooth quando o Gearslip conecta.</string>
|
||||
<string name="settings_use_as_launcher">Usar como inicializador</string>
|
||||
<string name="settings_use_as_launcher_off">Para uma central multimídia ou um tablet no painel. A tela de carro do Gearslip vira a tela inicial deste aparelho.</string>
|
||||
<string name="settings_use_as_launcher_on">O Gearslip pode ser a tela inicial deste aparelho. Escolha em App de início padrão.</string>
|
||||
<string name="status_no_car">O celular está carregando, mas nenhum carro respondeu. Alguns cabos só carregam; tente um que transmita dados.</string>
|
||||
<string name="status_no_car_graphene">O celular está carregando, mas nenhum carro respondeu. Verifique se o controle da porta USB-C, em Segurança e privacidade, permite dados e não está em Somente carregamento.</string>
|
||||
<string name="status_open_settings">Abrir configurações</string>
|
||||
<string name="status_stuck_cable">O carro ainda não mostrou o Gearslip. Desconecte o cabo, espere alguns segundos e conecte de novo. Se continuar, tente outro cabo ou porta USB e compartilhe o registro em uma issue.</string>
|
||||
<string name="status_stuck_wireless">O carro ainda não mostrou o Gearslip. Desligue e ligue o carro, ou conecte com um cabo. Se continuar, compartilhe o registro em uma issue.</string>
|
||||
<string name="update_installed_text">Instalada pelo Wi-Fi enquanto nenhum carro estava conectado.</string>
|
||||
<string name="update_installed_title">Gearslip atualizado para %1$s</string>
|
||||
<string name="update_silent">Instalar atualizações sozinho</string>
|
||||
<string name="update_silent_sub">Só versões finais, pelo Wi-Fi, quando nenhum carro está conectado</string>
|
||||
<string name="assist_listening">Ouvindo…</string>
|
||||
<string name="assist_one_moment">Um momento…</string>
|
||||
<string name="assist_examples">Tente “navigate home” ou “play” e um artista. Diga “help” para ver mais.</string>
|
||||
<string name="assist_grant">Permitir</string>
|
||||
<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>
|
||||
<string name="car_nav_mic">Botão do microfone</string>
|
||||
<string name="car_nav_mic_note">O botão do assistente na barra de navegação. O botão de voz do carro funciona de qualquer jeito.</string>
|
||||
<string name="car_apps_add">Adicionar um app</string>
|
||||
<string name="car_apps_add_note">Para um app que o carro não lista: a música toca pelo player do Gearslip, ou as mensagens aparecem em Mensagens.</string>
|
||||
<string name="car_apps_add_pick">Escolha um app</string>
|
||||
<string name="car_apps_add_as">Mostrar %1$s como</string>
|
||||
<string name="car_apps_as_media">Música e áudio</string>
|
||||
<string name="car_apps_as_media_note">O que está tocando, com tocar, pausar e pular. Não há biblioteca para navegar.</string>
|
||||
<string name="car_apps_as_messaging">Mensagens</string>
|
||||
<string name="car_apps_as_messaging_note">As notificações dele, lidas em voz alta, com respostas.</string>
|
||||
<string name="car_apps_added_by_you">Adicionado por você</string>
|
||||
<string name="car_apps_remove">Remover %1$s</string>
|
||||
</resources>
|
||||
@@ -188,6 +188,8 @@
|
||||
<string name="car_right">Dreapta</string>
|
||||
<string name="car_left">Stânga</string>
|
||||
<string name="car_autoplay">Redare automată la conectare</string>
|
||||
<string name="car_more_lyrics">Alte surse de versuri</string>
|
||||
<string name="car_more_lyrics_note">De asemenea, interoghează BetterLyrics și KuGou, care obțin apoi numele melodiei și al artistului. Opțiunea „Oprit” interoghează doar LRCLIB.</string>
|
||||
<string name="car_spoken_directions">Instrucțiuni vocale</string>
|
||||
<string name="car_spoken_directions_note">Dezactivează dacă vocea aplicației de hărți se aude deja în mașină.</string>
|
||||
<string name="car_phone_speech">Folosește funcția de recunoaștere vocală a telefonului</string>
|
||||
@@ -199,7 +201,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ă. Gol deschide pagina de start cu marcajele tale. Se aplică la următoarea conectare.</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="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>
|
||||
@@ -376,6 +378,8 @@
|
||||
<string name="carset_fit">Ajustare la ecran</string>
|
||||
<string name="off">Oprit</string>
|
||||
<string name="on">Pornit</string>
|
||||
<string name="carset_lyrics_from">Versuri din</string>
|
||||
<string name="carset_more_sources">Alte surse</string>
|
||||
<string name="carset_hide">Ascunde</string>
|
||||
<string name="carset_show">Afișează</string>
|
||||
<string name="carset_phone">Telefon</string>
|
||||
@@ -385,7 +389,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">Pagina de start (implicită) - atinge pentru a seta o pagină</string>
|
||||
<string name="carset_web_default">DuckDuckGo (implicit) – atinge pentru a seta o altă 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>
|
||||
@@ -401,6 +405,7 @@
|
||||
<string name="carset_saved_to">Salvat în %1$s</string>
|
||||
<string name="carset_save_failed">Nu s-a putut salva</string>
|
||||
<string name="carset_px">%1$dpx</string>
|
||||
<string name="media_open_on_phone">Deschide pe telefon</string>
|
||||
<string name="media_now_playing">Se redă acum</string>
|
||||
<string name="media_up_next">Urmează</string>
|
||||
<string name="media_browse">Răsfoire</string>
|
||||
@@ -409,7 +414,7 @@
|
||||
<string name="media_nothing_playing">Nu se redă nimic</string>
|
||||
<string name="media_press_play">Apasă pe „Redare” pentru a continua de unde ai rămas sau caută ceva în secțiunea „Răsfoire”.</string>
|
||||
<string name="media_keeps_library">%1$s nu își deschide biblioteca.</string>
|
||||
<string name="media_needs_access">Gearslip îl poate reda totuși după ce primește acces la notificări. Permite-l pe telefon când ai parcat.</string>
|
||||
<string name="media_needs_access">Gearslip poate continua să controleze aplicația odată ce are acces la notificări pe telefon. Permite acest lucru din Setări > Notificări > Acces la notificări.</string>
|
||||
<string name="media_not_running">%1$s nu rulează.</string>
|
||||
<string name="media_not_running_body">%1$s se redă prin playerul propriu al aplicației Gearslip, dar numai cât timp aceasta este deschisă pe telefon. Deschide o dată și va continua să ruleze aici, în mașină.</string>
|
||||
<string name="media_open_app">Deschide %1$s</string>
|
||||
@@ -562,7 +567,7 @@
|
||||
<string name="notif_title">Notificări</string>
|
||||
<string name="notif_clear_all">Șterge tot</string>
|
||||
<string name="notif_empty">Deocamdată nimic. Notificările noi de pe telefon vor apărea aici.</string>
|
||||
<string name="notif_off">Gearslip are nevoie de acces la notificări ca să le afișeze aici. Permite-l pe telefon când ai parcat.</string>
|
||||
<string name="notif_off">Notificările sunt dezactivate. Permite aplicației Gearslip accesul la notificări din Setări > Notificări > Acces la notificări pe telefon.</string>
|
||||
<string name="notif_replied">S-a răspuns</string>
|
||||
<string name="msg_mark_read">Marcare ca citit</string>
|
||||
<string name="msg_messages">Mesaje</string>
|
||||
@@ -570,278 +575,4 @@
|
||||
<string name="settings_language">Limbă</string>
|
||||
<string name="settings_language_phone">La fel ca pe telefon</string>
|
||||
<string name="player_next_chapter">Capitolul următor</string>
|
||||
<string name="allow_on_phone">Permite pe telefon</string>
|
||||
<string name="app_all_apps">Toate aplicațiile</string>
|
||||
<string name="bt_address_car_note">Termină un pas de configurare pe telefon când ai parcat.</string>
|
||||
<string name="bt_address_card_body">Unele mașini rămân conectate doar dacă știu adresa Bluetooth a telefonului. Android o ascunde de aplicații, așa că copiaz-o din Despre telefon. Ține apăsat pe adresa Bluetooth de acolo, atinge Copiază și revino.</string>
|
||||
<string name="bt_address_card_title">Ajută %1$s să recunoască acest telefon</string>
|
||||
<string name="bt_address_not_now">Nu acum</string>
|
||||
<string name="bt_address_open">Deschide Despre telefon</string>
|
||||
<string name="bt_address_type">Scrie-o de mână</string>
|
||||
<string name="bt_address_use_body">Gearslip o transmite mașinii, ca să știe că acesta e telefonul asociat.</string>
|
||||
<string name="bt_address_use_title">Folosești această adresă Bluetooth?</string>
|
||||
<string name="bt_address_your_car">mașina ta</string>
|
||||
<string name="car_apps_finding">Se caută aplicații…</string>
|
||||
<string name="car_apps_none">Nu s-au găsit aplicații pentru mașină</string>
|
||||
<string name="car_apps_sub">Alege ce aplicații apar în mașină</string>
|
||||
<string name="car_audio_blocked">%1$s nu lasă alte aplicații să-i redea sunetul, așa că nu ajunge la mașină prin Gearslip. Încearcă altă aplicație de muzică.</string>
|
||||
<string name="car_audio_switched">Această mașină oprește muzica prin Bluetooth cât timp rulează Gearslip, așa că muzica trece acum prin Gearslip. Permite solicitarea de pe telefon.</string>
|
||||
<string name="car_autoplay_note">Reia muzica de unde s-a oprit imediat ce mașina se conectează.</string>
|
||||
<string name="car_density">Densitatea ecranului mașinii</string>
|
||||
<string name="car_density_note">Text și butoane mai mari, inclusiv în aplicații. Încearcă 200 DPI cu Mărimea pe Normal. Reconectează mașina sau redeschide previzualizarea ca să se aplice. Dacă butoanele sunt tăiate, alege mai puțini DPI sau Din mașină.</string>
|
||||
<string name="car_directions_over_music">Indicații pe canalul de muzică</string>
|
||||
<string name="car_directions_over_music_note">Pentru o mașină care pierde conexiunea sau repornește după o indicație vocală. Gearslip amestecă fiecare indicație în muzică în loc să folosească canalul de navigație al mașinii.</string>
|
||||
<string name="car_experimental_note">Funcții încă în testare. S-ar putea să nu meargă în orice mașină.</string>
|
||||
<string name="car_player_beside_map_note">Partea hărții la care se fixează playerul când atingi butonul lui de fixare.</string>
|
||||
<string name="carset_advanced">Avansat</string>
|
||||
<string name="carset_experimental_heading">Experimental</string>
|
||||
<string name="carset_fit_needs_car">Conectează o mașină ca să salvezi această ajustare pentru ea.</string>
|
||||
<string name="choice_density_car">Din mașină</string>
|
||||
<string name="choice_density_dpi">%1$d DPI</string>
|
||||
<string name="choice_density_custom">Personalizat…</string>
|
||||
<string name="dialer_missed">Ratat · %1$s</string>
|
||||
<string name="dialer_no_recents">Niciun apel recent.</string>
|
||||
<string name="dialer_recents">Recente</string>
|
||||
<string name="dialer_recents_needs_permission">Gearslip are nevoie de permisiunea Jurnale de apeluri ca să afișeze apelurile recente.</string>
|
||||
<string name="help_bluetooth_first">Conectează Bluetooth înaintea cablului.</string>
|
||||
<string name="help_bluetooth_first_detail">Dacă muzica prin Bluetooth nu se conectează, lasă mașina să găsească întâi telefonul prin Bluetooth, apoi bagă cablul. Unele mașini se încurcă dacă vine întâi cablul.</string>
|
||||
<string name="help_usb_data">Permite datele prin USB.</string>
|
||||
<string name="help_usb_data_detail">GrapheneOS poate bloca datele pe portul USB. Dacă controlul portului USB-C e setat pe Doar încărcare, mașina nu vede niciodată telefonul, nici cu un cablu de date. Setează-l să permită datele.</string>
|
||||
<string name="launcher_in_car">Acest dispozitiv e conectat la o mașină, așa că Gearslip e pe ecranul mașinii.</string>
|
||||
<string name="launcher_open_gearslip">Deschide Gearslip</string>
|
||||
<string name="nav_arrival">sosire</string>
|
||||
<string name="nav_lane_any">Orice bandă</string>
|
||||
<string name="nav_lane_from_left">Folosește banda %1$d din stânga</string>
|
||||
<string name="nav_lane_from_right">Folosește banda %1$d din dreapta</string>
|
||||
<plurals name="nav_lane_left">
|
||||
<item quantity="one">Folosește banda din stânga</item>
|
||||
<item quantity="few">Folosește cele %d benzi din stânga</item>
|
||||
<item quantity="other">Folosește cele %d de benzi din stânga</item>
|
||||
</plurals>
|
||||
<string name="nav_lane_middle">Folosește banda din mijloc</string>
|
||||
<string name="nav_lane_middle_lanes">Folosește benzile din mijloc</string>
|
||||
<plurals name="nav_lane_right">
|
||||
<item quantity="one">Folosește banda din dreapta</item>
|
||||
<item quantity="few">Folosește cele %d benzi din dreapta</item>
|
||||
<item quantity="other">Folosește cele %d de benzi din dreapta</item>
|
||||
</plurals>
|
||||
<string name="nav_to_go">rămași</string>
|
||||
<string name="privacy_policy">Politica de confidențialitate</string>
|
||||
<string name="privacy_policy_sub">Ce trimite Gearslip, cui și de ce</string>
|
||||
<string name="settings_bt_address">Adresa Bluetooth a telefonului</string>
|
||||
<string name="settings_bt_address_hint">Copiaz-o din setările Android, la Despre telefon. Gearslip o transmite mașinii, ca să știe că telefonul de pe cablu e cel asociat.</string>
|
||||
<string name="settings_bt_address_invalid">Aceasta nu e o adresă Bluetooth. Arată ca 12:34:56:78:9A:BC.</string>
|
||||
<string name="settings_bt_address_off">Nesetată. Doar pentru o mașină care închide Bluetooth când se conectează Gearslip.</string>
|
||||
<string name="settings_use_as_launcher">Folosește ca lansator</string>
|
||||
<string name="settings_use_as_launcher_off">Pentru o unitate multimedia sau o tabletă pe bord. Ecranul de mașină Gearslip devine ecranul de pornire al acestui dispozitiv.</string>
|
||||
<string name="settings_use_as_launcher_on">Gearslip poate fi ecranul de pornire al acestui dispozitiv. Alege-l la Aplicația de pornire prestabilită.</string>
|
||||
<string name="status_no_car">Telefonul se încarcă, dar nicio mașină nu a răspuns. Unele cabluri doar încarcă; încearcă unul care transferă date.</string>
|
||||
<string name="status_no_car_graphene">Telefonul se încarcă, dar nicio mașină nu a răspuns. Verifică dacă controlul portului USB-C, la Securitate și confidențialitate, permite datele și nu e pe Doar încărcare.</string>
|
||||
<string name="status_open_settings">Deschide setările</string>
|
||||
<string name="status_stuck_cable">Mașina încă nu a afișat Gearslip. Scoate cablul, așteaptă câteva secunde și pune-l la loc. Dacă se tot întâmplă, încearcă alt cablu sau alt port USB și trimite jurnalul într-un issue.</string>
|
||||
<string name="status_stuck_wireless">Mașina încă nu a afișat Gearslip. Oprește și pornește din nou mașina sau conectează-te prin cablu. Dacă se tot întâmplă, trimite jurnalul într-un issue.</string>
|
||||
<string name="update_installed_text">Instalată prin Wi-Fi cât nu era conectată nicio mașină.</string>
|
||||
<string name="update_installed_title">Gearslip actualizat la %1$s</string>
|
||||
<string name="update_silent">Instalează actualizările singur</string>
|
||||
<string name="update_silent_sub">Doar versiuni finale, prin Wi-Fi, când nu e conectată nicio mașină</string>
|
||||
<string name="wireless_auto_off">Nu se conectează singur</string>
|
||||
<string name="wireless_auto_turn_on">Lasă-l să se conecteze singur</string>
|
||||
<plurals name="msg_earlier">
|
||||
<item quantity="one">%1$d mai devreme</item>
|
||||
<item quantity="few">%1$d mai devreme</item>
|
||||
<item quantity="other">%1$d mai devreme</item>
|
||||
</plurals>
|
||||
<string name="msg_hide_quick">Ascunde răspunsurile rapide</string>
|
||||
<string name="msg_quick">Răspuns rapid</string>
|
||||
<string name="msg_you">Tu:</string>
|
||||
<string name="msg_sender">%1$s:</string>
|
||||
<string name="nav_finding_route">Se caută un traseu…</string>
|
||||
<string name="nav_then">Apoi</string>
|
||||
<string name="nav_stop">Oprește navigarea</string>
|
||||
<string name="nav_finding_routes">Se caută trasee…</string>
|
||||
<string name="nav_lane_recommended">Bandă recomandată</string>
|
||||
<string name="nav_lane">Bandă</string>
|
||||
<string name="nav_arrive">Sosire %1$s</string>
|
||||
<string name="template_nothing_playing">Încă nu se redă nimic.</string>
|
||||
<string name="template_search">Caută</string>
|
||||
<string name="template_start">Pornește</string>
|
||||
<string name="call_incoming">Apel primit</string>
|
||||
<string name="call_decline">Respinge</string>
|
||||
<string name="call_answer">Răspunde</string>
|
||||
<string name="call_ringing">Sună deja tare - răspunde aici sau lasă-l să sune.</string>
|
||||
<string name="phoneapps_touch_off">Atingerea e oprită. Activează Gearslip la Setări > Accesibilitate pe telefon.</string>
|
||||
<string name="whatsnew_show_after">Arată după actualizări</string>
|
||||
<string name="whatsnew_got_it">Am înțeles</string>
|
||||
<string name="whatsnew_title">Ce e nou în %1$s</string>
|
||||
<string name="keyboard_space">spațiu</string>
|
||||
<string name="keyboard_hide">Ascunde tastatura</string>
|
||||
<string name="keyboard_submit">Trimite</string>
|
||||
<string name="launcher_broken">Nu merge cu Gearslip</string>
|
||||
<string name="stage_car_app">Aplicație pentru mașină</string>
|
||||
<string name="stage_close">Închide %1$s</string>
|
||||
<string name="stage_close_app">Închide aplicația</string>
|
||||
<string name="dir_slight_left">Virează ușor la stânga</string>
|
||||
<string name="dir_slight_left_onto">Virează ușor la stânga pe %1$s</string>
|
||||
<string name="dir_slight_right">Virează ușor la dreapta</string>
|
||||
<string name="dir_slight_right_onto">Virează ușor la dreapta pe %1$s</string>
|
||||
<string name="dir_left">Virează la stânga</string>
|
||||
<string name="dir_left_onto">Virează la stânga pe %1$s</string>
|
||||
<string name="dir_right">Virează la dreapta</string>
|
||||
<string name="dir_right_onto">Virează la dreapta pe %1$s</string>
|
||||
<string name="dir_sharp_left">Virează strâns la stânga</string>
|
||||
<string name="dir_sharp_left_onto">Virează strâns la stânga pe %1$s</string>
|
||||
<string name="dir_sharp_right">Virează strâns la dreapta</string>
|
||||
<string name="dir_sharp_right_onto">Virează strâns la dreapta pe %1$s</string>
|
||||
<string name="dir_u_turn">Întoarce</string>
|
||||
<string name="dir_keep_left">Ține stânga</string>
|
||||
<string name="dir_keep_left_toward">Ține stânga spre %1$s</string>
|
||||
<string name="dir_keep_right">Ține dreapta</string>
|
||||
<string name="dir_keep_right_toward">Ține dreapta spre %1$s</string>
|
||||
<string name="dir_fork_left">Ține stânga la bifurcație</string>
|
||||
<string name="dir_fork_left_toward">Ține stânga la bifurcație spre %1$s</string>
|
||||
<string name="dir_fork_right">Ține dreapta la bifurcație</string>
|
||||
<string name="dir_fork_right_toward">Ține dreapta la bifurcație spre %1$s</string>
|
||||
<string name="dir_ramp_left">Ia rampa din stânga</string>
|
||||
<string name="dir_ramp_left_onto">Ia rampa din stânga spre %1$s</string>
|
||||
<string name="dir_ramp_right">Ia rampa din dreapta</string>
|
||||
<string name="dir_ramp_right_onto">Ia rampa din dreapta spre %1$s</string>
|
||||
<string name="dir_exit_left">Ia ieșirea din stânga</string>
|
||||
<string name="dir_exit_left_toward">Ia ieșirea din stânga spre %1$s</string>
|
||||
<string name="dir_exit_right">Ia ieșirea din dreapta</string>
|
||||
<string name="dir_exit_right_toward">Ia ieșirea din dreapta spre %1$s</string>
|
||||
<string name="dir_merge_left">Intră pe banda din stânga</string>
|
||||
<string name="dir_merge_left_onto">Intră în stânga pe %1$s</string>
|
||||
<string name="dir_merge_right">Intră pe banda din dreapta</string>
|
||||
<string name="dir_merge_right_onto">Intră în dreapta pe %1$s</string>
|
||||
<string name="dir_merge">Intră în trafic</string>
|
||||
<string name="dir_merge_onto">Intră pe %1$s</string>
|
||||
<string name="dir_roundabout_through">Treci prin sensul giratoriu</string>
|
||||
<string name="dir_roundabout_through_onto">Treci prin sensul giratoriu pe %1$s</string>
|
||||
<string name="dir_roundabout_enter">Intră în sensul giratoriu</string>
|
||||
<string name="dir_roundabout_exit">Ieși din sensul giratoriu</string>
|
||||
<string name="dir_roundabout_exit_onto">Ieși din sensul giratoriu pe %1$s</string>
|
||||
<string name="dir_straight">Mergi înainte</string>
|
||||
<string name="dir_straight_onto">Mergi înainte pe %1$s</string>
|
||||
<string name="dir_continue">Continuă</string>
|
||||
<string name="dir_continue_onto">Continuă pe %1$s</string>
|
||||
<string name="dir_ferry">Ia feribotul</string>
|
||||
<string name="dir_train">Ia trenul</string>
|
||||
<string name="dir_destination">Destinația e în față</string>
|
||||
<string name="dir_destination_left">Destinația e pe stânga</string>
|
||||
<string name="dir_destination_right">Destinația e pe dreapta</string>
|
||||
<string name="dir_roundabout_take">La sensul giratoriu, ia %1$s ieșire</string>
|
||||
<string name="dir_roundabout_take_onto">La sensul giratoriu, ia %1$s ieșire spre %2$s</string>
|
||||
<string name="dir_ordinal_1">prima</string>
|
||||
<string name="dir_ordinal_2">a doua</string>
|
||||
<string name="dir_ordinal_3">a treia</string>
|
||||
<string name="dir_ordinal_4">a patra</string>
|
||||
<string name="dir_ordinal_5">a cincea</string>
|
||||
<string name="dir_ordinal_6">a șasea</string>
|
||||
<string name="dir_ordinal_7">a șaptea</string>
|
||||
<string name="dir_ordinal_8">a opta</string>
|
||||
<string name="dir_in_distance">Peste %1$s, %2$s</string>
|
||||
<plurals name="dir_km">
|
||||
<item quantity="one">%1$s kilometru</item>
|
||||
<item quantity="few">%1$s kilometri</item>
|
||||
<item quantity="other">%1$s de kilometri</item>
|
||||
</plurals>
|
||||
<string name="dir_km_decimal">%1$s kilometri</string>
|
||||
<string name="dir_quarter_mile">un sfert de milă</string>
|
||||
<string name="dir_half_mile">o jumătate de milă</string>
|
||||
<string name="dir_three_quarter_mile">trei sferturi de milă</string>
|
||||
<plurals name="dir_miles">
|
||||
<item quantity="one">%1$s milă</item>
|
||||
<item quantity="few">%1$s mile</item>
|
||||
<item quantity="other">%1$s de mile</item>
|
||||
</plurals>
|
||||
<string name="dir_miles_decimal">%1$s mile</string>
|
||||
<plurals name="dir_feet">
|
||||
<item quantity="one">%1$s picior</item>
|
||||
<item quantity="few">%1$s picioare</item>
|
||||
<item quantity="other">%1$s de picioare</item>
|
||||
</plurals>
|
||||
<plurals name="dir_yards">
|
||||
<item quantity="one">%1$s iard</item>
|
||||
<item quantity="few">%1$s iarzi</item>
|
||||
<item quantity="other">%1$s de iarzi</item>
|
||||
</plurals>
|
||||
<plurals name="dir_meters">
|
||||
<item quantity="one">%1$d metru</item>
|
||||
<item quantity="few">%1$d metri</item>
|
||||
<item quantity="other">%1$d de metri</item>
|
||||
</plurals>
|
||||
<string name="certsetup_installed_hint">Dacă mașina nu se conectează, s-ar putea să respingă acest certificat. Importă-l pe al tău în Setări, la Certificat.</string>
|
||||
<string name="cert_downloaded_footer">Mașina nu se conectează? Unele mașini din jurul lui 2020 încoace resping certificatul aasdk, așa că importă-l pe al tău. Schimbările se aplică la următoarea conectare.</string>
|
||||
<string name="car_cluster_video">Cardul de viraj pe ecranul de bord</string>
|
||||
<string name="car_cluster_video_note">Pentru o mașină cu un al doilea ecran în bord: Gearslip arată acolo următorul viraj. Se aplică la următoarea conectare.</string>
|
||||
<string name="status_idle">Neconectat</string>
|
||||
<string name="status_idle_detail">Conectează-te la portul USB al mașinii sau fără fir. Gearslip se deschide singur când mașina răspunde.</string>
|
||||
<string name="status_connecting">Se conectează</string>
|
||||
<string name="status_projecting">Se proiectează</string>
|
||||
<string name="status_projecting_detail">Se transmite către unitatea multimedia.</string>
|
||||
<string name="status_disconnected">Deconectat</string>
|
||||
<string name="status_disconnected_detail">Unitatea multimedia nu mai e conectată.</string>
|
||||
<string name="places_title">Locuri salvate</string>
|
||||
<string name="places_sub">Spune „navigate home” în orice aplicație de hărți</string>
|
||||
<string name="places_intro">Spune „navigate home”, „take me to work” sau „navigate to” urmat de un nume ales de tine, iar Gearslip trimite adresa aplicației tale de hărți.</string>
|
||||
<string name="places_home">Acasă</string>
|
||||
<string name="places_work">Serviciu</string>
|
||||
<string name="places_other">Locul %1$d</string>
|
||||
<string name="places_name">Nume</string>
|
||||
<string name="places_address">Adresă</string>
|
||||
<string name="places_here">Folosește unde sunt</string>
|
||||
<string name="places_finding">Te caut…</string>
|
||||
<string name="places_not_found">Gearslip nu a găsit unde ești. Verifică dacă locația e pornită.</string>
|
||||
<string name="places_clear">Șterge</string>
|
||||
<string name="assist_listening">Ascult…</string>
|
||||
<string name="assist_one_moment">Un moment…</string>
|
||||
<string name="assist_examples">Încearcă „navigate home” sau „play” și un artist. Spune „help” pentru mai multe.</string>
|
||||
<string name="assist_grant">Permite</string>
|
||||
<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>
|
||||
<string name="car_nav_mic">Butonul microfonului</string>
|
||||
<string name="car_nav_mic_note">Butonul asistentului din bara de navigare. Butonul vocal al mașinii funcționează oricum.</string>
|
||||
<string name="car_apps_add">Adaugă o aplicație</string>
|
||||
<string name="car_apps_add_note">Pentru o aplicație pe care mașina n-o afișează: muzica ei merge prin playerul Gearslip, sau mesajele ei apar în Mesaje.</string>
|
||||
<string name="car_apps_add_pick">Alege o aplicație</string>
|
||||
<string name="car_apps_add_as">Afișează %1$s ca</string>
|
||||
<string name="car_apps_as_media">Muzică și audio</string>
|
||||
<string name="car_apps_as_media_note">Ce se redă, cu redare, pauză și salt. Nu există bibliotecă de răsfoit.</string>
|
||||
<string name="car_apps_as_messaging">Mesaje</string>
|
||||
<string name="car_apps_as_messaging_note">Notificările ei, citite cu voce tare, cu răspunsuri.</string>
|
||||
<string name="car_apps_added_by_you">Adăugată de tine</string>
|
||||
<string name="car_apps_remove">Elimină %1$s</string>
|
||||
</resources>
|
||||
@@ -200,6 +200,8 @@
|
||||
<string name="car_right">Справа</string>
|
||||
<string name="car_left">Слева</string>
|
||||
<string name="car_autoplay">Воспроизводить при подключении</string>
|
||||
<string name="car_more_lyrics">Больше источников текстов</string>
|
||||
<string name="car_more_lyrics_note">Также спрашивает BetterLyrics и KuGou, которые тогда получают название и исполнителя песни. Если выключено, спрашивает только LRCLIB.</string>
|
||||
<string name="car_spoken_directions">Голосовые подсказки</string>
|
||||
<string name="car_spoken_directions_note">Выключите, если голос вашего навигатора уже звучит в машине.</string>
|
||||
<string name="car_phone_speech">Распознавание речи телефона</string>
|
||||
@@ -211,7 +213,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">Показывается приложением «Браузер» в машине. Если пусто, открывается стартовая страница с закладками. Применяется при следующем подключении.</string>
|
||||
<string name="web_page_note">Показывается приложением «Браузер» в машине. Если пусто, открывается DuckDuckGo. Действует со следующего подключения.</string>
|
||||
<string name="car_audio">Звук в машине</string>
|
||||
<string name="car_audio_bt">Bluetooth (рекомендуется)</string>
|
||||
<string name="car_audio_bt_sub">Никаких запросов в каждой поездке, а голос навигатора звучит в машине</string>
|
||||
@@ -429,6 +431,8 @@
|
||||
<string name="carset_fit">Подогнать под экран</string>
|
||||
<string name="off">Выкл.</string>
|
||||
<string name="on">Вкл.</string>
|
||||
<string name="carset_lyrics_from">Тексты из</string>
|
||||
<string name="carset_more_sources">Больше источников</string>
|
||||
<string name="carset_hide">Скрыть</string>
|
||||
<string name="carset_show">Показать</string>
|
||||
<string name="carset_phone">Телефон</string>
|
||||
@@ -438,7 +442,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">Стартовая страница (по умолчанию) — нажмите, чтобы указать страницу</string>
|
||||
<string name="carset_web_default">DuckDuckGo (по умолчанию) - нажмите, чтобы задать другую страницу</string>
|
||||
<string name="carset_web_note">Показывается приложением «Браузер» в лаунчере. Действует со следующего подключения.</string>
|
||||
<string name="carset_screen_fit">Подгонка экрана</string>
|
||||
<string name="carset_top">Сверху</string>
|
||||
@@ -457,6 +461,7 @@
|
||||
<string name="carset_px">%1$dpx</string>
|
||||
|
||||
<!-- Car media app -->
|
||||
<string name="media_open_on_phone">Открыть на телефоне</string>
|
||||
<string name="media_now_playing">Сейчас играет</string>
|
||||
<string name="media_up_next">Далее</string>
|
||||
<string name="media_browse">Обзор</string>
|
||||
@@ -466,7 +471,7 @@
|
||||
<string name="media_press_play">Нажмите воспроизведение, чтобы продолжить с того места, где остановились, или найдите что-нибудь в «Обзоре».</string>
|
||||
|
||||
<string name="media_keeps_library">%1$s не открывает свою библиотеку.</string>
|
||||
<string name="media_needs_access">Gearslip всё равно сможет его воспроизводить, когда получит доступ к уведомлениям. Разрешите его на телефоне, когда припаркуетесь.</string>
|
||||
<string name="media_needs_access">Gearslip всё равно может им управлять, если получит доступ к уведомлениям на телефоне. Разрешите его в Настройки > Уведомления > Доступ к уведомлениям.</string>
|
||||
<string name="media_not_running">%1$s не запущено.</string>
|
||||
<string name="media_not_running_body">%1$s играет через плеер Gearslip, но только пока открыто на телефоне. Откройте его один раз, и оно продолжит играть здесь, в машине.</string>
|
||||
<string name="media_open_app">Открыть %1$s</string>
|
||||
@@ -659,7 +664,7 @@
|
||||
<string name="notif_title">Уведомления</string>
|
||||
<string name="notif_clear_all">Очистить все</string>
|
||||
<string name="notif_empty">Пока ничего. Новые уведомления с телефона появятся здесь.</string>
|
||||
<string name="notif_off">Gearslip нужен доступ к уведомлениям, чтобы показывать их здесь. Разрешите его на телефоне, когда припаркуетесь.</string>
|
||||
<string name="notif_off">Уведомления выключены. Разрешите Gearslip в Настройки > Уведомления > Доступ к уведомлениям на телефоне.</string>
|
||||
<string name="notif_replied">Отвечено</string>
|
||||
|
||||
<!-- Messages -->
|
||||
@@ -845,131 +850,4 @@
|
||||
<string name="settings_language">Язык</string>
|
||||
<string name="settings_language_phone">Как на телефоне</string>
|
||||
<string name="player_next_chapter">Следующая глава</string>
|
||||
<string name="allow_on_phone">Разрешить на телефоне</string>
|
||||
<string name="app_all_apps">Все приложения</string>
|
||||
<string name="bt_address_car_note">Когда припаркуетесь, завершите один шаг настройки на телефоне.</string>
|
||||
<string name="bt_address_card_body">Некоторые машины остаются на связи, только если знают Bluetooth-адрес телефона. Android скрывает его от приложений, поэтому скопируйте его в разделе «О телефоне». Нажмите и удерживайте там Bluetooth-адрес, нажмите «Копировать» и вернитесь.</string>
|
||||
<string name="bt_address_card_title">Помогите %1$s узнать этот телефон</string>
|
||||
<string name="bt_address_not_now">Не сейчас</string>
|
||||
<string name="bt_address_open">Открыть «О телефоне»</string>
|
||||
<string name="bt_address_type">Ввести вручную</string>
|
||||
<string name="bt_address_use_body">Gearslip передаст его машине, чтобы она знала, что это сопряжённый телефон.</string>
|
||||
<string name="bt_address_use_title">Использовать этот Bluetooth-адрес?</string>
|
||||
<string name="bt_address_your_car">машине</string>
|
||||
<string name="car_apps_finding">Поиск приложений…</string>
|
||||
<string name="car_apps_none">Приложения для машины не найдены</string>
|
||||
<string name="car_apps_sub">Выберите, какие приложения будут в машине</string>
|
||||
<string name="car_audio_blocked">%1$s не даёт другим приложениям воспроизводить свой звук, поэтому он не дойдёт до машины через Gearslip. Попробуйте другое музыкальное приложение.</string>
|
||||
<string name="car_audio_switched">Эта машина выключает музыку по Bluetooth, пока работает Gearslip, поэтому музыка теперь идёт через Gearslip. Разрешите запрос на телефоне.</string>
|
||||
<string name="car_autoplay_note">Продолжает музыку с того места, где она остановилась, как только машина подключится.</string>
|
||||
<string name="car_density">Плотность экрана машины</string>
|
||||
<string name="car_density_note">Крупнее текст и элементы управления, в том числе в приложениях. Попробуйте 200 DPI с размером «Обычный». Чтобы применить, переподключите машину или заново откройте предпросмотр. Если элементы обрезаются, выберите меньше DPI или «От машины».</string>
|
||||
<string name="car_directions_over_music">Подсказки через канал музыки</string>
|
||||
<string name="car_directions_over_music_note">Для машины, которая теряет связь или перезагружается после голосовой подсказки. Gearslip подмешивает каждую подсказку в музыку вместо канала навигации машины.</string>
|
||||
<string name="car_experimental_note">Функции, которые ещё тестируются. Они могут работать не в каждой машине.</string>
|
||||
<string name="car_player_beside_map_note">Сторона карты, к которой прикрепляется плеер, когда вы нажимаете его кнопку закрепления.</string>
|
||||
<string name="carset_advanced">Дополнительно</string>
|
||||
<string name="carset_experimental_heading">Экспериментальное</string>
|
||||
<string name="carset_fit_needs_car">Подключите машину, чтобы сохранить для неё эту подгонку.</string>
|
||||
<string name="choice_density_car">От машины</string>
|
||||
<string name="choice_density_dpi">%1$d DPI</string>
|
||||
<string name="choice_density_custom">Своё значение…</string>
|
||||
<string name="dialer_missed">Пропущенный · %1$s</string>
|
||||
<string name="dialer_no_recents">Нет недавних вызовов.</string>
|
||||
<string name="dialer_recents">Недавние</string>
|
||||
<string name="dialer_recents_needs_permission">Gearslip нужно разрешение «Список вызовов», чтобы показать недавние вызовы.</string>
|
||||
<string name="help_bluetooth_first">Подключите Bluetooth раньше кабеля.</string>
|
||||
<string name="help_bluetooth_first_detail">Если музыка по Bluetooth не подключается, дайте машине сначала найти телефон по Bluetooth, а потом подключите кабель. Некоторые машины путаются, когда кабель первый.</string>
|
||||
<string name="help_usb_data">Разрешите передачу данных по USB.</string>
|
||||
<string name="help_usb_data_detail">GrapheneOS может блокировать данные на порту USB. Если управление портом USB-C стоит на «Только зарядка», машина никогда не увидит телефон, даже с кабелем для данных. Разрешите передачу данных.</string>
|
||||
<string name="launcher_in_car">Это устройство подключено к машине, поэтому Gearslip на экране машины.</string>
|
||||
<string name="launcher_open_gearslip">Открыть Gearslip</string>
|
||||
<string name="nav_arrival">прибытие</string>
|
||||
<string name="nav_lane_any">Любая полоса</string>
|
||||
<string name="nav_lane_from_left">Займите %1$d-ю полосу слева</string>
|
||||
<string name="nav_lane_from_right">Займите %1$d-ю полосу справа</string>
|
||||
<plurals name="nav_lane_left">
|
||||
<item quantity="one">Займите левую полосу</item>
|
||||
<item quantity="few">Займите одну из %d левых полос</item>
|
||||
<item quantity="many">Займите одну из %d левых полос</item>
|
||||
<item quantity="other">Займите одну из %d левых полос</item>
|
||||
</plurals>
|
||||
<string name="nav_lane_middle">Займите среднюю полосу</string>
|
||||
<string name="nav_lane_middle_lanes">Займите средние полосы</string>
|
||||
<plurals name="nav_lane_right">
|
||||
<item quantity="one">Займите правую полосу</item>
|
||||
<item quantity="few">Займите одну из %d правых полос</item>
|
||||
<item quantity="many">Займите одну из %d правых полос</item>
|
||||
<item quantity="other">Займите одну из %d правых полос</item>
|
||||
</plurals>
|
||||
<string name="nav_to_go">осталось</string>
|
||||
<string name="privacy_policy">Политика конфиденциальности</string>
|
||||
<string name="privacy_policy_sub">Что Gearslip отправляет, кому и зачем</string>
|
||||
<string name="settings_bt_address">Bluetooth-адрес телефона</string>
|
||||
<string name="settings_bt_address_hint">Скопируйте его в настройках Android, в разделе «О телефоне». Gearslip передаст его машине, чтобы она знала, что телефон на кабеле — сопряжённый.</string>
|
||||
<string name="settings_bt_address_invalid">Это не Bluetooth-адрес. Он выглядит так: 12:34:56:78:9A:BC.</string>
|
||||
<string name="settings_bt_address_off">Не задан. Только для машины, которая отключает Bluetooth, когда Gearslip подключается.</string>
|
||||
<string name="settings_use_as_launcher">Использовать как лаунчер</string>
|
||||
<string name="settings_use_as_launcher_off">Для магнитолы или планшета на приборной панели. Экран машины Gearslip станет главным экраном этого устройства.</string>
|
||||
<string name="settings_use_as_launcher_on">Gearslip может быть главным экраном этого устройства. Выберите его в разделе «Главный экран по умолчанию».</string>
|
||||
<string name="status_no_car">Телефон заряжается, но ни одна машина не ответила. Некоторые кабели только заряжают; попробуйте кабель, который передаёт данные.</string>
|
||||
<string name="status_no_car_graphene">Телефон заряжается, но ни одна машина не ответила. Проверьте, что управление портом USB-C в разделе «Безопасность и конфиденциальность» разрешает данные и не стоит на «Только зарядка».</string>
|
||||
<string name="status_open_settings">Открыть настройки</string>
|
||||
<string name="status_stuck_cable">Машина ещё не показала Gearslip. Отключите кабель, подождите несколько секунд и подключите снова. Если это повторяется, попробуйте другой кабель или порт USB и поделитесь журналом в issue.</string>
|
||||
<string name="status_stuck_wireless">Машина ещё не показала Gearslip. Выключите и снова включите машину или подключитесь кабелем. Если это повторяется, поделитесь журналом в issue.</string>
|
||||
<string name="update_installed_text">Установлено по Wi-Fi, пока машина не была подключена.</string>
|
||||
<string name="update_installed_title">Gearslip обновлён до %1$s</string>
|
||||
<string name="update_silent">Устанавливать обновления самостоятельно</string>
|
||||
<string name="update_silent_sub">Только готовые версии, по Wi-Fi, когда машина не подключена</string>
|
||||
<string name="assist_listening">Слушаю…</string>
|
||||
<string name="assist_one_moment">Секунду…</string>
|
||||
<string name="assist_examples">Попробуйте «navigate home» или «play» и исполнителя. Скажите «help», чтобы узнать больше.</string>
|
||||
<string name="assist_grant">Разрешить</string>
|
||||
<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>
|
||||
<string name="car_nav_mic">Кнопка микрофона</string>
|
||||
<string name="car_nav_mic_note">Кнопка ассистента на панели навигации. Голосовая кнопка машины работает в любом случае.</string>
|
||||
<string name="car_apps_add">Добавить приложение</string>
|
||||
<string name="car_apps_add_note">Для приложения, которого нет в машине: его музыка играет через плеер Gearslip, или его сообщения показываются в «Сообщениях».</string>
|
||||
<string name="car_apps_add_pick">Выберите приложение</string>
|
||||
<string name="car_apps_add_as">Показывать %1$s как</string>
|
||||
<string name="car_apps_as_media">Музыка и аудио</string>
|
||||
<string name="car_apps_as_media_note">Сейчас играет: воспроизведение, пауза и переключение. Библиотеки для просмотра нет.</string>
|
||||
<string name="car_apps_as_messaging">Сообщения</string>
|
||||
<string name="car_apps_as_messaging_note">Его уведомления, прочитанные вслух, с ответами.</string>
|
||||
<string name="car_apps_added_by_you">Добавлено вами</string>
|
||||
<string name="car_apps_remove">Удалить %1$s</string>
|
||||
</resources>
|
||||
@@ -200,6 +200,8 @@
|
||||
<string name="car_right">Höger</string>
|
||||
<string name="car_left">Vänster</string>
|
||||
<string name="car_autoplay">Spela upp vid anslutning</string>
|
||||
<string name="car_more_lyrics">Fler källor för låttexter</string>
|
||||
<string name="car_more_lyrics_note">Frågar även BetterLyrics och KuGou, som då får låtens namn och artist. Av frågar bara LRCLIB.</string>
|
||||
<string name="car_spoken_directions">Röstvägledning</string>
|
||||
<string name="car_spoken_directions_note">Stäng av den om kartappens egen röst redan hörs i bilen.</string>
|
||||
<string name="car_phone_speech">Använd telefonens taligenkänning</string>
|
||||
@@ -211,7 +213,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 startsidan med dina bokmärken. 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="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>
|
||||
@@ -427,6 +429,8 @@
|
||||
<string name="carset_fit">Anpassa till skärmen</string>
|
||||
<string name="off">Av</string>
|
||||
<string name="on">På</string>
|
||||
<string name="carset_lyrics_from">Låttexter från</string>
|
||||
<string name="carset_more_sources">Fler källor</string>
|
||||
<string name="carset_hide">Dölj</string>
|
||||
<string name="carset_show">Visa</string>
|
||||
<string name="carset_phone">Telefon</string>
|
||||
@@ -436,7 +440,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">Startsida (standard) - tryck för att välja en sida</string>
|
||||
<string name="carset_web_default">DuckDuckGo (standard) - tryck för att välja en annan 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>
|
||||
@@ -455,6 +459,7 @@
|
||||
<string name="carset_px">%1$dpx</string>
|
||||
|
||||
<!-- Car media app -->
|
||||
<string name="media_open_on_phone">Öppna på telefonen</string>
|
||||
<string name="media_now_playing">Spelas nu</string>
|
||||
<string name="media_up_next">Härnäst</string>
|
||||
<string name="media_browse">Bläddra</string>
|
||||
@@ -464,7 +469,7 @@
|
||||
<string name="media_press_play">Tryck på spela för att fortsätta där du slutade, eller hitta något i Bläddra.</string>
|
||||
|
||||
<string name="media_keeps_library">%1$s håller sitt bibliotek för sig själv.</string>
|
||||
<string name="media_needs_access">Gearslip kan ändå spela den när den har åtkomst till aviseringar. Tillåt det på telefonen när du har parkerat.</string>
|
||||
<string name="media_needs_access">Gearslip kan ändå styra den när den har aviseringsåtkomst på telefonen. Tillåt det under Inställningar > Aviseringar > Aviseringsåtkomst.</string>
|
||||
<string name="media_not_running">%1$s körs inte.</string>
|
||||
<string name="media_not_running_body">%1$s spelas via Gearslips egen spelare, men bara medan appen är öppen på telefonen. Öppna den en gång så fortsätter den spela här i bilen.</string>
|
||||
<string name="media_open_app">Öppna %1$s</string>
|
||||
@@ -655,7 +660,7 @@
|
||||
<string name="notif_title">Aviseringar</string>
|
||||
<string name="notif_clear_all">Rensa alla</string>
|
||||
<string name="notif_empty">Inget än. Nya aviseringar från telefonen visas här.</string>
|
||||
<string name="notif_off">Gearslip behöver åtkomst till aviseringar för att visa dem här. Tillåt det på telefonen när du har parkerat.</string>
|
||||
<string name="notif_off">Aviseringar är av. Tillåt Gearslip under Inställningar > Aviseringar > Aviseringsåtkomst på telefonen.</string>
|
||||
<string name="notif_replied">Besvarad</string>
|
||||
|
||||
<!-- Messages -->
|
||||
@@ -829,127 +834,4 @@
|
||||
<string name="settings_language">Språk</string>
|
||||
<string name="settings_language_phone">Samma som telefonen</string>
|
||||
<string name="player_next_chapter">Nästa kapitel</string>
|
||||
<string name="allow_on_phone">Tillåt på telefonen</string>
|
||||
<string name="app_all_apps">Alla appar</string>
|
||||
<string name="bt_address_car_note">Gör klart ett steg i inställningen på telefonen när du har parkerat.</string>
|
||||
<string name="bt_address_card_body">Vissa bilar håller bara anslutningen om de känner till telefonens Bluetooth-adress. Android döljer den för appar, så kopiera den från Om telefonen. Håll ned Bluetooth-adressen där, tryck på Kopiera och kom tillbaka.</string>
|
||||
<string name="bt_address_card_title">Hjälp %1$s att känna igen telefonen</string>
|
||||
<string name="bt_address_not_now">Inte nu</string>
|
||||
<string name="bt_address_open">Öppna Om telefonen</string>
|
||||
<string name="bt_address_type">Skriv in den i stället</string>
|
||||
<string name="bt_address_use_body">Gearslip skickar den till bilen så att den vet att det här är den parkopplade telefonen.</string>
|
||||
<string name="bt_address_use_title">Använda den här Bluetooth-adressen?</string>
|
||||
<string name="bt_address_your_car">din bil</string>
|
||||
<string name="car_apps_finding">Letar efter appar…</string>
|
||||
<string name="car_apps_none">Inga bilappar hittades</string>
|
||||
<string name="car_apps_sub">Välj vilka appar som visas i bilen</string>
|
||||
<string name="car_audio_blocked">%1$s låter inte andra appar spela upp dess ljud, så det når inte bilen via Gearslip. Prova en annan musikapp.</string>
|
||||
<string name="car_audio_switched">Den här bilen stänger av Bluetooth-musik medan Gearslip körs, så musiken går nu via Gearslip. Godkänn frågan på telefonen.</string>
|
||||
<string name="car_autoplay_note">Fortsätter musiken där den slutade så fort bilen ansluter.</string>
|
||||
<string name="car_density">Bilskärmens täthet</string>
|
||||
<string name="car_density_note">Större text och kontroller, även i appar. Prova 200 DPI med Storlek på Normal. Anslut bilen igen eller öppna förhandsvisningen igen för att tillämpa det. Om kontroller klipps av, välj lägre DPI eller Från bilen.</string>
|
||||
<string name="car_directions_over_music">Vägbeskrivning via musikkanalen</string>
|
||||
<string name="car_directions_over_music_note">För en bil som tappar anslutningen eller startar om efter en uppläst instruktion. Gearslip blandar in varje instruktion i musiken i stället för att använda bilens navigeringskanal.</string>
|
||||
<string name="car_experimental_note">Funktioner som fortfarande testas. De kanske inte fungerar i alla bilar.</string>
|
||||
<string name="car_player_beside_map_note">Den sida av kartan som spelaren dockas till när du trycker på dess dockningsknapp.</string>
|
||||
<string name="carset_advanced">Avancerat</string>
|
||||
<string name="carset_experimental_heading">Experimentellt</string>
|
||||
<string name="carset_fit_needs_car">Anslut en bil för att spara den här justeringen för den.</string>
|
||||
<string name="choice_density_car">Från bilen</string>
|
||||
<string name="choice_density_dpi">%1$d DPI</string>
|
||||
<string name="choice_density_custom">Anpassad…</string>
|
||||
<string name="dialer_missed">Missat · %1$s</string>
|
||||
<string name="dialer_no_recents">Inga senaste samtal.</string>
|
||||
<string name="dialer_recents">Senaste</string>
|
||||
<string name="dialer_recents_needs_permission">Gearslip behöver behörigheten Samtalsloggar för att visa senaste samtal.</string>
|
||||
<string name="help_bluetooth_first">Anslut Bluetooth före kabeln.</string>
|
||||
<string name="help_bluetooth_first_detail">Om musik över Bluetooth inte ansluter, låt bilen hitta telefonen via Bluetooth först och anslut sedan kabeln. Vissa bilar blir förvirrade när kabeln kommer först.</string>
|
||||
<string name="help_usb_data">Tillåt USB-data.</string>
|
||||
<string name="help_usb_data_detail">GrapheneOS kan blockera data på USB-porten. Om USB-C-portkontroll står på Endast laddning ser bilen aldrig telefonen, inte ens med en datakabel. Ställ in den så att data tillåts.</string>
|
||||
<string name="launcher_in_car">Den här enheten är ansluten till en bil, så Gearslip visas på bilens skärm.</string>
|
||||
<string name="launcher_open_gearslip">Öppna Gearslip</string>
|
||||
<string name="nav_arrival">ankomst</string>
|
||||
<string name="nav_lane_any">Valfritt körfält</string>
|
||||
<string name="nav_lane_from_left">Använd körfält %1$d från vänster</string>
|
||||
<string name="nav_lane_from_right">Använd körfält %1$d från höger</string>
|
||||
<plurals name="nav_lane_left">
|
||||
<item quantity="one">Använd vänster körfält</item>
|
||||
<item quantity="other">Använd de %d vänstra körfälten</item>
|
||||
</plurals>
|
||||
<string name="nav_lane_middle">Använd mittenkörfältet</string>
|
||||
<string name="nav_lane_middle_lanes">Använd mittenkörfälten</string>
|
||||
<plurals name="nav_lane_right">
|
||||
<item quantity="one">Använd höger körfält</item>
|
||||
<item quantity="other">Använd de %d högra körfälten</item>
|
||||
</plurals>
|
||||
<string name="nav_to_go">kvar</string>
|
||||
<string name="privacy_policy">Integritetspolicy</string>
|
||||
<string name="privacy_policy_sub">Vad Gearslip skickar, till vem och varför</string>
|
||||
<string name="settings_bt_address">Telefonens Bluetooth-adress</string>
|
||||
<string name="settings_bt_address_hint">Kopiera den från Androids inställningar, under Om telefonen. Gearslip skickar den till bilen så att den vet att telefonen i kabeln är den parkopplade.</string>
|
||||
<string name="settings_bt_address_invalid">Det är ingen Bluetooth-adress. Den ser ut som 12:34:56:78:9A:BC.</string>
|
||||
<string name="settings_bt_address_off">Inte inställd. Bara för en bil som stänger av Bluetooth när Gearslip ansluter.</string>
|
||||
<string name="settings_use_as_launcher">Använd som startprogram</string>
|
||||
<string name="settings_use_as_launcher_off">För en bilstereo eller en surfplatta på instrumentbrädan. Gearslips bilskärm blir enhetens startskärm.</string>
|
||||
<string name="settings_use_as_launcher_on">Gearslip kan vara enhetens startskärm. Välj den under Standardapp för startskärm.</string>
|
||||
<string name="status_no_car">Telefonen laddas, men ingen bil har svarat. Vissa kablar laddar bara; prova en som för över data.</string>
|
||||
<string name="status_no_car_graphene">Telefonen laddas, men ingen bil har svarat. Kontrollera att USB-C-portkontroll under Säkerhet och integritet tillåter data och inte står på Endast laddning.</string>
|
||||
<string name="status_open_settings">Öppna inställningar</string>
|
||||
<string name="status_stuck_cable">Bilen visar inte Gearslip än. Dra ur kabeln, vänta några sekunder och sätt i den igen. Om det fortsätter, prova en annan kabel eller USB-port och dela loggen i ett ärende.</string>
|
||||
<string name="status_stuck_wireless">Bilen visar inte Gearslip än. Stäng av bilen och starta den igen, eller anslut med kabel. Om det fortsätter, dela loggen i ett ärende.</string>
|
||||
<string name="update_installed_text">Installerad via wifi medan ingen bil var ansluten.</string>
|
||||
<string name="update_installed_title">Gearslip uppdaterad till %1$s</string>
|
||||
<string name="update_silent">Installera uppdateringar själv</string>
|
||||
<string name="update_silent_sub">Bara färdiga versioner, via wifi, när ingen bil är ansluten</string>
|
||||
<string name="assist_listening">Lyssnar…</string>
|
||||
<string name="assist_one_moment">Ett ögonblick…</string>
|
||||
<string name="assist_examples">Prova ”navigate home” eller ”play” och en artist. Säg ”help” för mer.</string>
|
||||
<string name="assist_grant">Tillåt</string>
|
||||
<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>
|
||||
<string name="car_nav_mic">Mikrofonknapp</string>
|
||||
<string name="car_nav_mic_note">Assistentens knapp i navigeringsfältet. Bilens egen röstknapp fungerar oavsett.</string>
|
||||
<string name="car_apps_add">Lägg till en app</string>
|
||||
<string name="car_apps_add_note">För en app som bilen inte visar: musiken spelas i Gearslips spelare, eller så visas meddelandena under Meddelanden.</string>
|
||||
<string name="car_apps_add_pick">Välj en app</string>
|
||||
<string name="car_apps_add_as">Visa %1$s som</string>
|
||||
<string name="car_apps_as_media">Musik och ljud</string>
|
||||
<string name="car_apps_as_media_note">Det som spelas, med spela, pausa och hoppa. Inget bibliotek att bläddra i.</string>
|
||||
<string name="car_apps_as_messaging">Meddelanden</string>
|
||||
<string name="car_apps_as_messaging_note">Dess aviseringar, upplästa, med svar.</string>
|
||||
<string name="car_apps_added_by_you">Tillagd av dig</string>
|
||||
<string name="car_apps_remove">Ta bort %1$s</string>
|
||||
</resources>
|
||||
@@ -200,6 +200,8 @@
|
||||
<string name="car_right">Sağ</string>
|
||||
<string name="car_left">Sol</string>
|
||||
<string name="car_autoplay">Bağlanınca çal</string>
|
||||
<string name="car_more_lyrics">Daha fazla şarkı sözü kaynağı</string>
|
||||
<string name="car_more_lyrics_note">BetterLyrics ve KuGou’ya da sorar; bunlar şarkının adını ve sanatçısını alır. Kapalıyken yalnızca LRCLIB’ye sorar.</string>
|
||||
<string name="car_spoken_directions">Sesli yol tarifi</string>
|
||||
<string name="car_spoken_directions_note">Harita uygulamanızın kendi sesi araçta zaten çalıyorsa kapatın.</string>
|
||||
<string name="car_phone_speech">Telefonun konuşma tanımasını kullan</string>
|
||||
@@ -211,7 +213,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ı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="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="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>
|
||||
@@ -427,6 +429,8 @@
|
||||
<string name="carset_fit">Ekrana sığdır</string>
|
||||
<string name="off">Kapalı</string>
|
||||
<string name="on">Açık</string>
|
||||
<string name="carset_lyrics_from">Sözlerin kaynağı</string>
|
||||
<string name="carset_more_sources">Daha fazla kaynak</string>
|
||||
<string name="carset_hide">Gizle</string>
|
||||
<string name="carset_show">Göster</string>
|
||||
<string name="carset_phone">Telefon</string>
|
||||
@@ -436,7 +440,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">Başlangıç sayfası (varsayılan) - bir sayfa ayarlamak için dokunun</string>
|
||||
<string name="carset_web_default">DuckDuckGo (varsayılan) - başka 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>
|
||||
@@ -455,6 +459,7 @@
|
||||
<string name="carset_px">%1$dpx</string>
|
||||
|
||||
<!-- Car media app -->
|
||||
<string name="media_open_on_phone">Telefonda aç</string>
|
||||
<string name="media_now_playing">Şimdi çalıyor</string>
|
||||
<string name="media_up_next">Sıradaki</string>
|
||||
<string name="media_browse">Göz at</string>
|
||||
@@ -464,7 +469,7 @@
|
||||
<string name="media_press_play">Kaldığınız yerden devam etmek için oynat’a basın veya Göz at’ta bir şey bulun.</string>
|
||||
|
||||
<string name="media_keeps_library">%1$s kitaplığını paylaşmıyor.</string>
|
||||
<string name="media_needs_access">Gearslip bildirim erişimi alınca yine de oynatabilir. Park ettiğinde telefonda izin ver.</string>
|
||||
<string name="media_needs_access">Gearslip, telefonda bildirim erişimi olduğunda yine de onu kontrol edebilir. Ayarlar > Bildirimler > Bildirim erişimi altında izin verin.</string>
|
||||
<string name="media_not_running">%1$s çalışmıyor.</string>
|
||||
<string name="media_not_running_body">%1$s, Gearslip’in kendi oynatıcısı üzerinden çalar, ama yalnızca telefonda açıkken. Bir kez açın, burada araçta çalmaya devam eder.</string>
|
||||
<string name="media_open_app">%1$s uygulamasını aç</string>
|
||||
@@ -655,7 +660,7 @@
|
||||
<string name="notif_title">Bildirimler</string>
|
||||
<string name="notif_clear_all">Tümünü temizle</string>
|
||||
<string name="notif_empty">Henüz bir şey yok. Telefondan gelen yeni bildirimler burada görünür.</string>
|
||||
<string name="notif_off">Gearslip\'in bildirimleri burada göstermesi için bildirim erişimi gerekir. Park ettiğinde telefonda izin ver.</string>
|
||||
<string name="notif_off">Bildirimler kapalı. Telefonda Ayarlar > Bildirimler > Bildirim erişimi altında Gearslip’e izin verin.</string>
|
||||
<string name="notif_replied">Yanıtlandı</string>
|
||||
|
||||
<!-- Messages -->
|
||||
@@ -829,127 +834,4 @@
|
||||
<string name="settings_language">Dil</string>
|
||||
<string name="settings_language_phone">Telefonla aynı</string>
|
||||
<string name="player_next_chapter">Sonraki bölüm</string>
|
||||
<string name="allow_on_phone">Telefonda izin ver</string>
|
||||
<string name="app_all_apps">Tüm uygulamalar</string>
|
||||
<string name="bt_address_car_note">Park ettiğinde telefonunda bir kurulum adımını bitir.</string>
|
||||
<string name="bt_address_card_body">Bazı arabalar yalnızca telefonun Bluetooth adresini bildiklerinde bağlı kalır. Android bunu uygulamalardan saklar, bu yüzden Telefon hakkında bölümünden kopyala. Oradaki Bluetooth adresine basılı tut, Kopyala\'ya dokun ve geri dön.</string>
|
||||
<string name="bt_address_card_title">%1$s bu telefonu tanısın</string>
|
||||
<string name="bt_address_not_now">Şimdi değil</string>
|
||||
<string name="bt_address_open">Telefon hakkında\'yı aç</string>
|
||||
<string name="bt_address_type">Elle yaz</string>
|
||||
<string name="bt_address_use_body">Gearslip bunu arabana iletir, böylece araba bunun eşlenen telefon olduğunu bilir.</string>
|
||||
<string name="bt_address_use_title">Bu Bluetooth adresi kullanılsın mı?</string>
|
||||
<string name="bt_address_your_car">Araban</string>
|
||||
<string name="car_apps_finding">Uygulamalar aranıyor…</string>
|
||||
<string name="car_apps_none">Araba uygulaması bulunamadı</string>
|
||||
<string name="car_apps_sub">Arabada hangi uygulamaların görüneceğini seç</string>
|
||||
<string name="car_audio_blocked">%1$s, sesini başka uygulamaların çalmasına izin vermiyor, bu yüzden Gearslip üzerinden arabaya ulaşamıyor. Başka bir müzik uygulaması dene.</string>
|
||||
<string name="car_audio_switched">Bu araba Gearslip çalışırken Bluetooth müziği kapatıyor, bu yüzden müzik artık Gearslip üzerinden gidiyor. Telefonundaki isteğe izin ver.</string>
|
||||
<string name="car_autoplay_note">Araba bağlanır bağlanmaz müziği kaldığı yerden sürdürür.</string>
|
||||
<string name="car_density">Araba ekranı yoğunluğu</string>
|
||||
<string name="car_density_note">Uygulamalar dahil daha büyük yazı ve düğmeler. Boyut Normal iken 200 DPI dene. Uygulamak için arabayı yeniden bağla ya da önizlemeyi yeniden aç. Düğmeler kesilirse daha düşük DPI veya Arabadan seç.</string>
|
||||
<string name="car_directions_over_music">Yol tarifi müzik kanalından</string>
|
||||
<string name="car_directions_over_music_note">Sesli bir yönlendirmeden sonra bağlantıyı kesen veya yeniden başlayan bir araba için. Gearslip her yönlendirmeyi arabanın navigasyon kanalı yerine müziğe karıştırır.</string>
|
||||
<string name="car_experimental_note">Hâlâ test edilen özellikler. Her arabada çalışmayabilir.</string>
|
||||
<string name="car_player_beside_map_note">Sabitleme düğmesine dokunduğunda oynatıcının sabitlendiği harita kenarı.</string>
|
||||
<string name="carset_advanced">Gelişmiş</string>
|
||||
<string name="carset_experimental_heading">Deneysel</string>
|
||||
<string name="carset_fit_needs_car">Bu ayarı bir araba için kaydetmek üzere arabayı bağla.</string>
|
||||
<string name="choice_density_car">Arabadan</string>
|
||||
<string name="choice_density_dpi">%1$d DPI</string>
|
||||
<string name="choice_density_custom">Özel…</string>
|
||||
<string name="dialer_missed">Cevapsız · %1$s</string>
|
||||
<string name="dialer_no_recents">Son arama yok.</string>
|
||||
<string name="dialer_recents">Son aramalar</string>
|
||||
<string name="dialer_recents_needs_permission">Gearslip son aramaları listelemek için Arama kayıtları iznine ihtiyaç duyar.</string>
|
||||
<string name="help_bluetooth_first">Bluetooth\'u kablodan önce bağla.</string>
|
||||
<string name="help_bluetooth_first_detail">Bluetooth üzerinden müzik bağlanmıyorsa, önce arabanın telefonu Bluetooth ile bulmasına izin ver, sonra kabloyu tak. Bazı arabalar kablo önce gelince karışıyor.</string>
|
||||
<string name="help_usb_data">USB veri aktarımına izin ver.</string>
|
||||
<string name="help_usb_data_detail">GrapheneOS USB bağlantı noktasında veriyi engelleyebilir. USB-C bağlantı noktası denetimi Yalnızca şarj olarak ayarlıysa, veri kablosuyla bile araba telefonu hiç görmez. Veriye izin verecek şekilde ayarla.</string>
|
||||
<string name="launcher_in_car">Bu cihaz bir arabaya bağlı, bu yüzden Gearslip arabanın ekranında.</string>
|
||||
<string name="launcher_open_gearslip">Gearslip\'i aç</string>
|
||||
<string name="nav_arrival">varış</string>
|
||||
<string name="nav_lane_any">Herhangi bir şerit</string>
|
||||
<string name="nav_lane_from_left">Soldan %1$d. şeridi kullan</string>
|
||||
<string name="nav_lane_from_right">Sağdan %1$d. şeridi kullan</string>
|
||||
<plurals name="nav_lane_left">
|
||||
<item quantity="one">Sol şeridi kullan</item>
|
||||
<item quantity="other">Soldaki %d şeridi kullan</item>
|
||||
</plurals>
|
||||
<string name="nav_lane_middle">Orta şeridi kullan</string>
|
||||
<string name="nav_lane_middle_lanes">Orta şeritleri kullan</string>
|
||||
<plurals name="nav_lane_right">
|
||||
<item quantity="one">Sağ şeridi kullan</item>
|
||||
<item quantity="other">Sağdaki %d şeridi kullan</item>
|
||||
</plurals>
|
||||
<string name="nav_to_go">kaldı</string>
|
||||
<string name="privacy_policy">Gizlilik politikası</string>
|
||||
<string name="privacy_policy_sub">Gearslip ne gönderir, kime ve neden</string>
|
||||
<string name="settings_bt_address">Telefonun Bluetooth adresi</string>
|
||||
<string name="settings_bt_address_hint">Android Ayarlar\'da Telefon hakkında bölümünden kopyala. Gearslip bunu arabaya iletir, böylece araba kablodaki telefonun eşlenen telefon olduğunu bilir.</string>
|
||||
<string name="settings_bt_address_invalid">Bu bir Bluetooth adresi değil. 12:34:56:78:9A:BC gibi görünür.</string>
|
||||
<string name="settings_bt_address_off">Ayarlanmadı. Yalnızca Gearslip bağlanınca Bluetooth\'u kesen bir araba için.</string>
|
||||
<string name="settings_use_as_launcher">Başlatıcı olarak kullan</string>
|
||||
<string name="settings_use_as_launcher_off">Bir araç ünitesi veya torpidodaki bir tablet için. Gearslip\'in araba ekranı bu cihazın ana ekranı olur.</string>
|
||||
<string name="settings_use_as_launcher_on">Gearslip bu cihazın ana ekranı olabilir. Varsayılan ana ekran uygulaması altında seç.</string>
|
||||
<string name="status_no_car">Telefon şarj oluyor ama hiçbir araba yanıt vermedi. Bazı kablolar yalnızca şarj eder; veri taşıyan bir kablo dene.</string>
|
||||
<string name="status_no_car_graphene">Telefon şarj oluyor ama hiçbir araba yanıt vermedi. Güvenlik ve gizlilik altındaki USB-C bağlantı noktası denetiminin veriye izin verdiğini ve Yalnızca şarj olmadığını kontrol et.</string>
|
||||
<string name="status_open_settings">Ayarları aç</string>
|
||||
<string name="status_stuck_cable">Araba henüz Gearslip\'i göstermedi. Kabloyu çıkar, birkaç saniye bekle ve yeniden tak. Sürerse başka bir kablo veya USB bağlantı noktası dene ve günlüğü bir issue\'da paylaş.</string>
|
||||
<string name="status_stuck_wireless">Araba henüz Gearslip\'i göstermedi. Arabayı kapatıp yeniden aç ya da kabloyla bağlan. Sürerse günlüğü bir issue\'da paylaş.</string>
|
||||
<string name="update_installed_text">Hiçbir araba bağlı değilken Wi-Fi üzerinden yüklendi.</string>
|
||||
<string name="update_installed_title">Gearslip %1$s sürümüne güncellendi</string>
|
||||
<string name="update_silent">Güncellemeleri kendisi yüklesin</string>
|
||||
<string name="update_silent_sub">Yalnızca tamamlanmış sürümler, Wi-Fi üzerinden, araba bağlı değilken</string>
|
||||
<string name="assist_listening">Dinliyorum…</string>
|
||||
<string name="assist_one_moment">Bir saniye…</string>
|
||||
<string name="assist_examples">“navigate home” ya da “play” ve bir sanatçı adı dene. Daha fazlası için “help” de.</string>
|
||||
<string name="assist_grant">İzin ver</string>
|
||||
<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>
|
||||
<string name="car_nav_mic">Mikrofon düğmesi</string>
|
||||
<string name="car_nav_mic_note">Gezinme çubuğundaki asistan düğmesi. Aracın kendi ses düğmesi her durumda çalışır.</string>
|
||||
<string name="car_apps_add">Uygulama ekle</string>
|
||||
<string name="car_apps_add_note">Aracın listelemediği bir uygulama için: müziği Gearslip oynatıcısında çalar veya mesajları Mesajlar ekranında görünür.</string>
|
||||
<string name="car_apps_add_pick">Bir uygulama seçin</string>
|
||||
<string name="car_apps_add_as">%1$s şu şekilde gösterilsin</string>
|
||||
<string name="car_apps_as_media">Müzik ve ses</string>
|
||||
<string name="car_apps_as_media_note">Çalan içerik; oynat, duraklat ve atla. Göz atılacak bir kitaplık yok.</string>
|
||||
<string name="car_apps_as_messaging">Mesajlar</string>
|
||||
<string name="car_apps_as_messaging_note">Bildirimleri, sesli okunur ve yanıtlanabilir.</string>
|
||||
<string name="car_apps_added_by_you">Sizin eklediğiniz</string>
|
||||
<string name="car_apps_remove">%1$s kaldır</string>
|
||||
</resources>
|
||||
Loaded 100 of 105 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user