Author SHA1 Message Date
Seb3thehacker dce8ae9041 0.3.39-alpha: version and release notes 2026-11-04 20:25:20 -08:00
Seb3thehacker 7c6f36cdbb Merge Weblate's German, Romanian and Estonian updates 2026-11-04 20:12:55 -08:00
Seb3thehacker 71d8af5128 Merge #63: a display density setting for car apps
# Conflicts:
#	gearslip/src/main/java/app/seb3thehacker/gearslip/ui/CarPreviewScreen.kt
2026-11-04 14:09:14 -08:00
Seb3thehacker 4172cf1a0a Merge #64: choose which apps the car's drawer shows 2026-11-04 14:08:25 -08:00
Seb3thehacker bcd9a87174 The car can learn which paired phone is on the cable
A VW MIB2 drops an already paired phone's Bluetooth when Gearslip
connects over USB, because nothing tells it the two are the same phone
(#28). Android Auto says so on the car's Bluetooth channel with the
phone's address, which Android hides from apps. Settings now takes the
address typed in by hand; with it, Gearslip opens that channel on every
connection to a paired car and sends the address. Without it, nothing
changes.
2026-11-04 13:54:02 -08:00
Seb3thehacker fed735b5a2 Spoken directions can go over the music channel
A 2025 Nissan's Bosch unit drops the link a second after every turn
on its directions channel while its music channel is open, and it
plays music through nothing else (#35). A new car setting, off by
default, leaves the directions channel alone: each turn is mixed into
the music with the music turned down under it, or, when nothing is
playing, goes out on the music channel on its own under ducking focus.
2026-11-04 13:51:51 -08:00
Seb3thehacker 97555396c0 Spoken directions use a voice in Gearslip's language
The turns are worded in Gearslip's language, but the speech engine read
them with its default voice, which may speak another one (#70).
2026-11-04 13:43:25 -08:00
Seb3thehacker d42bc0d00a Gearslip pings the car once a second, as Android Auto does
Either side of the link may ping, and a real phone does. A 2021 Bolt
hangs up 25 seconds into every drive even with its own pings answered
and GM's channel open (#18), which leaves the phone's pings as the one
thing Android Auto sends that Gearslip didn't.
2026-11-04 13:41:42 -08:00
Seb3thehacker c33875c8f8 A still car screen gets a fresh frame every second
A surface encoder only makes a frame when the screen changes, and the
repeat-last-frame setting should cover a still one. On a Pixel 10 it
didn't: a LuChi box got 11 frames, then nothing, and stayed black
(#58). When the encoder has been quiet for a second, Gearslip now
draws its screen again, which is a new frame for the car.
2026-11-04 13:37:36 -08:00
Seb3thehacker a70dea7bd5 Merge #75: show the alias the driver gave a paired Bluetooth device 2026-11-04 13:28:16 -08:00
Théo c07d73cc92 Show the alias the driver gave a paired Bluetooth device
The car list and the wireless status messages read the device's own name,
so a device renamed in Android's Bluetooth settings still showed its old
name. Prefer the alias, and sort the list by the name it shows.
2026-11-04 21:05:39 +01:00
Priit Jõerüüt 962f5ff2c4 chore(l10n): update Estonian translation
Translation: Gearslip/App
Language: Estonian
Progress: 2.3% (16 of 687 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/et/
2026-11-04 11:17:05 +01:00
Igor Sorocean c7fa32d393 chore(l10n): update Romanian translation
Translation: Gearslip/App
Language: Romanian
Progress: 82.0% (564 of 687 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/ro/
2026-11-04 11:17:03 +01:00
icke68746 3c415fb953 chore(l10n): update German translation
Translation: Gearslip/App
Language: German
Progress: 100.0% (687 of 687 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/de/
2026-11-04 11:16:58 +01:00
Seb3thehacker 75f43c3e3d Launcher mode: Gearslip as the home screen of a head unit or a dash tablet
Turned on under Settings > Connection, Gearslip's car screen fills the device's own
display as its home app. An All apps page opens any other app full screen, Back and
a head unit's keys work the car UI, and Phone hides on devices that can't call.
Phones never see Gearslip offered as a home app unless it's switched on.
2026-11-03 13:38:48 -08:00
Seb3thehacker 07e398cf79 A wireless drive keeps Gearslip in the foreground
From the moment the car's Bluetooth channel opens until the drive ends, a quiet
notification keeps Gearslip in the foreground, so Android lets it join the car's
Wi-Fi with the phone locked.
2026-11-03 12:52:18 -08:00
Seb3thehacker cc78020eda Google Maps and Waze turns in the nav bar and on the cluster
Neither runs on the car screen, but while one guides on the phone, Gearslip reads
its turn notification and shows the arrow, distance and road in the nav bar, and
sends the turn to the instrument cluster.
2026-11-03 12:27:07 -08:00
Seb3thehacker 1faf418798 Wireless start keeps knocking and starts over after a failed step
While Gearslip waits for the car to call, it also calls the car's own service every
8 seconds, for cars that publish it late. If the Bluetooth channel closes, the Wi-Fi
join fails or TCP can't reach the car before its screen comes up, the start-up
begins again, up to three times.
2026-11-03 12:27:07 -08:00
Seb3thehacker ef1b811920 Clearer messages when the car's clock is wrong or a start gets stuck
A certificate the car calls not valid yet or expired, but that is fine by the
phone's clock, now says to set the car's date and time. A start that hasn't
reached the car's screen after 45 seconds says what to try, for cable and wireless.
2026-11-03 12:22:01 -08:00
Seb3thehacker fa89017521 Car maps get a position with the phone locked
Android only lets the location service start in the seconds after Gearslip's
screen opens. Gearslip now starts it as the car connects, instead of when a map
opens later with the phone already in a pocket.
2026-11-03 12:22:01 -08:00
Seb3thehacker 88c85cc871 The log says why Android ended the last run
Native crashes, freezes and the memory killer leave no stack trace in the log. On
the next launch, Gearslip writes the reason Android recorded, at the end of the run
that died and in the new one.
2026-11-03 12:22:01 -08:00
Seb3thehacker 86e61bcc1d Music comes back after a call when it plays through Gearslip
When the car took the speakers for a moment, for a call or its own voice, Gearslip
paused the music and gave up its claim, so the car never handed the sound back.
It now keeps the claim and plays the music again once the car is done.
2026-11-03 12:22:01 -08:00
Seb3thehacker 9ddab16645 README shows translation progress for each language 2026-11-03 11:26:26 -08:00
Seb3thehacker 0a5d0792d6 README and CONTRIBUTING say translations go through Weblate only 2026-11-03 11:11:15 -08:00
Seb3thehacker d302caf3a4 Home warns when the phone charges but no car answers, and Help covers GrapheneOS's USB data setting 2026-11-03 11:10:11 -08:00
Seb3thehacker ea64329f2f README warns that Gearslip is alpha, and about GrapheneOS's charging-only USB setting (#71) 2026-11-03 11:00:04 -08:00
Seb3thehacker 44d9de2498 Merge Weblate's German, Danish, Romanian and Spanish updates (#50) 2026-11-03 10:56:08 -08:00
Priit Jõerüüt 7991731d4b chore(l10n): add Estonian translation
Translation: Gearslip/App
Language: Estonian
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/et/
2026-11-03 12:33:30 +01:00
John Hansen 776207eac7 chore(l10n): update Danish translation
Translation: Gearslip/App
Language: Danish
Progress: 36.9% (254 of 687 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/da/
2026-11-03 12:33:29 +01:00
Igor Sorocean 296ae063e7 chore(l10n): update Romanian translation
Translation: Gearslip/App
Language: Romanian
Progress: 36.3% (250 of 687 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/ro/
2026-11-03 12:33:27 +01:00
Voltox 4310e7b70d chore(l10n): update German translation
Translation: Gearslip/App
Language: German
Progress: 100.0% (687 of 687 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/de/
2026-11-03 09:26:16 +01:00
Voltox 0d6b8baf64 chore(l10n): update German translation
Translation: Gearslip/App
Language: German
Progress: 100.0% (687 of 687 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/de/
2026-11-03 09:24:38 +01:00
LibreTranslate b1e7138e62 chore(l10n): update German translation
Translation: Gearslip/App
Language: German
Progress: 100.0% (687 of 687 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/de/
2026-11-03 09:19:33 +01:00
Voltox 16c949b414 chore(l10n): update German translation
Translation: Gearslip/App
Language: German
Progress: 100.0% (687 of 687 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/de/
2026-11-03 09:19:32 +01:00
John Hansen 4a40f782f7 chore(l10n): update Danish translation
Translation: Gearslip/App
Language: Danish
Progress: 27.5% (189 of 687 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/da/
2026-11-03 08:48:21 +01:00
Voltox e982e75155 chore(l10n): update German translation
Translation: Gearslip/App
Language: German
Progress: 100.0% (687 of 687 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/de/
2026-11-03 08:48:18 +01:00
icke68746 13139923b2 chore(l10n): update German translation
Translation: Gearslip/App
Language: German
Progress: 100.0% (687 of 687 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/de/
2026-11-03 08:48:18 +01:00
LibreTranslate bf2213e4e6 chore(l10n): update German translation
Translation: Gearslip/App
Language: German
Progress: 100.0% (687 of 687 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/de/
2026-11-03 08:48:17 +01:00
Voltox bbe734bb14 chore(l10n): update German translation
Translation: Gearslip/App
Language: German
Progress: 100.0% (687 of 687 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/de/
2026-11-03 08:48:17 +01:00
icke68746 7733a4042e chore(l10n): update German translation
Translation: Gearslip/App
Language: German
Progress: 100.0% (687 of 687 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/de/
2026-11-03 08:48:15 +01:00
LibreTranslate 5a8bd5a8ac chore(l10n): update German translation
Translation: Gearslip/App
Language: German
Progress: 100.0% (687 of 687 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/de/
2026-11-03 08:48:14 +01:00
Voltox b13abee9e0 chore(l10n): update German translation
Translation: Gearslip/App
Language: German
Progress: 100.0% (687 of 687 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/de/
2026-11-03 08:48:13 +01:00
Voltox 8d61e3245a chore(l10n): update German translation
Translation: Gearslip/App
Language: German
Progress: 100.0% (687 of 687 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/de/
2026-11-03 08:48:10 +01:00
Voltox f9eadefc2f chore(l10n): update German translation
Translation: Gearslip/App
Language: German
Progress: 100.0% (687 of 687 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/de/
2026-11-03 08:48:09 +01:00
Seb3thehacker 303958f2b2 AntennaPod's Next chapter takes the second button slot, where it shows
The player shows an app's first two buttons, and AntennaPod's are Rewind
and Fast-forward, so the new button never appeared.
2026-11-02 17:16:37 -08:00
Seb3thehacker 253cc5cad5 AntennaPod gets a Next chapter button in the car
AntennaPod takes the command whenever it plays, but only offers the
button when it's turned on in its own notification settings.
2026-11-02 16:53:14 -08:00
Seb3thehacker 3f25122fb6 Settings has a Language row, for choosing Gearslip's language
Android 13 and later already let you pick it, but only from the
system's app settings, which few people find (#51).
2026-11-02 16:53:14 -08:00
Seb3thehacker a1a7eca5cb Spoken directions start the speech engine again if it didn't start
A failed or unanswered start was final, so one bad start left every turn
of a drive unspoken (#35).
2026-11-02 16:52:58 -08:00
Seb3thehacker 588d13c3a8 Session reports say Wi-Fi when a wireless link fails, not USB 2026-11-02 16:52:58 -08:00
Seb3thehacker a1dc9a6519 TlsSelfTest moves to the tests, where its trust-anything peer belongs
Only the unit tests ran it, but it shipped in the APK and code scanning
flagged its trust manager.
2026-11-02 16:52:58 -08:00
Devinside 3e2e410115 Add Renault Clio V to CARS.md 2026-11-02 16:52:45 -08:00
Seb3thehacker 08a7af4476 car: alerts from hosted apps show on the car screen
Waze asks whether a reported hazard is still there with an alert, and
Gearslip dropped every one (#54). An alert now shows as a card across
the top of any screen, with its buttons and a bar for the time left.
2026-11-02 16:13:10 -08:00
Seb3thehacker dc8544794e wireless: the version reply names the Wi-Fi channel type the car asked for
Strict head units, Toyota's among them, wait for more than a bare status
before they send the StartRequest.
2026-11-02 16:07:17 -08:00
Seb3thehacker aadca6019e Gearslip leaves the phone's screen once the car's screen is up
The car opens Gearslip on the phone each time it connects, over USB or
through auto-connect. Once video is up it now moves to the background,
unless the phone was already showing it or a question needs the driver.
2026-11-02 15:26:37 -08:00
Seb3thehacker 1e3d4f5188 protocol: open the media channel when a car holds back its screen
Older head units (2017 Pilot, 2020 Civic, 2024 Mirage) never show our
screen while the media channel is closed, which it is when music goes
over Bluetooth. If the screen hasn't come 5 seconds after asking, open
the channel and ask again.
2026-11-02 14:57:33 -08:00
John Hansen 83256224d4 chore(l10n): update Danish translation
Translation: Gearslip/App
Language: Danish
Progress: 9.3% (64 of 687 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/da/
2026-11-02 08:17:33 +01:00
johnneerdaelandClaude Opus 5.5 cae1b06d05 car: a display density setting, so text and controls can grow in car apps
Size scales Gearslip's own screens but can't reach a hosted app, which
draws from the density it's handed. On a short screen those apps stay
small. Car display density, under Screen after Size, is From car or 160
to 240 DPI, and the video's resolution stays the same.

CarEnvironment.frame is what the display and hosted apps already read, so
the choice goes in there, once a session starts: the display and the apps
on it can't disagree mid-drive. Reconnecting or reopening the preview
applies a change. Phone mirroring keeps the car's own density, and the
cluster has its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 05:22:37 +01:00
johnneerdaelandClaude Opus 5.5 db90c72584 car: choose which apps the drawer shows, from a new Apps card on the phone
Short car screens fill up fast, and a phone can offer dozens of car apps
that a driver never opens there. The drawer had no way to leave one out.

The Apps card comes first under Car, with one row per tile, all ticked.
Unticking a row takes the tile out of the drawer at once. It stays pinned
to the nav bar if it was, and Gearslip's own tiles aren't listed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 05:22:37 +01:00
John Hansen 96821e27e3 chore(l10n): update Danish translation
Translation: Gearslip/App
Language: Danish
Progress: 6.6% (46 of 687 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/da/
2026-11-02 00:51:23 +01:00
icke68746 89ab0f9d63 chore(l10n): update German translation
Translation: Gearslip/App
Language: German
Progress: 21.1% (145 of 687 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/de/
2026-11-02 00:51:21 +01:00
Igor Sorocean 4fb612f602 chore(l10n): update Romanian translation
Translation: Gearslip/App
Language: Romanian
Progress: 5.2% (36 of 687 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/ro/
2026-10-29 09:51:21 +01:00
Igor Sorocean 0234cbdc10 chore(l10n): update Romanian translation
Translation: Gearslip/App
Language: Romanian
Progress: 5.0% (35 of 687 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/ro/
2026-10-28 08:34:11 +00:00
John Hansen 167933862a chore(l10n): update Danish translation
Translation: Gearslip/App
Language: Danish
Progress: 1.3% (9 of 687 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/da/
2026-10-28 09:34:03 +01:00
Igor Sorocean 9bf0911f39 chore(l10n): update Romanian translation
Translation: Gearslip/App
Language: Romanian
Progress: 4.9% (34 of 687 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/ro/
2026-10-28 09:34:02 +01:00
Jorge 4acd1bb569 chore(l10n): update Spanish translation
Translation: Gearslip/App
Language: Spanish
Progress: 10.1% (70 of 687 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/es/
2026-10-28 09:34:00 +01:00
Voltox 726ca3eddd chore(l10n): update German translation
Translation: Gearslip/App
Language: German
Progress: 19.6% (135 of 687 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/de/
2026-10-28 09:33:59 +01:00
Seb3thehacker 89dd28bd62 protocol: ask for the car's screen with reason unknown, not 'phone screen off' 2026-10-27 14:33:10 -07:00
Seb3thehacker e9d4203d58 wireless: a car you said no to can be let to connect on its own again, from the car list 2026-10-27 14:33:10 -07:00
Seb3thehacker 327d6fd40a protocol: service discovery sends Gearslip's icon at three sizes and a session ID, like a real phone 2026-10-27 14:33:10 -07:00
Seb3thehacker 37d56be59a Merge Weblate's Translation Memory changes without taking them
Translation Memory had filled in wrong near-matches (French and Spanish Bluetooth off became
on, German Directions on became off), so every language keeps the repo's wording. Only the
new, empty Danish file comes in.
2026-10-27 14:32:58 -07:00
John Hansen 40313843d1 chore(l10n): add Danish translation
Translation: Gearslip/App
Language: Danish
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/da/
2026-10-27 21:55:02 +01:00
Weblate Translation Memory 60afbf1dd2 chore(l10n): update Chinese (Simplified Han script) translation
Translation: Gearslip/App
Language: Chinese (Simplified Han script)
Progress: 94.1% (647 of 687 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/zh_Hans/
2026-10-27 21:55:01 +01:00
Weblate Translation Memory 8b40f1576d chore(l10n): update Portuguese (Brazil) translation
Translation: Gearslip/App
Language: Portuguese (Brazil)
Progress: 74.9% (515 of 687 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/pt_BR/
2026-10-27 21:55:00 +01:00
Weblate Translation Memory 14ef6676b9 chore(l10n): update Russian translation
Translation: Gearslip/App
Language: Russian
Progress: 79.6% (547 of 687 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/ru/
2026-10-27 21:54:59 +01:00
Weblate Translation Memory 9dca6d302c chore(l10n): update Ukrainian translation
Translation: Gearslip/App
Language: Ukrainian
Progress: 87.3% (600 of 687 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/uk/
2026-10-27 21:54:58 +01:00
Weblate Translation Memory 4ea466452b chore(l10n): update Turkish translation
Translation: Gearslip/App
Language: Turkish
Progress: 86.8% (597 of 687 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/tr/
2026-10-27 21:54:57 +01:00
Weblate Translation Memory d0971bb62d chore(l10n): update Polish translation
Translation: Gearslip/App
Language: Polish
Progress: 94.1% (647 of 687 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/pl/
2026-10-27 21:54:56 +01:00
Weblate Translation Memory fa0530499e chore(l10n): update Dutch translation
Translation: Gearslip/App
Language: Dutch
Progress: 88.2% (606 of 687 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/nl/
2026-10-27 21:54:55 +01:00
Weblate Translation Memory d5b5fb8533 chore(l10n): update Korean translation
Translation: Gearslip/App
Language: Korean
Progress: 93.0% (639 of 687 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/ko/
2026-10-27 21:54:54 +01:00
Weblate Translation Memory ad0b3807a8 chore(l10n): update Japanese translation
Translation: Gearslip/App
Language: Japanese
Progress: 89.5% (615 of 687 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/ja/
2026-10-27 21:54:53 +01:00
Weblate Translation Memory b672f820b2 chore(l10n): update Italian translation
Translation: Gearslip/App
Language: Italian
Progress: 76.7% (527 of 687 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/it/
2026-10-27 21:54:52 +01:00
Weblate Translation Memory c45bfe597d chore(l10n): update Swedish translation
Translation: Gearslip/App
Language: Swedish
Progress: 73.0% (502 of 687 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/sv/
2026-10-27 21:54:51 +01:00
Weblate Translation Memory a7f6440994 chore(l10n): update Czech translation
Translation: Gearslip/App
Language: Czech
Progress: 85.1% (585 of 687 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/cs/
2026-10-27 21:54:50 +01:00
Weblate Translation Memory f6f72a9e66 chore(l10n): update French translation
Translation: Gearslip/App
Language: French
Progress: 78.3% (538 of 687 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/fr/
2026-10-27 21:54:49 +01:00
Weblate Translation Memory a5ad7f4384 chore(l10n): update Arabic translation
Translation: Gearslip/App
Language: Arabic
Progress: 91.5% (629 of 687 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/ar/
2026-10-27 21:54:48 +01:00
Weblate Translation Memory b82a09289c chore(l10n): update Spanish translation
Translation: Gearslip/App
Language: Spanish
Progress: 8.8% (61 of 687 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/es/
2026-10-27 21:54:47 +01:00
Weblate Translation Memory 6705ade9ae chore(l10n): update German translation
Translation: Gearslip/App
Language: German
Progress: 10.0% (69 of 687 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/de/
2026-10-27 21:54:46 +01:00
Seb3thehacker df986cae6b Merge Weblate's translations, keeping the repo's wording where they clash
Weblate's branch had machine translations that overwrote better ones, so the repo's
text wins. German and Spanish get the four strings they were missing, written by
hand, and German's Certificate setting is called Zertifikat like everywhere else.
2026-10-27 13:35:00 -07:00
Igor Sorocean 734dae4836 chore(l10n): add Romanian translation
Translation: Gearslip/App
Language: Romanian
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/ro/
2026-10-27 09:02:45 +01:00
LibreTranslate 966522cac4 chore(l10n): update Chinese (Simplified Han script) translation
Translation: Gearslip/App
Language: Chinese (Simplified Han script)
Progress: 0.0% (0 of 666 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/zh_Hans/
2026-10-27 09:02:44 +01:00
LibreTranslate cbb0e74cf1 chore(l10n): update Portuguese (Brazil) translation
Translation: Gearslip/App
Language: Portuguese (Brazil)
Progress: 0.0% (0 of 666 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/pt_BR/
2026-10-27 09:02:43 +01:00
LibreTranslate 406d97e288 chore(l10n): update Russian translation
Translation: Gearslip/App
Language: Russian
Progress: 0.0% (0 of 666 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/ru/
2026-10-27 09:02:42 +01:00
LibreTranslate 36d8f987fa chore(l10n): update Ukrainian translation
Translation: Gearslip/App
Language: Ukrainian
Progress: 0.0% (0 of 666 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/uk/
2026-10-27 09:02:41 +01:00
LibreTranslate 00d62caa03 chore(l10n): update Turkish translation
Translation: Gearslip/App
Language: Turkish
Progress: 0.0% (0 of 666 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/tr/
2026-10-27 09:02:40 +01:00
LibreTranslate f4a251d24f chore(l10n): update Polish translation
Translation: Gearslip/App
Language: Polish
Progress: 0.0% (0 of 666 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/pl/
2026-10-27 09:02:37 +01:00
LibreTranslate 0a6da250ee chore(l10n): update Dutch translation
Translation: Gearslip/App
Language: Dutch
Progress: 0.0% (0 of 666 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/nl/
2026-10-27 09:02:36 +01:00
LibreTranslate fea63a092f chore(l10n): update Korean translation
Translation: Gearslip/App
Language: Korean
Progress: 0.0% (0 of 666 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/ko/
2026-10-27 09:02:33 +01:00
LibreTranslate d8694f1b97 chore(l10n): update Japanese translation
Translation: Gearslip/App
Language: Japanese
Progress: 0.0% (0 of 666 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/ja/
2026-10-27 09:02:32 +01:00
LibreTranslate 497a4a1481 chore(l10n): update Italian translation
Translation: Gearslip/App
Language: Italian
Progress: 0.0% (0 of 666 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/it/
2026-10-27 09:02:31 +01:00
LibreTranslate 4cf9b3d9b1 chore(l10n): update Swedish translation
Translation: Gearslip/App
Language: Swedish
Progress: 0.0% (0 of 666 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/sv/
2026-10-27 09:02:30 +01:00
LibreTranslate 0db0c64b6e chore(l10n): update Czech translation
Translation: Gearslip/App
Language: Czech
Progress: 0.0% (0 of 666 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/cs/
2026-10-27 09:02:29 +01:00
Weblate Translation Memory f262b67e83 chore(l10n): update French translation
Translation: Gearslip/App
Language: French
Progress: 1.9% (13 of 666 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/fr/
2026-10-27 09:02:28 +01:00
LibreTranslate b3cacf0630 chore(l10n): update French translation
Translation: Gearslip/App
Language: French
Progress: 1.9% (13 of 666 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/fr/
2026-10-27 09:02:27 +01:00
LibreTranslate 2e0cee654a chore(l10n): update Arabic translation
Translation: Gearslip/App
Language: Arabic
Progress: 0.0% (0 of 666 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/ar/
2026-10-27 09:02:26 +01:00
LibreTranslate a0cb34eb87 chore(l10n): update Spanish translation
Translation: Gearslip/App
Language: Spanish
Progress: 0.0% (0 of 666 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/es/
2026-10-27 09:02:25 +01:00
LibreTranslate 106b0c3987 chore(l10n): update German translation
Translation: Gearslip/App
Language: German
Progress: 0.0% (0 of 666 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/de/
2026-10-27 09:02:24 +01:00
Seb3thehacker a00f5c4896 README: new screenshots of every screen 2026-10-27 00:34:07 -07:00
LibreTranslate b428c54dd2 chore(l10n): update Dutch translation
Translation: Gearslip/App
Language: Dutch
Progress: 0.0% (0 of 666 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/nl/
2026-10-27 05:26:54 +01:00
LibreTranslate c0735ed65e chore(l10n): update Korean translation
Translation: Gearslip/App
Language: Korean
Progress: 0.0% (0 of 666 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/ko/
2026-10-27 05:26:53 +01:00
LibreTranslate 89dbbee5ef chore(l10n): update Japanese translation
Translation: Gearslip/App
Language: Japanese
Progress: 0.0% (0 of 666 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/ja/
2026-10-27 05:26:52 +01:00
LibreTranslate fa8c13a6ec chore(l10n): update Italian translation
Translation: Gearslip/App
Language: Italian
Progress: 0.0% (0 of 666 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/it/
2026-10-27 05:26:51 +01:00
LibreTranslate 2912d24c32 chore(l10n): update Czech translation
Translation: Gearslip/App
Language: Czech
Progress: 0.0% (0 of 666 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/cs/
2026-10-27 05:26:49 +01:00
LibreTranslate f3ee10ce0c chore(l10n): update Spanish translation
Translation: Gearslip/App
Language: Spanish
Progress: 0.0% (0 of 666 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/es/
2026-10-27 05:26:46 +01:00
LibreTranslate 5805ca5471 chore(l10n): update German translation
Translation: Gearslip/App
Language: German
Progress: 0.0% (0 of 666 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/de/
2026-10-27 05:26:45 +01:00
LibreTranslate 10743b36a4 chore(l10n): update Chinese (Simplified Han script) translation
Translation: Gearslip/App
Language: Chinese (Simplified Han script)
Progress: 0.0% (0 of 662 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/zh_Hans/
2026-10-27 05:25:41 +01:00
LibreTranslate 5da51873f6 chore(l10n): update Portuguese (Brazil) translation
Translation: Gearslip/App
Language: Portuguese (Brazil)
Progress: 0.0% (0 of 662 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/pt_BR/
2026-10-27 05:25:41 +01:00
LibreTranslate 8e41706239 chore(l10n): update Russian translation
Translation: Gearslip/App
Language: Russian
Progress: 0.0% (0 of 662 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/ru/
2026-10-27 05:25:41 +01:00
LibreTranslate 8f0c4c4145 chore(l10n): update Ukrainian translation
Translation: Gearslip/App
Language: Ukrainian
Progress: 0.0% (0 of 662 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/uk/
2026-10-27 05:25:41 +01:00
LibreTranslate 43193eaa8f chore(l10n): update Turkish translation
Translation: Gearslip/App
Language: Turkish
Progress: 0.0% (0 of 662 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/tr/
2026-10-27 05:25:41 +01:00
LibreTranslate 92bee5e911 chore(l10n): update Polish translation
Translation: Gearslip/App
Language: Polish
Progress: 0.0% (0 of 662 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/pl/
2026-10-27 05:25:41 +01:00
LibreTranslate e13bb2af24 chore(l10n): update Dutch translation
Translation: Gearslip/App
Language: Dutch
Progress: 0.0% (0 of 662 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/nl/
2026-10-27 05:25:41 +01:00
LibreTranslate 4b50be2e73 chore(l10n): update Korean translation
Translation: Gearslip/App
Language: Korean
Progress: 0.0% (0 of 662 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/ko/
2026-10-27 05:25:41 +01:00
LibreTranslate 3573999d36 chore(l10n): update Japanese translation
Translation: Gearslip/App
Language: Japanese
Progress: 0.0% (0 of 662 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/ja/
2026-10-27 05:25:41 +01:00
LibreTranslate a071ee072e chore(l10n): update Italian translation
Translation: Gearslip/App
Language: Italian
Progress: 0.0% (0 of 662 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/it/
2026-10-27 05:25:41 +01:00
LibreTranslate 55c6924eb3 chore(l10n): update Swedish translation
Translation: Gearslip/App
Language: Swedish
Progress: 0.0% (0 of 662 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/sv/
2026-10-27 05:25:41 +01:00
LibreTranslate 8193d97453 chore(l10n): update Czech translation
Translation: Gearslip/App
Language: Czech
Progress: 0.0% (0 of 662 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/cs/
2026-10-27 05:25:41 +01:00
LibreTranslate 660b840278 chore(l10n): update French translation
Translation: Gearslip/App
Language: French
Progress: 0.0% (0 of 662 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/fr/
2026-10-27 05:25:41 +01:00
LibreTranslate 614459d9f7 chore(l10n): update Arabic translation
Translation: Gearslip/App
Language: Arabic
Progress: 0.0% (0 of 662 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/ar/
2026-10-27 05:25:41 +01:00
LibreTranslate aafa6ae593 chore(l10n): update Spanish translation
Translation: Gearslip/App
Language: Spanish
Progress: 0.0% (0 of 662 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/es/
2026-10-27 05:25:41 +01:00
LibreTranslate 95b12c7225 chore(l10n): update Turkish translation
Translation: Gearslip/App
Language: Turkish
Progress: 0.0% (0 of 662 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/tr/
2026-10-27 05:25:41 +01:00
Seb3thehacker bfbabfe8fc chore(l10n): add Chinese (Simplified Han script) translation
Translation: Gearslip/App
Language: Chinese (Simplified Han script)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/zh_Hans/
2026-10-27 05:25:41 +01:00
Seb3thehacker d8e4dcf228 chore(l10n): add Portuguese (Brazil) translation
Translation: Gearslip/App
Language: Portuguese (Brazil)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/pt_BR/
2026-10-27 05:25:41 +01:00
LibreTranslate 7feb42211e chore(l10n): update Spanish translation
Translation: Gearslip/App
Language: Spanish
Progress: 0.0% (0 of 662 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/es/
2026-10-27 05:25:41 +01:00
LibreTranslate 27262719b1 chore(l10n): update German translation
Translation: Gearslip/App
Language: German
Progress: 0.0% (0 of 662 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/de/
2026-10-27 05:25:41 +01:00
Seb3thehacker a29706f2e7 chore(l10n): remove Portuguese translation
Translation: Gearslip/App
Language: Portuguese
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/pt/
2026-10-27 05:25:41 +01:00
Seb3thehacker feb104f2eb chore(l10n): remove Chinese (Simplified) (zh_MO) translation
Translation: Gearslip/App
Language: Chinese (Simplified) (zh_MO)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/zh_MO/
2026-10-27 05:25:40 +01:00
Seb3thehacker c6f0547943 chore(l10n): add Russian translation
Translation: Gearslip/App
Language: Russian
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/ru/
2026-10-27 05:25:40 +01:00
Seb3thehacker ee1f5d2aa6 chore(l10n): add Ukrainian translation
Translation: Gearslip/App
Language: Ukrainian
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/uk/
2026-10-27 05:25:40 +01:00
Seb3thehacker 878b0f64cc chore(l10n): add Turkish translation
Translation: Gearslip/App
Language: Turkish
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/tr/
2026-10-27 05:25:40 +01:00
Seb3thehacker 298ce1cd1f chore(l10n): add Portuguese translation
Translation: Gearslip/App
Language: Portuguese
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/pt/
2026-10-27 05:25:40 +01:00
Seb3thehacker 2b6ce5c7ce chore(l10n): add Polish translation
Translation: Gearslip/App
Language: Polish
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/pl/
2026-10-27 05:25:40 +01:00
Seb3thehacker 04e99562e3 chore(l10n): add Dutch translation
Translation: Gearslip/App
Language: Dutch
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/nl/
2026-10-27 05:25:40 +01:00
Seb3thehacker 9439b2d7d0 chore(l10n): add Korean translation
Translation: Gearslip/App
Language: Korean
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/ko/
2026-10-27 05:25:40 +01:00
Seb3thehacker 96b93513dd chore(l10n): add Japanese translation
Translation: Gearslip/App
Language: Japanese
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/ja/
2026-10-27 05:25:40 +01:00
Seb3thehacker d114108f19 chore(l10n): add Italian translation
Translation: Gearslip/App
Language: Italian
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/it/
2026-10-27 05:25:40 +01:00
Seb3thehacker 67d88ae7b9 chore(l10n): add Swedish translation
Translation: Gearslip/App
Language: Swedish
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/sv/
2026-10-27 05:25:40 +01:00
Seb3thehacker bfe9723457 chore(l10n): add Czech translation
Translation: Gearslip/App
Language: Czech
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/cs/
2026-10-27 05:25:40 +01:00
Seb3thehacker 55b497190f chore(l10n): add French translation
Translation: Gearslip/App
Language: French
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/fr/
2026-10-27 05:25:40 +01:00
Seb3thehacker 65c630585a chore(l10n): add Arabic translation
Translation: Gearslip/App
Language: Arabic
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/ar/
2026-10-27 05:25:40 +01:00
Seb3thehacker 560c6efab0 chore(l10n): add Chinese (Simplified) (zh_MO) translation
Translation: Gearslip/App
Language: Chinese (Simplified) (zh_MO)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/zh_MO/
2026-10-27 05:25:40 +01:00
Seb3thehacker d4d9289034 chore(l10n): add Spanish translation
Translation: Gearslip/App
Language: Spanish
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/es/
2026-10-27 05:25:40 +01:00
Seb3thehacker 41da785965 chore(l10n): add German translation
Translation: Gearslip/App
Language: German
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/de/
2026-10-27 05:25:40 +01:00
86 changed files with 3805 additions and 719 deletions

No files matched your search

+27 -20
View File
@@ -1,24 +1,31 @@
Gearslip 0.3.31 brings saved places, a new music player and sixteen languages. Gearslip 0.3.39 runs as a head unit's home screen, connects to more cars, and brings Google Maps and Waze turns to the car.
This is a pre-release. Many of these fixes target cars we don't own, so please try it and tell us how your car does. Attach a log to your issue: open Gearslip, tap Logs, then Share log.
## New ## New
- Save Home, Work and three places of your own under Saved places in the Car card on the phone's home screen. Say "navigate home" or "take me to work", and your map app gets the address. - Launcher mode: on a head unit that runs Android, or a spare phone or tablet on the dash, Gearslip can be the home screen. Turn it on under Settings > Connection. An All apps page opens everything else.
- Calls show on the car's own screens, and the car's phone button answers them. - Turns from Google Maps or Waze on the phone show in the car's nav bar and on its instrument cluster.
- A new music player: Now playing with a big cover, Browse with covers, and Up next. Beside the map, the player floats as a card; drag its edge to resize it. - An Apps card on the phone's home screen lets you hide apps from the car's drawer. Thanks to @johnneerdael.
- Lyrics come from BetterLyrics, LRCLIB or KuGou, whichever has the song. - A display density setting makes text and buttons bigger in car apps on small screens. Thanks to @johnneerdael.
- A Dark mode setting: black, black and colour, deep colour or grey. Black is the default. - A Language setting picks Gearslip's language, and spoken directions use a voice in that language.
- Gearslip speaks Arabic, Chinese, Czech, Dutch, French, German, Italian, Japanese, Korean, Polish, Portuguese, Russian, Spanish, Swedish, Turkish and Ukrainian. Most started as machine translations, so fix your language on [Weblate](https://hosted.weblate.org/engage/gearslip/). - Alerts from car apps show on the car screen, such as Waze asking whether a hazard is still there.
- With experimental features on, car apps get the car's speed, fuel, range and compass, and cars with an instrument cluster screen get a turn card there. - AntennaPod gets a Next chapter button.
- A Manage storage screen in App info clears lyrics, voice models and old logs. - The car list shows the names you gave your Bluetooth devices. Thanks to @theo330.
- A car you once turned away can connect on its own again, from the car list.
## Changed
- The nav bar floats on every screen.
- The car's settings all sit under the Car card on the phone's home screen.
- The Light app theme makes the phone app light too.
- With the player beside the map, the map app's own buttons fold behind one button, so everything fits.
- The USB audio choice is now called Through Gearslip.
- Setup and Settings explain that some newer cars reject the built-in certificate and need your own.
## Fixed ## Fixed
- Older head units that take only AAC audio no longer drop the connection when music starts. - Cars that waited for the music channel before showing a picture now show Gearslip's screen. This should fix the black screen or endless "Starting video" on the 2017 Pilot, 2020 Civic, 2021 CX-5 and 2024 Mirage.
- Long screens such as Settings, Messages and Vehicle data scroll all the way down. - Gearslip sends the car its icon and a session ID, as a real phone does. Older Hyundais that stopped after the first request should now connect.
- Settings opens faster. - Gearslip pings the car once a second, as a real phone does. This may stop the 2021 Bolt from hanging up after 25 seconds.
- A still car screen gets a fresh frame every second, which should fix the black screen on some aftermarket boxes.
- Wireless keeps calling the car until it answers, starts over after a failed step, and stays connected with the phone locked.
- Music playing through Gearslip comes back after a call.
- Car maps keep the phone's position while the phone is locked.
- Spoken directions start the speech engine again when it fails to start.
- Error messages say when the car's clock is wrong, and what to try when a connection gets stuck.
## For a few cars
- Directions over the music channel, under Spoken directions in the car's settings: for a car that drops the connection or restarts after a spoken turn.
- Phone's Bluetooth address, under Settings > Connection: for a car that drops the phone's Bluetooth when Gearslip connects. Copy the address from Android's Settings > About phone.
New text is in English until translators catch up. Help with your language on [Weblate](https://hosted.weblate.org/engage/gearslip/).
+1
View File
@@ -23,6 +23,7 @@ and some don't say.
| Jeep Cherokee | 2019 | Uconnect 8.4 (Delphi) | | Jeep Cherokee | 2019 | Uconnect 8.4 (Delphi) |
| MG | 2019 | Desay SV | | MG | 2019 | Desay SV |
| Ram 1500 | 2018 | Uconnect 8.4 (Panasonic) | | Ram 1500 | 2018 | Uconnect 8.4 (Panasonic) |
| Renault Clio V | 2019-2024 | Easy Link |
| Škoda | 2018 | MIB Standard2 (TechniSat) | | Škoda | 2018 | MIB Standard2 (TechniSat) |
| Subaru Outback | 2020 | Denso | | Subaru Outback | 2020 | Denso |
| Toyota RAV4 | 2020 | Pioneer T1 | | Toyota RAV4 | 2020 | Pioneer T1 |
+6 -1
View File
@@ -40,10 +40,15 @@ branch on `experimental`, and open your pull request against it.
## Translating ## Translating
Translations happen on [Weblate](https://hosted.weblate.org/engage/gearslip/), not in pull Translations happen on [Weblate](https://hosted.weblate.org/engage/gearslip/), never in pull
requests. Pick your language, or start a new one, and translate in the browser. Weblate requests. Pick your language, or start a new one, and translate in the browser. Weblate
sends the finished work to this repository as a pull request. sends the finished work to this repository as a pull request.
Don't edit the translated `values-*/strings.xml` files by hand. Weblate keeps its own copy of
every language, so a change made in git collides with Weblate's next update, and the pull
request that made it gets closed. If a code change adds or rewords text, change only the
English file, `values/strings.xml`; Weblate picks up the new strings for translators.
Keep the tone of the English: short, plain, and friendly. Leave placeholders such as `%1$s` Keep the tone of the English: short, plain, and friendly. Leave placeholders such as `%1$s`
and `%1$d` in place; Gearslip fills them with names and numbers. Untranslated strings show and `%1$d` in place; Gearslip fills them with names and numbers. Untranslated strings show
in English, so a partial translation is still useful. in English, so a partial translation is still useful.
+21 -11
View File
@@ -9,16 +9,24 @@ An Android Auto alternative built for everyone.
[<img src="https://raw.githubusercontent.com/ImranR98/Obtainium/main/assets/graphics/badge_obtainium.png" alt="Get it on Obtainium" height="80">](https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://github.com/twinksoftware/gearslip) [<img src="https://raw.githubusercontent.com/ImranR98/Obtainium/main/assets/graphics/badge_obtainium.png" alt="Get it on Obtainium" height="80">](https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://github.com/twinksoftware/gearslip)
[![Translation status](https://hosted.weblate.org/widget/gearslip/app/svg-badge.svg)](https://hosted.weblate.org/engage/gearslip/)
Gearslip speaks English, Arabic, Chinese, Czech, Dutch, French, German, Italian, Japanese,
Korean, Polish, Portuguese, Russian, Spanish, Swedish, Turkish, and Ukrainian. Most of these
started as machine translations, so some read awkwardly.
[Fix or add your language on Weblate](https://hosted.weblate.org/engage/gearslip/). [Fix or add your language on Weblate](https://hosted.weblate.org/engage/gearslip/).
Translations go through Weblate only; pull requests that edit them get closed.
[![Translation status by language](https://hosted.weblate.org/widget/gearslip/app/multi-auto.svg)](https://hosted.weblate.org/engage/gearslip/)
Questions, ideas? Questions, ideas?
[Join the discussion on Matrix](https://matrix.to/#/!mf0_mgBd5KqKpc9FfX_wHp1jFCcwAtPrndIn_oGIyMk?via=matrix.org). [Join the discussion on Matrix](https://matrix.to/#/!mf0_mgBd5KqKpc9FfX_wHp1jFCcwAtPrndIn_oGIyMk?via=matrix.org).
> [!WARNING]
> Gearslip is **alpha software**. It can drop the connection, freeze the car screen, or fail
> to start, and a new release can break a car that worked before. Set it up while parked, and
> keep another way to navigate.
> [!WARNING]
> On GrapheneOS, make sure **USB-C port control** isn't set to **Charging-only**. That setting
> blocks USB data, so the car never sees the phone, even with a data cable. Set it to allow
> data transfer.
## Which cars work ## Which cars work
Most cars work. Starting around 2020, some cars added newer security checks that reject Most cars work. Starting around 2020, some cars added newer security checks that reject
@@ -36,14 +44,14 @@ screen-sharing mode.
| | | | | |
|---|---| |---|---|
| ![The launcher, with every car app on the phone and the route in the bar below](docs/screenshots/launcher.png) | ![Vela Maps showing the route, with the music player beside the map](docs/screenshots/navigation.png) | | ![The launcher, with every car app on the phone](docs/screenshots/launcher.png) | ![Vela Maps showing the route, with the music player beside the map](docs/screenshots/navigation.png) |
| Your car apps in one place. A red X marks the ones that don't work yet. | Vela Maps navigating, with the player beside it. | | Your car apps in one place. | Vela Maps navigating, with the player beside it. |
| ![Metrolist's song, art, and controls in Gearslip's player](docs/screenshots/music.png) | ![A new message card over Vela Maps](docs/screenshots/message.png) | | ![Metrolist's song, art, and controls in Gearslip's player](docs/screenshots/music.png) | ![A new message card over Vela Maps](docs/screenshots/message.png) |
| Metrolist in Gearslip's player. | A new message, read aloud at a tap, over the map. | | Metrolist in Gearslip's player. | A new message, read aloud at a tap, over the map. |
| ![The dashboard, with the weather, the week ahead, and recent notifications](docs/screenshots/dashboard.png) | ![DuckDuckGo open in Gearslip's web browser](docs/screenshots/web.png) | | ![Synced lyrics beside the player, lit up to the word being sung](docs/screenshots/lyrics.png) | ![DuckDuckGo open in Gearslip's web browser](docs/screenshots/web.png) |
| The weather, your week, and what you missed. | Browse the web while parked. | | Lyrics light up word by word as the song plays. | Browse the web while parked. |
| ![The voice assistant listening over Vela Maps](docs/screenshots/assistant.png) | ![Three routes to a coffee shop in Vela Maps, each with its time and distance](docs/screenshots/places.png) | | ![The voice assistant listening over Vela Maps](docs/screenshots/assistant.png) | ![Three routes to a coffee shop in Vela Maps, each with its time and distance](docs/screenshots/places.png) |
| Ask for a place, a song, or an app. | Pick a route to coffee nearby. Each one shows its time and distance. | | Ask for a place, a song, or an app. | Pick a route to a coffee shop. Each one shows its time and distance. |
## Features ## Features
@@ -163,7 +171,9 @@ Bug reports from real cars help most, and pull requests are welcome.
on, and the one-time agreement you sign before your first pull request merges. on, and the one-time agreement you sign before your first pull request merges.
To help translate Gearslip into your language, join To help translate Gearslip into your language, join
[Gearslip on Weblate](https://hosted.weblate.org/engage/gearslip/). No coding needed. [Gearslip on Weblate](https://hosted.weblate.org/engage/gearslip/). No coding needed. Please
don't send translations as pull requests: Weblate holds its own copy of every language, and
changes made in git collide with it.
## Building ## Building
Binary file not shown.

Before

Width:  |  Height:  |  Size: 523 KiB

After

Width:  |  Height:  |  Size: 402 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 502 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 373 KiB

After

Width:  |  Height:  |  Size: 313 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 351 KiB

After

Width:  |  Height:  |  Size: 449 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 397 KiB

After

Width:  |  Height:  |  Size: 420 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 586 KiB

After

Width:  |  Height:  |  Size: 392 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 107 KiB

+2 -2
View File
@@ -38,9 +38,9 @@ android {
applicationId = "app.seb3thehacker.gearslip" applicationId = "app.seb3thehacker.gearslip"
minSdk = 31 minSdk = 31
targetSdk = 37 targetSdk = 37
versionCode = 13 versionCode = 14
// -PgearslipVersionName=0.1.99-test makes a build that thinks it's older, to try the updater. // -PgearslipVersionName=0.1.99-test makes a build that thinks it's older, to try the updater.
versionName = project.findProperty("gearslipVersionName")?.toString() ?: "0.3.31-alpha" versionName = project.findProperty("gearslipVersionName")?.toString() ?: "0.3.39-alpha"
manifestPlaceholders["appLabel"] = "Gearslip" manifestPlaceholders["appLabel"] = "Gearslip"
// Where opted-in phones send their daily usage note: the stats-worker/ Worker's address, // 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. // set as gearslipStatsUrl in gradle.properties. Left empty, nothing is ever sent.
+27
View File
@@ -19,6 +19,8 @@
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<!-- Keeps a wireless start going with the phone locked; see WirelessKeepAlive. -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<!-- <!--
Wireless Android Auto: Bluetooth reaches the paired car's Android Auto service, which Wireless Android Auto: Bluetooth reaches the paired car's Android Auto service, which
@@ -158,6 +160,26 @@
</activity> </activity>
<!--
Launcher mode: the car UI as this device's home screen, for a head unit that runs Android
or a tablet on the dash. Off until Settings turns it on (LauncherMode), so phones never
list Gearslip as a home app.
-->
<activity
android:name=".LauncherActivity"
android:enabled="false"
android:exported="true"
android:launchMode="singleTask"
android:excludeFromRecents="true"
android:screenOrientation="sensorLandscape"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden|uiMode|density|locale|layoutDirection|fontScale">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.HOME" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<!-- <!--
Answers the car's USB connection on GearslipActivity's behalf. Kept apart from the Answers the car's USB connection on GearslipActivity's behalf. Kept apart from the
activity so Settings can switch off just this (AppSettings.setGearslipEnabled) and leave activity so Settings can switch off just this (AppSettings.setGearslipEnabled) and leave
@@ -204,6 +226,11 @@
android:exported="false" android:exported="false"
android:foregroundServiceType="location" /> android:foregroundServiceType="location" />
<service
android:name=".wireless.WirelessKeepAlive"
android:exported="false"
android:foregroundServiceType="connectedDevice" />
<!-- <!--
Holds the capture session that carries a media app's audio to the car. It also claims Holds the capture session that carries a media app's audio to the car. It also claims
the microphone, so voice replies can hear the driver with the phone locked. the microphone, so voice replies can hear the driver with the phone locked.
@@ -10,6 +10,18 @@ object Changelog {
class Release(val versionCode: Int, val versionName: String, val changes: List<String>) class Release(val versionCode: Int, val versionName: String, val changes: List<String>)
val releases = listOf( val releases = listOf(
Release(
14, "0.3.39-alpha",
listOf(
"Turns from Google Maps or Waze on the phone show in the nav bar and on the car's cluster.",
"Alerts from car apps show here, such as Waze asking whether a hazard is still there.",
"Hide apps you don't use in the car from the Apps card on the phone.",
"Make text in car apps bigger with Car display density, under Screen.",
"Music playing through Gearslip comes back after a call.",
"Maps keep your position while the phone is locked.",
"Wireless keeps trying until the car answers.",
),
),
Release( Release(
9, "0.3.00-alpha", 9, "0.3.00-alpha",
listOf( listOf(
@@ -118,6 +118,10 @@ class GearslipActivity : ComponentActivity(), Projection {
/** Android's one-time "Allow" for a car to connect by itself; see [WirelessAuto]. */ /** Android's one-time "Allow" for a car to connect by itself; see [WirelessAuto]. */
private var approvingCar: String? = null private var approvingCar: String? = null
/** Whether the phone showed Gearslip when the car's intent arrived; see [stepAside]. */
private var shownOnPhone = false
private var stepAsideOnVideo = false
private val carApproval = registerForActivityResult( private val carApproval = registerForActivityResult(
androidx.activity.result.contract.ActivityResultContracts.StartIntentSenderForResult(), androidx.activity.result.contract.ActivityResultContracts.StartIntentSenderForResult(),
) { result -> ) { result ->
@@ -223,6 +227,16 @@ class GearslipActivity : ComponentActivity(), Projection {
} }
} }
override fun onResume() {
super.onResume()
shownOnPhone = true
}
override fun onStop() {
super.onStop()
shownOnPhone = false
}
override fun onNewIntent(intent: Intent) { override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent) super.onNewIntent(intent)
handleIntent(intent) handleIntent(intent)
@@ -296,7 +310,8 @@ class GearslipActivity : ComponentActivity(), Projection {
val target = surface ?: return val target = surface ?: return
if (screenProjector != null) return if (screenProjector != null) return
val projector = ScreenProjector(this).also { screenProjector = it } val projector = ScreenProjector(this).also { screenProjector = it }
projector.start(target, frameWidth, frameHeight, frameDensity) { CarUi() } // The frame's density, not frameDensity: that stays the car's own, for the phone mirror.
projector.start(target, frameWidth, frameHeight, CarEnvironment.frame.value.densityDpi) { CarUi() }
} }
/** /**
@@ -323,6 +338,7 @@ class GearslipActivity : ComponentActivity(), Projection {
// Debug builds only: opens the audio choice for a look, without turning down a real prompt. // Debug builds only: opens the audio choice for a look, without turning down a real prompt.
if (BuildConfig.DEBUG && intent?.getBooleanExtra("show_audio_choice", false) == true) offerBluetooth.value = true if (BuildConfig.DEBUG && intent?.getBooleanExtra("show_audio_choice", false) == true) offerBluetooth.value = true
if (intent?.action == app.seb3thehacker.gearslip.wireless.WirelessAuto.ACTION_CAR_NEARBY) { if (intent?.action == app.seb3thehacker.gearslip.wireless.WirelessAuto.ACTION_CAR_NEARBY) {
stepAsideOnVideo = !shownOnPhone
connectToNearbyCar(intent.getStringExtra(app.seb3thehacker.gearslip.wireless.WirelessAuto.EXTRA_ADDRESS)) connectToNearbyCar(intent.getStringExtra(app.seb3thehacker.gearslip.wireless.WirelessAuto.EXTRA_ADDRESS))
return return
} }
@@ -340,6 +356,7 @@ class GearslipActivity : ComponentActivity(), Projection {
return return
} }
GearslipLog.i("accessory attached by the system - permission is implicit on this path") GearslipLog.i("accessory attached by the system - permission is implicit on this path")
stepAsideOnVideo = !shownOnPhone
start(accessory) start(accessory)
} }
@@ -401,9 +418,11 @@ class GearslipActivity : ComponentActivity(), Projection {
identityProvider = { CertProvider.load(this) }, identityProvider = { CertProvider.load(this) },
projection = this, projection = this,
vehicleProfileFor = { info -> VehicleProfiles.find(VehicleProfiles.load(this), info) }, vehicleProfileFor = { info -> VehicleProfiles.find(VehicleProfiles.load(this), info) },
phoneIcons = { PhoneIcons.pngs(this) },
) )
this.runner = runner this.runner = runner
worker = Thread { runner.run() }.apply { name = "gearslip"; start() } worker = Thread { runner.run() }.apply { name = "gearslip"; start() }
holdLocation()
} }
/** A wireless session: the same runner, on the socket [WirelessLink] opened to the car. */ /** A wireless session: the same runner, on the socket [WirelessLink] opened to the car. */
@@ -423,9 +442,25 @@ class GearslipActivity : ComponentActivity(), Projection {
projection = this, projection = this,
vehicleProfileFor = { info -> VehicleProfiles.find(VehicleProfiles.load(this), info) }, vehicleProfileFor = { info -> VehicleProfiles.find(VehicleProfiles.load(this), info) },
wireless = true, wireless = true,
phoneIcons = { PhoneIcons.pngs(this) },
) )
this.runner = runner this.runner = runner
worker = Thread { runner.run() }.apply { name = "gearslip"; start() } worker = Thread { runner.run() }.apply { name = "gearslip"; start() }
holdLocation()
}
/** Whether this session holds [app.seb3thehacker.gearslip.host.LocationKeepAlive]. */
private var holdingLocation = false
/**
* Android lets a foreground service started from the background use location only in the few
* seconds after one of the app's screens opens. The car just opened this one, so take the
* location service now: a map bound later, with the phone already locked, got no position.
*/
private fun holdLocation() {
if (holdingLocation) return
holdingLocation = true
app.seb3thehacker.gearslip.host.LocationKeepAlive.acquire(this) {}
} }
private fun describe(accessory: UsbAccessory) { private fun describe(accessory: UsbAccessory) {
@@ -445,10 +480,27 @@ class GearslipActivity : ComponentActivity(), Projection {
wirelessSocket = null wirelessSocket = null
if (stopWireless) app.seb3thehacker.gearslip.wireless.WirelessLink.stop(this) if (stopWireless) app.seb3thehacker.gearslip.wireless.WirelessLink.stop(this)
runner = null runner = null
if (holdingLocation) {
holdingLocation = false
app.seb3thehacker.gearslip.host.LocationKeepAlive.release(this)
}
} }
// --- Projection --------------------------------------------------------------------- // --- Projection ---------------------------------------------------------------------
/**
* The car opens Gearslip on the phone, since Android hands a USB car only to an activity and
* auto-connect has to start one too. Once the car's screen is up, go back to whatever the
* phone showed, as Home would; the session carries on as it does with the phone locked. A
* question for the driver keeps Gearslip in front, and so does the phone already showing it.
*/
private fun stepAside() {
stepAsideOnVideo = false
if (offerBluetooth.value || micBlockedNotice.value || approvingCar != null) return
GearslipLog.i("the car's screen is up - moving Gearslip off the phone's screen")
moveTaskToBack(true)
}
override fun onSurfaceReady(surface: Surface, width: Int, height: Int, densityDpi: Int) { override fun onSurfaceReady(surface: Surface, width: Int, height: Int, densityDpi: Int) {
runOnUiThread { runOnUiThread {
this.surface = surface this.surface = surface
@@ -458,6 +510,7 @@ class GearslipActivity : ComponentActivity(), Projection {
CarEnvironment.setDisplay(width, height, densityDpi) CarEnvironment.setDisplay(width, height, densityDpi)
CarEnvironment.start(this) CarEnvironment.start(this)
showCarUi() showCarUi()
if (stepAsideOnVideo) stepAside()
app.seb3thehacker.gearslip.car.CarKeys.focusSink = { code, down -> app.seb3thehacker.gearslip.car.CarKeys.focusSink = { code, down ->
runOnUiThread { screenProjector?.dispatchKey(code, down) } runOnUiThread { screenProjector?.dispatchKey(code, down) }
} }
@@ -474,6 +527,10 @@ class GearslipActivity : ComponentActivity(), Projection {
} }
} }
override fun onStill() {
screenProjector?.redraw()
}
override fun onProjectionStopped() { override fun onProjectionStopped() {
// Before the car UI goes, while the media connection is still up. // Before the car UI goes, while the media connection is still up.
app.seb3thehacker.gearslip.car.CarServices.pausePhoneMedia() app.seb3thehacker.gearslip.car.CarServices.pausePhoneMedia()
@@ -47,6 +47,8 @@ object GearslipLog {
val version: StateFlow<Int> = liveVersion val version: StateFlow<Int> = liveVersion
fun init(context: Context) { fun init(context: Context) {
// The phone app and the launcher both start here; one file per process.
if (file != null) return
val dir = context.getExternalFilesDir(null) ?: return val dir = context.getExternalFilesDir(null) ?: return
val name = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date()) val name = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date())
file = File(dir, "gearslip-$name.log") file = File(dir, "gearslip-$name.log")
@@ -54,9 +56,65 @@ object GearslipLog {
SessionReport.init(context) SessionReport.init(context)
// One file per launch adds up; the newest are the ones anyone asks for. // One file per launch adds up; the newest are the ones anyone asks for.
io.execute { logFiles(dir).drop(KEEP_FILES).forEach { it.delete() } } io.execute { logFiles(dir).drop(KEEP_FILES).forEach { it.delete() } }
lastExit(context, dir)
} }
private const val KEEP_FILES = 30 private const val KEEP_FILES = 30
private const val KEY_EXIT_SEEN = "log_exit_seen"
/**
* Why Android ended the last run, when the crash handler can't have seen it: a native crash,
* a freeze (ANR), or the memory killer. Written here and onto the end of that run's own log,
* which is the one a driver shares after a drive that went wrong.
*/
private fun lastExit(context: Context, dir: File) {
val am = context.getSystemService(android.app.ActivityManager::class.java) ?: return
val seen = AppSettings.getString(context, KEY_EXIT_SEEN, "0").toLongOrNull() ?: 0L
val exits = runCatching { am.getHistoricalProcessExitReasons(null, 0, 5) }.getOrDefault(emptyList())
.filter { it.timestamp > seen }
exits.firstOrNull()?.let { AppSettings.putString(context, KEY_EXIT_SEEN, it.timestamp.toString()) }
val parse = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US)
for (exit in exits.reversed()) {
val why = EXIT_REASONS[exit.reason] ?: continue
val at = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(Date(exit.timestamp))
val detail = buildString {
append("$at, $why")
exit.description?.takeIf { it.isNotBlank() }?.let { append(" ($it)") }
append(", memory ${exit.pss / 1024} MB")
if (exit.reason == android.app.ApplicationExitInfo.REASON_ANR) {
anrMainThread(exit)?.let { append("\n").append(it) }
}
}
w("Android ended an earlier run at $detail")
// The run that died: the newest log that started before it ended.
val run = logFiles(dir).filter { it != file }.firstOrNull { f ->
val started = runCatching { parse.parse(f.name.removePrefix("gearslip-").removeSuffix(".log")) }.getOrNull()
started != null && started.time <= exit.timestamp
} ?: continue
io.execute { runCatching { run.appendText("--- $EXIT_MARK $detail\n") } }
}
}
/** The main thread's stack from an ANR trace: what Gearslip was stuck doing. */
private fun anrMainThread(exit: android.app.ApplicationExitInfo): String? = runCatching {
exit.traceInputStream?.bufferedReader()?.useLines { lines ->
lines.dropWhile { !it.startsWith("\"main\"") }.take(ANR_LINES).joinToString("\n")
}?.takeIf { it.isNotBlank() }
}.getOrNull()
private const val ANR_LINES = 25
private const val EXIT_MARK = "Android ended this run:"
/** Exit reasons worth a line; leaving, updating and Java crashes (already logged) aren't. */
private val EXIT_REASONS = mapOf(
android.app.ApplicationExitInfo.REASON_CRASH_NATIVE to "a native crash",
android.app.ApplicationExitInfo.REASON_ANR to "the app stopped responding (ANR)",
android.app.ApplicationExitInfo.REASON_LOW_MEMORY to "the phone ran low on memory",
android.app.ApplicationExitInfo.REASON_EXCESSIVE_RESOURCE_USAGE to "it used too much of the phone's resources",
android.app.ApplicationExitInfo.REASON_INITIALIZATION_FAILURE to "it failed to start",
android.app.ApplicationExitInfo.REASON_SIGNALED to "the system killed it",
android.app.ApplicationExitInfo.REASON_DEPENDENCY_DIED to "something it depends on died",
)
private fun logFiles(dir: File): List<File> = private fun logFiles(dir: File): List<File> =
dir.listFiles { f -> f.isFile && f.name.startsWith("gearslip-") && f.name.endsWith(".log") } dir.listFiles { f -> f.isFile && f.name.startsWith("gearslip-") && f.name.endsWith(".log") }
@@ -77,7 +135,7 @@ object GearslipLog {
PastLog( PastLog(
file = f, file = f,
started = runCatching { parse.parse(f.name.removePrefix("gearslip-").removeSuffix(".log")) }.getOrNull(), started = runCatching { parse.parse(f.name.removePrefix("gearslip-").removeSuffix(".log")) }.getOrNull(),
crashed = "FATAL uncaught" in text, crashed = "FATAL uncaught" in text || EXIT_MARK in text,
car = Regex("""car="([^"]+)"""").findAll(text).lastOrNull()?.groupValues?.get(1), car = Regex("""car="([^"]+)"""").findAll(text).lastOrNull()?.groupValues?.get(1),
) )
} }
@@ -173,7 +231,11 @@ object GearslipLog {
* Records an uncaught exception in the log file before the process dies, then lets the * Records an uncaught exception in the log file before the process dies, then lets the
* system's own handler run. Without it the file ends mid-sentence and the reason is lost. * system's own handler run. Without it the file ends mid-sentence and the reason is lost.
*/ */
@Volatile private var crashHandlerInstalled = false
fun installCrashHandler() { fun installCrashHandler() {
if (crashHandlerInstalled) return
crashHandlerInstalled = true
val previous = Thread.getDefaultUncaughtExceptionHandler() val previous = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
runCatching { runCatching {
@@ -48,6 +48,8 @@ class GearslipRunner(
private val vehicleProfileFor: (ServiceDiscovery.HeadUnitInfo) -> VehicleProfile? = { null }, private val vehicleProfileFor: (ServiceDiscovery.HeadUnitInfo) -> VehicleProfile? = { null },
/** Over Wi-Fi rather than USB: the video is sent smaller so the link keeps up. */ /** Over Wi-Fi rather than USB: the video is sent smaller so the link keeps up. */
private val wireless: Boolean = false, private val wireless: Boolean = false,
/** Gearslip's icon as 32, 64 and 128 px PNGs, for the car's phone tile. See [PhoneIcons]. */
private val phoneIcons: () -> List<ByteArray> = { emptyList() },
) { ) {
private val log = GearslipLog.tagged("PROTO") private val log = GearslipLog.tagged("PROTO")
@@ -56,6 +58,8 @@ class GearslipRunner(
private val parser = Frames.Parser() private val parser = Frames.Parser()
private val assembler = Frames.Assembler() private val assembler = Frames.Assembler()
private lateinit var identityTask: FutureTask<PhoneTls> private lateinit var identityTask: FutureTask<PhoneTls>
/** The certificate in use, to tell a wrong date on the car from a certificate that's really out of date. */
@Volatile private var phoneCert: java.security.cert.X509Certificate? = null
/** Sends every message, in order, so no other thread ever blocks on the USB link. See [send]. */ /** Sends every message, in order, so no other thread ever blocks on the USB link. See [send]. */
private val writer = java.util.concurrent.Executors.newSingleThreadExecutor { private val writer = java.util.concurrent.Executors.newSingleThreadExecutor {
@@ -128,6 +132,7 @@ class GearslipRunner(
identityTask = FutureTask { identityTask = FutureTask {
val identity = identityProvider() val identity = identityProvider()
val cert = identity.certificate val cert = identity.certificate
phoneCert = cert
log.i("phone certificate source: ${identity.source}") log.i("phone certificate source: ${identity.source}")
SessionReport.certificate(identity.source.toString(), cert.issuerX500Principal.toString()) SessionReport.certificate(identity.source.toString(), cert.issuerX500Principal.toString())
log.i(" subject = ${cert.subjectX500Principal}") log.i(" subject = ${cert.subjectX500Principal}")
@@ -158,7 +163,8 @@ class GearslipRunner(
} catch (t: Throwable) { } catch (t: Throwable) {
if (stopped) return if (stopped) return
log.e("transport failed in state $state", t) log.e("transport failed in state $state", t)
SessionReport.fail(SessionReport.Category.USB, "transport failed", t, state.name) val link = if (wireless) SessionReport.Category.WIFI else SessionReport.Category.USB
SessionReport.fail(link, "transport failed", t, state.name)
reportDisconnect() reportDisconnect()
} finally { } finally {
running = false running = false
@@ -283,6 +289,7 @@ class GearslipRunner(
MSG_AUTH_COMPLETE -> onAuthComplete(body) MSG_AUTH_COMPLETE -> onAuthComplete(body)
MSG_SERVICE_DISCOVERY_RESPONSE -> onServiceDiscoveryResponse(body) MSG_SERVICE_DISCOVERY_RESPONSE -> onServiceDiscoveryResponse(body)
MSG_PING_REQUEST -> onPingRequest(body) MSG_PING_REQUEST -> onPingRequest(body)
MSG_PING_RESPONSE -> onPingResponse()
MSG_BYEBYE_REQUEST -> onByeByeRequest(body) MSG_BYEBYE_REQUEST -> onByeByeRequest(body)
MSG_UNEXPECTED_MESSAGE, MSG_FRAMING_ERROR -> onProtocolError(messageId) MSG_UNEXPECTED_MESSAGE, MSG_FRAMING_ERROR -> onProtocolError(messageId)
AudioLink.MSG_FOCUS_RESPONSE -> { AudioLink.MSG_FOCUS_RESPONSE -> {
@@ -404,8 +411,7 @@ class GearslipRunner(
} }
-2, -23, -24 -> { -2, -23, -24 -> {
val reason = when (status) { val reason = when (status) {
-23 -> "The phone's certificate is not valid yet." -23, -24 -> certificateDates(status)
-24 -> "The phone's certificate has expired."
else -> "The head unit refused the phone's certificate." else -> "The head unit refused the phone's certificate."
} }
SessionReport.fail(SessionReport.Category.CERTIFICATE, "AuthComplete status $status", state.name, "auth $status") SessionReport.fail(SessionReport.Category.CERTIFICATE, "AuthComplete status $status", state.name, "auth $status")
@@ -446,11 +452,44 @@ class GearslipRunner(
if (authenticationFailed) running = false if (authenticationFailed) running = false
} }
/**
* The car checks the certificate's dates against its own clock. When they're fine by the
* phone's clock, the car's clock is the one that's wrong, which a flat battery or a car
* that never got the time from GPS leaves behind.
*/
private fun certificateDates(status: Int): String {
val cert = phoneCert ?: return if (status == -23) "The phone's certificate is not valid yet." else "The phone's certificate has expired."
val now = java.util.Date()
val day = java.text.DateFormat.getDateInstance(java.text.DateFormat.LONG)
return when {
status == -23 && now.before(cert.notBefore) ->
"The phone's certificate is not valid until ${day.format(cert.notBefore)}."
status == -24 && now.after(cert.notAfter) ->
"The phone's certificate expired on ${day.format(cert.notAfter)}. Get a new one under Certificate in Settings."
else -> {
val side = if (status == -23) "behind" else "ahead"
log.w("the certificate's dates are fine by the phone's clock, so the car's clock is $side")
"The car's clock looks wrong: the certificate is valid today, but the car thinks it " +
(if (status == -23) "isn't valid yet" else "has expired") +
". Set the car's date and time, then connect again."
}
}
}
/**
* What a real phone sends here: its icon at three sizes (fields 1 to 3), a label, its model,
* and a session ID that's new each connection (field 6). A 2017 Hyundai on protocol 1.0
* never answered the request when it had only the label and model (issue #47).
*/
private fun sendServiceDiscoveryRequest() { private fun sendServiceDiscoveryRequest() {
val body = Protobuf.stringField(4, "Gearslip") + // label_text val icons = runCatching(phoneIcons).getOrDefault(emptyList())
Protobuf.stringField(5, Build.MODEL) // device_name var body = ByteArray(0)
icons.take(3).forEachIndexed { i, png -> body += Protobuf.bytesField(i + 1, png) } // small, medium, large icon
body += Protobuf.stringField(4, "Gearslip") + // label_text
Protobuf.stringField(5, Build.MODEL) + // device_name
Protobuf.bytesField(6, Protobuf.stringField(1, java.util.UUID.randomUUID().toString())) // session ID
send(MSG_SERVICE_DISCOVERY_REQUEST, body, encrypted = true) send(MSG_SERVICE_DISCOVERY_REQUEST, body, encrypted = true)
log.i("-> ServiceDiscoveryRequest (encrypted)") log.i("-> ServiceDiscoveryRequest (encrypted, ${icons.size} icons, ${body.size} bytes)")
} }
private fun onServiceDiscoveryResponse(body: ByteArray) { private fun onServiceDiscoveryResponse(body: ByteArray) {
@@ -469,6 +508,7 @@ class GearslipRunner(
"possible against THIS unit - portability to newer units is still unproven.", "possible against THIS unit - portability to newer units is still unproven.",
) )
state = State.DONE state = State.DONE
startPhonePings()
val info = ServiceDiscovery.findHeadUnitInfo(body) val info = ServiceDiscovery.findHeadUnitInfo(body)
log.i("head unit: $info") log.i("head unit: $info")
SessionReport.headUnit(info.toString()) SessionReport.headUnit(info.toString())
@@ -577,6 +617,8 @@ class GearslipRunner(
// The driver picked the radio or another source on the car: pause the phone's music // The driver picked the radio or another source on the car: pause the phone's music
// as Android Auto does, instead of letting it play on to no one. // as Android Auto does, instead of letting it play on to no one.
onTakenBack = { CarServices.pausePhoneMedia("the car switched to another source") }, onTakenBack = { CarServices.pausePhoneMedia("the car switched to another source") },
onPausedForNow = { CarServices.pausePhoneMediaForNow() },
onGivenBack = { CarServices.resumePhoneMedia() },
) )
if (CarSettings.pipeAudio.value) { if (CarSettings.pipeAudio.value) {
openChannel(media.serviceId) openChannel(media.serviceId)
@@ -606,7 +648,9 @@ class GearslipRunner(
tag = "guidance", tag = "guidance",
sessionId = GUIDANCE_SESSION_ID, sessionId = GUIDANCE_SESSION_ID,
asksFocus = false, asksFocus = false,
onReady = { DirectionsVoice.attach(it) { audioLink?.streaming == true } }, onReady = {
DirectionsVoice.attach(it, musicIsOurs = { audioLink?.streaming == true }, musicSink = { audioLink?.takeIf { link -> link.isReady } })
},
onClose = { DirectionsVoice.detach(it) }, onClose = { DirectionsVoice.detach(it) },
) )
openChannel(guidance.serviceId) openChannel(guidance.serviceId)
@@ -995,11 +1039,8 @@ class GearslipRunner(
"${videoConfigs.getOrNull(selectedConfigIndex ?: -1)?.resolutionName ?: "?"} " + "${videoConfigs.getOrNull(selectedConfigIndex ?: -1)?.resolutionName ?: "?"} " +
"(head unit offered ${offered.size}), max_unacked=$maxUnacked", "(head unit offered ${offered.size}), max_unacked=$maxUnacked",
) )
// VideoFocusRequestNotification { mode = 2; reason = 3 } requestProjectedFocus()
val focus = Protobuf.varintField(2, VIDEO_FOCUS_PROJECTED.toLong()) + if (CarAudio.openSink != null) watchForWithheldScreen()
Protobuf.varintField(3, 1L)
send(MSG_VIDEO_FOCUS_REQUEST, focus, encrypted = true, channel = videoChannelId)
log.i("-> VideoFocusRequest(mode=PROJECTED)")
} }
MSG_VIDEO_FOCUS_NOTIFICATION -> { MSG_VIDEO_FOCUS_NOTIFICATION -> {
@@ -1152,6 +1193,7 @@ class GearslipRunner(
onFrame = { data, presentationTimeUs, keyFrame -> onFrame = { data, presentationTimeUs, keyFrame ->
sendVideoFrame(data, presentationTimeUs, keyFrame) sendVideoFrame(data, presentationTimeUs, keyFrame)
}, },
onStill = { projection?.onStill() },
).also { source -> ).also { source ->
runCatching { runCatching {
source.start() source.start()
@@ -1275,6 +1317,38 @@ class GearslipRunner(
} }
} }
/** Asks the car for its screen; the answer comes to [onVideoFocus]. */
private fun requestProjectedFocus() {
// VideoFocusRequestNotification { 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.
val focus = Protobuf.varintField(2, VIDEO_FOCUS_PROJECTED.toLong()) +
Protobuf.varintField(3, 0L)
send(MSG_VIDEO_FOCUS_REQUEST, focus, encrypted = true, channel = videoChannelId)
log.i("-> VideoFocusRequest(mode=PROJECTED)")
}
/**
* Some older head units keep their own screen until every channel the phone was offered is
* open: a 2017 Pilot, a 2020 Civic and a 2024 Mirage (issues #43, #49, #57) never showed
* ours with the media channel closed, and the Mirage worked on 0.3.01, which opened it. With
* music over Bluetooth that channel waits for the car to switch source, so if the screen
* hasn't come a few seconds after asking, open it and ask again. Cars that hand the screen
* straight over keep their own source as before.
*/
private fun watchForWithheldScreen() {
Thread({
Thread.sleep(WITHHELD_SCREEN_MS)
if (!running || videoStarted) return@Thread
val open = CarAudio.openSink ?: return@Thread
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)
if (running && !videoStarted) requestProjectedFocus()
}, "gearslip-focus-fallback").apply { isDaemon = true }.start()
}
/** Android Auto's Exit: asks the car for its own interface, leaving the session running. */ /** Android Auto's Exit: asks the car for its own interface, leaving the session running. */
private fun requestNativeFocus() { private fun requestNativeFocus() {
if (!videoStarted) return if (!videoStarted) return
@@ -1307,6 +1381,38 @@ class GearslipRunner(
log.i("touch -> frame: ${draw + encode}ms (draw ${draw}ms, encode and wait ${encode}ms)") log.i("touch -> frame: ${draw + encode}ms (draw ${draw}ms, encode and wait ${encode}ms)")
} }
/**
* Android Auto pings the car too, not only answers its pings; either side may. A 2021 Bolt
* hangs up 25 seconds into every drive while its own pings get answered and GM's channel is
* open (issue #18), so the one thing left that a real phone sends and Gearslip didn't is this.
*/
private fun startPhonePings() {
Thread({
while (running && state == State.DONE) {
runCatching {
send(MSG_PING_REQUEST, Protobuf.varintField(1, System.nanoTime() / 1_000), encrypted = true)
}.onFailure { return@Thread }
pingsSent++
if (pingsSent == 1) log.i("-> PingRequest (Gearslip pings the car once a second)")
try {
Thread.sleep(PHONE_PING_MS)
} catch (e: InterruptedException) {
return@Thread
}
}
}, "gearslip-ping").apply { isDaemon = true }.start()
}
@Volatile private var pingsSent = 0
private var pingsAnswered = 0
private fun onPingResponse() {
pingsAnswered++
if (pingsAnswered == 1 || pingsAnswered % PING_LOG_EVERY == 0) {
log.i("<- PingResponse ($pingsAnswered of $pingsSent answered)")
}
}
private fun onPingRequest(body: ByteArray) { private fun onPingRequest(body: ByteArray) {
// The timestamp is an int64 and has to come back unchanged. Read as an int32 it was cut // The timestamp is an int64 and has to come back unchanged. Read as an int32 it was cut
// short, and a car that checks the echo (an Audi MIB2+) gave up at its second ping. // short, and a car that checks the echo (an Audi MIB2+) gave up at its second ping.
@@ -1400,6 +1506,9 @@ class GearslipRunner(
SessionReport.print() SessionReport.print()
} }
private val startedAt = System.currentTimeMillis()
private var toldStuck = false
private fun startWatchdog() { private fun startWatchdog() {
Thread { Thread {
var lastFrames = 0 var lastFrames = 0
@@ -1411,6 +1520,11 @@ class GearslipRunner(
val idle = System.currentTimeMillis() - lastProgress val idle = System.currentTimeMillis() - lastProgress
if (idle > 10_000) log.w("stalled in state $state for ${idle / 1000}s") if (idle > 10_000) log.w("stalled in state $state for ${idle / 1000}s")
} }
if (!videoStarted && !toldStuck && System.currentTimeMillis() - startedAt > STUCK_MS) {
toldStuck = true
log.w("still not on the car's screen after ${STUCK_MS / 1000}s, in state $state")
SessionStatus.stillConnecting(if (wireless) SessionStatus.Link.WIRELESS else SessionStatus.Link.CABLE)
}
// Stream health. Without this a drive cannot tell "streaming fine" from // Stream health. Without this a drive cannot tell "streaming fine" from
// "stalled after three frames" - the per-frame logging is deliberately sparse. // "stalled after three frames" - the per-frame logging is deliberately sparse.
@@ -1503,6 +1617,9 @@ class GearslipRunner(
/** The stats window adaptive bitrate reads, matching the watchdog's sleep. */ /** The stats window adaptive bitrate reads, matching the watchdog's sleep. */
const val ADAPT_WINDOW_MS = 5_000L const val ADAPT_WINDOW_MS = 5_000L
/** A car shows its screen within seconds; this long without one, Home says what to try. */
const val STUCK_MS = 45_000L
// protobuf/aap_protobuf/service/control/ControlMessageType.proto:7-17 // protobuf/aap_protobuf/service/control/ControlMessageType.proto:7-17
const val MSG_VERSION_REQUEST = 1 const val MSG_VERSION_REQUEST = 1
const val MSG_VERSION_RESPONSE = 2 const val MSG_VERSION_RESPONSE = 2
@@ -1512,6 +1629,8 @@ class GearslipRunner(
const val MSG_SERVICE_DISCOVERY_RESPONSE = 6 const val MSG_SERVICE_DISCOVERY_RESPONSE = 6
const val MSG_PING_REQUEST = 11 const val MSG_PING_REQUEST = 11
const val MSG_PING_RESPONSE = 12 const val MSG_PING_RESPONSE = 12
private const val PHONE_PING_MS = 1_000L
private const val PING_LOG_EVERY = 60
const val MSG_BYEBYE_REQUEST = 15 const val MSG_BYEBYE_REQUEST = 15
const val MSG_BYEBYE_RESPONSE = 16 const val MSG_BYEBYE_RESPONSE = 16
const val MSG_VOICE_SESSION_NOTIFICATION = 17 const val MSG_VOICE_SESSION_NOTIFICATION = 17
@@ -1556,6 +1675,7 @@ class GearslipRunner(
const val ACTION_UP = 1 const val ACTION_UP = 1
const val ACK_STALL_MS = 2_000L const val ACK_STALL_MS = 2_000L
const val WITHHELD_SCREEN_MS = 5_000L
const val AUDIO_TRACE_MESSAGES = 6 const val AUDIO_TRACE_MESSAGES = 6
const val GUIDANCE_SESSION_ID = 1 const val GUIDANCE_SESSION_ID = 1
const val PERIODIC_KEYFRAME_MS = 5_000L const val PERIODIC_KEYFRAME_MS = 5_000L
@@ -0,0 +1,152 @@
package app.seb3thehacker.gearslip
import android.content.Intent
import android.content.res.Configuration
import android.os.Bundle
import android.view.KeyEvent
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.activity.OnBackPressedCallback
import androidx.activity.compose.setContent
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.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.seb3thehacker.gearslip.car.CarEnvironment
import app.seb3thehacker.gearslip.car.CarKeys
import app.seb3thehacker.gearslip.car.CarSettings
import app.seb3thehacker.gearslip.car.CarUi
import app.seb3thehacker.gearslip.car.LauncherMode
import app.seb3thehacker.gearslip.car.Prefetch
import app.seb3thehacker.gearslip.ui.CarFrame
import app.seb3thehacker.gearslip.ui.GearslipTheme
import app.seb3thehacker.gearslip.ui.rememberCarProjector
/**
* The home screen in launcher mode (see [LauncherMode]): the car UI, filling this device's own
* screen. It runs for as long as the activity lives, not only while it's in front, so a map
* keeps guiding and music keeps playing while another app is open over it.
*/
class LauncherActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
GearslipLog.init(this)
GearslipLog.installCrashHandler()
CarSettings.init(this)
CarKeys.init(this)
Prefetch.warm(this)
CarEnvironment.setPhoneTheme(resources.configuration)
// A dash screen is something to glance at: it stays on, with the system bars out of the way.
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
WindowCompat.setDecorFitsSystemWindows(window, false)
WindowInsetsControllerCompat(window, window.decorView).apply {
systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
hide(WindowInsetsCompat.Type.systemBars())
}
// Back moves back in the car UI. A home screen has nowhere else to go back to.
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
CarKeys.onKey(KeyEvent.KEYCODE_BACK, down = true, longPress = false)
}
})
setContent {
GearslipTheme {
LauncherScreen(onOpenGearslip = { startActivity(Intent(this, GearslipActivity::class.java)) })
}
}
}
/** Home pressed while Gearslip is already the home screen: back to the car UI's Home. */
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
CarKeys.onKey(KeyEvent.KEYCODE_HOME, down = true, longPress = false)
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
CarEnvironment.setPhoneTheme(newConfig)
}
/** A head unit's knob, D-pad and hard keys work the car UI the way a car's do. */
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
if (event.keyCode != KeyEvent.KEYCODE_BACK && event.repeatCount == 0 &&
CarKeys.onKey(event.keyCode, event.action == KeyEvent.ACTION_DOWN, event.isLongPress)
) return true
return super.dispatchKeyEvent(event)
}
}
@Composable
private fun LauncherScreen(onOpenGearslip: () -> Unit) {
val status by SessionStatus.state.collectAsStateWithLifecycle()
// Plugged into a car, this device is the phone and the car UI belongs on the car's screen.
val inCar = status.phase == SessionStatus.Phase.CONNECTING || status.phase == SessionStatus.Phase.PROJECTING
if (inCar) {
InCarNotice(onOpenGearslip)
return
}
BoxWithConstraints(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
val px = with(LocalDensity.current) { maxWidth.roundToPx() / 2 * 2 to maxHeight.roundToPx() / 2 * 2 }
val (width, height) = px
if (width <= 0 || height <= 0) return@BoxWithConstraints
// Car-like text and buttons: 160dpi for every 480 pixels of height, as the car preview uses.
// This screen stands in for the car's, so hosted apps size to it and the density setting applies.
val density = remember(width, height) {
CarEnvironment.setDisplay(width, height, (160 * height / 480).coerceIn(120, 640))
CarEnvironment.frame.value.densityDpi
}
// Before the projector starts, so the car UI's first screen already knows what to hide.
DisposableEffect(Unit) {
LauncherMode.setActive(true)
onDispose { LauncherMode.setActive(false) }
}
val projector = rememberCarProjector(width, height, density, "gearslip-launcher") { CarUi() }
DisposableEffect(projector) {
CarKeys.focusSink = { code, down -> projector.dispatchKey(code, down) }
onDispose { CarKeys.focusSink = null }
}
CarFrame(projector, width, height, Modifier.fillMaxSize())
}
}
@Composable
private fun InCarNotice(onOpenGearslip: () -> Unit) {
Column(
Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background).padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
stringResource(R.string.launcher_in_car),
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onBackground,
)
Spacer(Modifier.height(16.dp))
Button(onClick = onOpenGearslip) { Text(stringResource(R.string.launcher_open_gearslip)) }
}
}
@@ -0,0 +1,34 @@
package app.seb3thehacker.gearslip
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import java.io.ByteArrayOutputStream
/**
* Gearslip's own icon as the PNGs a phone hands the car in its ServiceDiscoveryRequest, at the
* 32, 64 and 128 px a real phone sends. Cars draw the phone's tile on their home screen from
* them, and older head units may wait for them before answering at all.
*/
object PhoneIcons {
private val SIZES = intArrayOf(32, 64, 128)
@Volatile private var cached: List<ByteArray>? = null
/** The three PNGs, smallest first; empty if the icon can't be drawn. */
fun pngs(context: Context): List<ByteArray> = cached ?: runCatching {
val icon = context.packageManager.getApplicationIcon(context.applicationInfo).mutate()
SIZES.map { size ->
val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
icon.setBounds(0, 0, size, size)
icon.draw(Canvas(bitmap))
val png = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.PNG, 100, png)
bitmap.recycle()
png.toByteArray()
}
}.onFailure { GearslipLog.w("phone icons: couldn't draw them (${it.message})") }
.getOrDefault(emptyList())
.also { if (it.isNotEmpty()) cached = it }
}
@@ -24,6 +24,12 @@ interface Projection {
fun onTouch(action: Int, actionIndex: Int, points: List<TouchPoint>) fun onTouch(action: Int, actionIndex: Int, points: List<TouchPoint>)
fun onProjectionStopped() fun onProjectionStopped()
/**
* The screen has been still for a moment and the encoder has sent nothing since. Draw it once
* more, so the car gets a fresh frame (see [VideoSource]'s onStill).
*/
fun onStill() {}
} }
/** One finger on the car's screen: a stable [id] for as long as it stays down, and where it is. */ /** One finger on the car's screen: a stable [id] for as long as it stays down, and where it is. */
@@ -223,6 +223,11 @@ class ScreenProjector(private val context: Context) {
* Points the display at another surface, or none, without restarting what it shows. The * Points the display at another surface, or none, without restarting what it shows. The
* preview's SurfaceView comes and goes as the phone turns; the car UI on it carries on. * preview's SurfaceView comes and goes as the phone turns; the car UI on it carries on.
*/ */
/** Draws the screen again as it is, which hands the display's consumer a new frame. */
fun redraw() {
root?.let { it.post { it.invalidate() } }
}
fun setSurface(surface: Surface?) { fun setSurface(surface: Surface?) {
virtualDisplay?.surface = surface virtualDisplay?.surface = surface
} }
@@ -18,6 +18,7 @@ object SessionReport {
enum class Category(val label: String) { enum class Category(val label: String) {
NONE("none"), NONE("none"),
USB("USB transport"), USB("USB transport"),
WIFI("Wi-Fi transport"),
NO_HANDSHAKE("head unit never started talking"), NO_HANDSHAKE("head unit never started talking"),
TLS("TLS handshake failed"), TLS("TLS handshake failed"),
CERTIFICATE("certificate rejected by the head unit"), CERTIFICATE("certificate rejected by the head unit"),
@@ -218,7 +219,7 @@ object SessionReport {
// switching the car off at the end shows up as a USB or ByeBye "failure" that isn't one. // switching the car off at the end shows up as a USB or ByeBye "failure" that isn't one.
val reached = carScreen.isNotEmpty() val reached = carScreen.isNotEmpty()
val outcome = when { val outcome = when {
reached && category in setOf(Category.NONE, Category.USB, Category.BYEBYE) -> "connected" reached && category in setOf(Category.NONE, Category.USB, Category.WIFI, Category.BYEBYE) -> "connected"
reached -> "connected, then ${category.label}" reached -> "connected, then ${category.label}"
category == Category.NONE -> "ended before video" category == Category.NONE -> "ended before video"
else -> category.label else -> category.label
@@ -14,7 +14,10 @@ object SessionStatus {
enum class Phase { IDLE, CONNECTING, PROJECTING, FAILED, DISCONNECTED } enum class Phase { IDLE, CONNECTING, PROJECTING, FAILED, DISCONNECTED }
data class Snapshot(val phase: Phase, val headline: String, val detail: String) /** [stuckOn] is set when connecting has gone on too long: the cable or Wi-Fi, for what to try. */
data class Snapshot(val phase: Phase, val headline: String, val detail: String, val stuckOn: Link? = null)
enum class Link { CABLE, WIRELESS }
private val flow = MutableStateFlow( private val flow = MutableStateFlow(
Snapshot( Snapshot(
@@ -30,6 +33,12 @@ object SessionStatus {
flow.value = Snapshot(Phase.CONNECTING, "Connecting", stage) flow.value = Snapshot(Phase.CONNECTING, "Connecting", stage)
} }
/** Still connecting after [GearslipRunner]'s patience ran out: Home says what to try. */
fun stillConnecting(link: Link) {
val now = flow.value
if (now.phase == Phase.CONNECTING) flow.value = now.copy(stuckOn = link)
}
fun projecting() { fun projecting() {
flow.value = Snapshot(Phase.PROJECTING, "Projecting", "Streaming to the head unit.") flow.value = Snapshot(Phase.PROJECTING, "Projecting", "Streaming to the head unit.")
} }
@@ -27,6 +27,12 @@ class VideoSource(
private val wireless: Boolean = false, private val wireless: Boolean = false,
private val onCodecConfig: (ByteArray) -> Unit, private val onCodecConfig: (ByteArray) -> Unit,
private val onFrame: (data: ByteArray, presentationTimeUs: Long, keyFrame: Boolean) -> Unit, private val onFrame: (data: ByteArray, presentationTimeUs: Long, keyFrame: Boolean) -> Unit,
/**
* SURFACE mode, nothing encoded for [STILL_MS]. The repeat setting below should cover a still
* screen, but on a Pixel 10 it didn't: a LuChi box got 11 frames, then none, and stayed black
* (issue #58). Whoever draws the screen can draw it again, and that is a frame.
*/
private val onStill: () -> Unit = {},
) { ) {
private val log = GearslipLog.tagged("VIDEO") private val log = GearslipLog.tagged("VIDEO")
@@ -170,6 +176,8 @@ class VideoSource(
private fun pump(encoder: MediaCodec) { private fun pump(encoder: MediaCodec) {
val info = MediaCodec.BufferInfo() val info = MediaCodec.BufferInfo()
val frameIntervalUs = 1_000_000L / frameRate val frameIntervalUs = 1_000_000L / frameRate
var lastOutputAt = 0L
var lastNudgeAt = 0L
while (running) { while (running) {
// SURFACE mode has no input buffers to fill - frames arrive from the display. // SURFACE mode has no input buffers to fill - frames arrive from the display.
@@ -208,8 +216,16 @@ class VideoSource(
} }
} }
encoder.releaseOutputBuffer(outIndex, false) encoder.releaseOutputBuffer(outIndex, false)
lastOutputAt = System.currentTimeMillis()
outIndex = encoder.dequeueOutputBuffer(info, 0) outIndex = encoder.dequeueOutputBuffer(info, 0)
} }
if (mode == Mode.SURFACE && lastOutputAt > 0) {
val now = System.currentTimeMillis()
if (now - lastOutputAt > STILL_MS && now - lastNudgeAt > STILL_MS) {
lastNudgeAt = now
onStill()
}
}
if (mode != Mode.SURFACE) Thread.sleep(maxOf(1L, frameIntervalUs / 2000)) if (mode != Mode.SURFACE) Thread.sleep(maxOf(1L, frameIntervalUs / 2000))
} }
@@ -256,6 +272,7 @@ class VideoSource(
const val MIME = MediaFormat.MIMETYPE_VIDEO_AVC const val MIME = MediaFormat.MIMETYPE_VIDEO_AVC
const val TIMEOUT_US = 10_000L const val TIMEOUT_US = 10_000L
const val SURFACE_TIMEOUT_US = 100_000L const val SURFACE_TIMEOUT_US = 100_000L
private const val STILL_MS = 1_000L
/** /**
* 800x480 at 30fps gets 8 Mbps: about the most that size can show, enough that a moving * 800x480 at 30fps gets 8 Mbps: about the most that size can show, enough that a moving
@@ -36,6 +36,9 @@ class AudioLink(
private val onClose: (AudioLink) -> Unit = { CarAudio.detach(it) }, private val onClose: (AudioLink) -> Unit = { CarAudio.detach(it) },
/** The car took the sound back after giving it, as when the driver switches to the radio. */ /** The car took the sound back after giving it, as when the driver switches to the radio. */
private val onTakenBack: () -> Unit = {}, private val onTakenBack: () -> Unit = {},
/** The car wants the speakers for a moment (a call, its own voice), and then [onGivenBack]. */
private val onPausedForNow: () -> Unit = {},
private val onGivenBack: () -> Unit = {},
) : CarAudio.Link { ) : CarAudio.Link {
private var configIndex = 0 private var configIndex = 0
@@ -43,6 +46,8 @@ class AudioLink(
@Volatile private var started = false @Volatile private var started = false
@Volatile private var focusGranted = false @Volatile private var focusGranted = false
@Volatile private var focusRequested = false @Volatile private var focusRequested = false
/** The car took the speakers for a moment, and we paused the phone's music until it's done. */
@Volatile private var pausedForNow = false
@Volatile private var inFlight = 0 @Volatile private var inFlight = 0
// The Uconnect asks for 4 on its video channel; assume the same until its Config says otherwise. // 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 maxUnacked = 4
@@ -70,6 +75,9 @@ class AudioLink(
/** Sending audio now, which for media means the car has handed us the speakers. */ /** Sending audio now, which for media means the car has handed us the speakers. */
val streaming get() = started val streaming get() = started
/** The car has set the sink up, so a stream can start. */
val isReady get() = ready
@Volatile private var holdsSpeakers = false @Volatile private var holdsSpeakers = false
@Volatile private var focusAnswer: CountDownLatch? = null @Volatile private var focusAnswer: CountDownLatch? = null
@Volatile private var answerDueBy = 0L @Volatile private var answerDueBy = 0L
@@ -108,6 +116,21 @@ class AudioLink(
GearslipLog.i("$tag: -> AudioFocusRequest(RELEASE)") GearslipLog.i("$tag: -> AudioFocusRequest(RELEASE)")
} }
/**
* A spoken turn on the music channel while no music plays (see DirectionsVoice): the stream
* runs for the turn only, under ducking focus like the directions channel's, so the radio
* comes back after it rather than giving way to Android Auto for good.
*/
fun startForTurn() {
askForSpeakers()
startStream()
}
fun stopForTurn() {
synchronized(startLock) { stopLocked(release = false) }
handBackSpeakers()
}
fun opened(status: Int) { fun opened(status: Int) {
GearslipLog.i("$tag: <- ChannelOpenResponse: status=$status") GearslipLog.i("$tag: <- ChannelOpenResponse: status=$status")
if (status != 0) { if (status != 0) {
@@ -213,7 +236,7 @@ class AudioLink(
fun onFocus(state: Int) { fun onFocus(state: Int) {
val name = FOCUS_STATES[state] ?: "unknown($state)" val name = FOCUS_STATES[state] ?: "unknown($state)"
GearslipLog.i("$tag: <- AudioFocusResponse state=$name") GearslipLog.i("$tag: <- AudioFocusResponse state=$name")
if (!asksFocus) { if (!asksFocus || expectsFocusAnswer()) {
// Directions speak whatever the answer: a lost turn is worse than a loud one. // Directions speak whatever the answer: a lost turn is worse than a loud one.
answerDueBy = 0L answerDueBy = 0L
focusAnswer?.countDown() focusAnswer?.countDown()
@@ -222,9 +245,32 @@ class AudioLink(
val had = focusGranted val had = focusGranted
focusGranted = state == FOCUS_STATE_GAIN || state == FOCUS_STATE_GAIN_TRANSIENT || focusGranted = state == FOCUS_STATE_GAIN || state == FOCUS_STATE_GAIN_TRANSIENT ||
state == FOCUS_STATE_GAIN_MEDIA_ONLY state == FOCUS_STATE_GAIN_MEDIA_ONLY
if (focusGranted) startStream() else stop() when {
focusGranted -> {
startStream()
if (pausedForNow) {
pausedForNow = false
onGivenBack()
}
}
// A call or the car's own voice. Releasing focus here meant the car never handed it
// back, so the music stayed paused after the call: keep the claim and wait for GAIN.
state == FOCUS_STATE_LOSS_TRANSIENT -> {
synchronized(startLock) { stopLocked(release = false) }
if (had) {
pausedForNow = true
onPausedForNow()
}
}
else -> {
stop()
// A ducking request leaves the music playing; a loss means another source has the speakers. // A ducking request leaves the music playing; a loss means another source has the speakers.
if (had && (state == FOCUS_STATE_LOSS || state == FOCUS_STATE_LOSS_TRANSIENT)) onTakenBack() if (state == FOCUS_STATE_LOSS && (had || pausedForNow)) {
pausedForNow = false
onTakenBack()
}
}
}
} }
/** /**
@@ -299,19 +345,20 @@ class AudioLink(
synchronized(startLock) { stopLocked() } synchronized(startLock) { stopLocked() }
} }
private fun stopLocked() { /** [release] false keeps our claim on the speakers, so the car hands them back on its own. */
private fun stopLocked(release: Boolean = true) {
if (!started) return if (!started) return
started = false started = false
synchronized(ackLock) { ackLock.notifyAll() } synchronized(ackLock) { ackLock.notifyAll() }
runCatching { runCatching {
sendOnChannel(MSG_STOP, Protobuf.varintField(1, sessionId.toLong())) sendOnChannel(MSG_STOP, Protobuf.varintField(1, sessionId.toLong()))
if (focusRequested) sendControl(MSG_FOCUS_REQUEST, Protobuf.varintField(1, FOCUS_RELEASE.toLong())) if (release && focusRequested) sendControl(MSG_FOCUS_REQUEST, Protobuf.varintField(1, FOCUS_RELEASE.toLong()))
} }
focusRequested = false if (release) focusRequested = false
focusGranted = false focusGranted = false
aac?.release() aac?.release()
aac = null aac = null
GearslipLog.i("$tag: -> Stop, AudioFocusRequest(RELEASE) after $chunks chunks / $bytes bytes") GearslipLog.i("$tag: -> Stop${if (release) ", AudioFocusRequest(RELEASE)" else ""} after $chunks chunks / $bytes bytes")
chunks = 0 chunks = 0
bytes = 0 bytes = 0
// A new stream starts with the car's buffer empty again: earn the lead back if it's needed. // A new stream starts with the car's buffer empty again: earn the lead back if it's needed.
@@ -137,6 +137,59 @@ object CarAudio {
internal fun deliver(pcm: ByteArray, size: Int, peak: Float) { internal fun deliver(pcm: ByteArray, size: Int, peak: Float) {
_level.value = peak _level.value = peak
link?.write(pcm, size) val voice = synchronized(voiceLock) { spoken }
if (voice == null) link?.write(pcm, size) else link?.write(mixVoice(pcm, size), size)
} }
private val voiceLock = Any()
private var spoken: ByteArray? = null
private var spokenAt = 0
private var spokenDone: java.util.concurrent.CountDownLatch? = null
/**
* Mixes a spoken turn into the music from the next chunk on, with the music turned down
* under it, and waits until it has all gone out. [pcm] is 16-bit, in the sink's own format.
* False when no music is flowing to carry it, so the caller sends it some other way.
*/
fun speakOverMusic(pcm: ByteArray): Boolean {
if (_capture.value != Capture.ON || (link as? AudioLink)?.streaming != true) return false
val done = java.util.concurrent.CountDownLatch(1)
synchronized(voiceLock) {
spokenDone?.countDown()
spoken = pcm
spokenAt = 0
spokenDone = done
}
val ms = pcm.size * 1000L / (2 * channels * sampleRate)
if (!done.await(ms + VOICE_SLACK_MS, java.util.concurrent.TimeUnit.MILLISECONDS)) {
// Capture stopped mid-turn; the rest of it is dropped rather than played late.
synchronized(voiceLock) { if (spokenDone === done) spoken = null }
}
return true
}
private fun mixVoice(pcm: ByteArray, size: Int): ByteArray = synchronized(voiceLock) {
val voice = spoken ?: return pcm
val out = pcm.copyOf(size)
var i = 0
while (i + 1 < size) {
val music = (out[i].toInt() and 0xFF) or (out[i + 1].toInt() shl 8)
val word = if (spokenAt + 1 < voice.size) (voice[spokenAt].toInt() and 0xFF) or (voice[spokenAt + 1].toInt() shl 8) else 0
val mixed = (music * DUCK_PERCENT / 100 + word).coerceIn(Short.MIN_VALUE.toInt(), Short.MAX_VALUE.toInt())
out[i] = mixed.toByte()
out[i + 1] = (mixed shr 8).toByte()
i += 2
spokenAt += 2
}
if (spokenAt >= voice.size) {
spoken = null
spokenDone?.countDown()
spokenDone = null
}
out
}
/** How loud the music stays under a spoken turn. */
private const val DUCK_PERCENT = 30
private const val VOICE_SLACK_MS = 2_000L
} }
@@ -0,0 +1,65 @@
package app.seb3thehacker.gearslip.car
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import app.seb3thehacker.gearslip.R
import app.seb3thehacker.gearslip.mirror.PhoneApp
import app.seb3thehacker.gearslip.mirror.PhoneApps
import androidx.core.graphics.drawable.toBitmap
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* Launcher mode's app drawer: every app on the device, A to Z, each opened full screen. Gearslip
* comes first, for its own settings, which the car UI only partly covers.
*/
@Composable
fun AllAppsScreen() {
val context = LocalContext.current
val apps by produceState(initialValue = PhoneApps.cached() ?: emptyList(), context) {
value = PhoneApps.cached() ?: withContext(Dispatchers.IO) { PhoneApps.installed(context).also { PhoneApps.warm(context) } }
}
val gearslip = remember(context) {
val label = context.packageManager.getApplicationLabel(context.applicationInfo).toString()
val icon = runCatching { context.packageManager.getApplicationIcon(context.packageName).toBitmap(ICON_PX, ICON_PX).asImageBitmap() }
PhoneApp(context.packageName, label, icon.getOrNull())
}
Column(Modifier.fillMaxSize().padding(start = 20.dp, end = 20.dp, top = 12.dp)) {
ScreenHeader(stringResource(R.string.app_all_apps))
Spacer(Modifier.height(12.dp))
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 108.dp),
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item {
PhoneAppTile(gearslip) { LauncherMode.launch(context, context.packageName) }
}
items(apps.sortedBy { it.label.lowercase() }) { app ->
PhoneAppTile(app) { LauncherMode.launch(context, app.packageName) }
}
}
}
}
private const val ICON_PX = 128
@@ -61,7 +61,7 @@ object AssistantCommands {
private suspend fun allEntries(context: Context): List<Entry> { private suspend fun allEntries(context: Context): List<Entry> {
val vehicleData = CarSettings.experimentalFeaturesEnabled.value && CarSettings.carSensorsEnabled.value val vehicleData = CarSettings.experimentalFeaturesEnabled.value && CarSettings.carSensorsEnabled.value
val builtIn = BuiltInApps.entries.filter { it.builtIn !== BuiltInApps.VehicleData || vehicleData } val builtIn = BuiltInApps.entries(LauncherMode.active.value, LauncherMode.canCall(context)).filter { it.builtIn !== BuiltInApps.VehicleData || vehicleData }
return builtIn + (LauncherCache.entries ?: withContext(Dispatchers.IO) { LauncherCache.load(context) }) return builtIn + (LauncherCache.entries ?: withContext(Dispatchers.IO) { LauncherCache.load(context) })
} }
@@ -0,0 +1,203 @@
package app.seb3thehacker.gearslip.car
import androidx.annotation.MainThread
import androidx.car.app.model.Action
import androidx.car.app.model.Alert
import androidx.car.app.model.AlertCallback
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.tween
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.fillMaxWidth
import androidx.compose.foundation.layout.height
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.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
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.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import app.seb3thehacker.gearslip.GearslipLog
import app.seb3thehacker.gearslip.R
import app.seb3thehacker.gearslip.car.theme.GsColors
import app.seb3thehacker.gearslip.car.theme.GsIconBox
import app.seb3thehacker.gearslip.host.cancel
import app.seb3thehacker.gearslip.host.click
import app.seb3thehacker.gearslip.host.color
import app.seb3thehacker.gearslip.host.dismissed
import app.seb3thehacker.gearslip.host.text
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* The one alert on the car screen, from whichever hosted app sent it: Waze asking whether a
* hazard is still there, say. As in Android Auto, a newer alert replaces the one showing, and it
* shows over any screen, since a question about the road matters while music is up too.
*/
@MainThread
internal object CarAlerts {
class Shown(val owner: Any, val alert: Alert)
private val current = MutableStateFlow<Shown?>(null)
val shown: StateFlow<Shown?> = current.asStateFlow()
fun show(owner: Any, alert: Alert) {
val old = current.value
current.value = Shown(owner, alert)
GearslipLog.i("host: alert ${alert.id} \"${alert.title.text()}\" with ${alert.actions.size} buttons")
if (old != null && !(old.owner === owner && old.alert.id == alert.id)) old.alert.callbackDelegate.dismissed()
}
/** The app took its alert down; a no-op unless that alert is the one showing. */
fun dismiss(owner: Any, id: Int) {
val shown = current.value ?: return
if (shown.owner !== owner || shown.alert.id != id) return
current.value = null
shown.alert.callbackDelegate.dismissed()
}
/** The driver tapped one of its buttons: the app gets the tap, and the alert goes. */
fun tapped(shown: Shown, action: Action) {
action.onClickDelegate.click("alert button")
if (current.value !== shown) return
current.value = null
shown.alert.callbackDelegate.dismissed()
}
fun cancelled(shown: Shown, reason: Int) {
if (current.value !== shown) return
current.value = null
shown.alert.callbackDelegate.cancel(reason)
}
/** The app disconnected: its alert goes with it, and there is no one left to tell. */
fun clear(owner: Any) {
if (current.value?.owner === owner) current.value = null
}
}
@Composable
internal fun CarAlertOverlay(modifier: Modifier = Modifier) {
val current by CarAlerts.shown.collectAsState()
val shown = current ?: return
val alert = shown.alert
val timed = alert.durationMillis in 1 until Alert.DURATION_SHOW_INDEFINITELY
// What's left of the alert's time, drawn as the bar along its foot.
val left = remember(shown) { Animatable(1f) }
LaunchedEffect(shown) {
if (!timed) return@LaunchedEffect
left.animateTo(0f, tween(alert.durationMillis.coerceAtMost(Int.MAX_VALUE.toLong()).toInt(), easing = LinearEasing))
CarAlerts.cancelled(shown, AlertCallback.REASON_TIMEOUT)
}
val scheme = MaterialTheme.colorScheme
val dark = scheme.surface.luminanceIsDark()
Surface(
modifier
.padding(horizontal = 24.dp, vertical = 16.dp)
.widthIn(max = 640.dp)
.fillMaxWidth()
.semantics { liveRegion = LiveRegionMode.Polite },
shape = RoundedCornerShape(24.dp),
color = scheme.surfaceContainerHigh,
shadowElevation = 8.dp,
) {
Column {
Row(Modifier.padding(start = 20.dp, end = 12.dp, top = 16.dp), verticalAlignment = Alignment.CenterVertically) {
if (alert.icon != null) {
CarGlyph(alert.icon, Modifier.size(40.dp))
Spacer(Modifier.width(16.dp))
}
Column(Modifier.weight(1f)) {
Text(
alert.title.text(),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
alert.subtitle?.text()?.takeIf { it.isNotEmpty() }?.let {
Text(
it,
style = MaterialTheme.typography.titleMedium,
color = scheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
// With no timer and no buttons, nothing else would ever take it away.
if (!timed || alert.actions.isEmpty()) {
GsIconBox(
onClick = { CarAlerts.cancelled(shown, AlertCallback.REASON_USER_ACTION) },
modifier = Modifier.size(56.dp),
colors = GsColors(Color.Transparent, scheme.onSurface),
shape = CircleShape,
) {
Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.close))
}
}
}
if (alert.actions.isNotEmpty()) {
Row(
Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
alert.actions.take(2).forEach { action ->
val custom = action.backgroundColor?.type?.let { it != androidx.car.app.model.CarColor.TYPE_DEFAULT } == true
GsIconBox(
onClick = { CarAlerts.tapped(shown, action) },
modifier = Modifier.weight(1f).height(64.dp),
colors = GsColors(
action.backgroundColor.color(dark, scheme.surfaceContainerHighest),
if (custom) Color.White else scheme.onSurface,
),
enabled = action.isEnabled,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (action.icon != null) {
CarGlyph(action.icon, Modifier.size(28.dp))
Spacer(Modifier.width(8.dp))
}
Text(action.title.text(), style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
}
} else {
Spacer(Modifier.height(16.dp))
}
if (timed) {
Box(Modifier.fillMaxWidth().height(4.dp).background(scheme.surfaceContainerHighest)) {
Box(Modifier.fillMaxWidth(left.value).height(4.dp).clip(RoundedCornerShape(2.dp)).background(scheme.primary))
}
}
}
}
}
@@ -3,6 +3,7 @@ package app.seb3thehacker.gearslip.car
import androidx.annotation.StringRes import androidx.annotation.StringRes
import app.seb3thehacker.gearslip.R import app.seb3thehacker.gearslip.R
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.List
import androidx.compose.material.icons.filled.Call import androidx.compose.material.icons.filled.Call
import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.filled.Share
@@ -38,7 +39,16 @@ object CarApps {
CarApp("settings", "Settings", R.string.settings_title, Icons.Filled.Settings) { CarApp("settings", "Settings", R.string.settings_title, Icons.Filled.Settings) {
CarSettingsScreen() CarSettingsScreen()
}, },
CarApp("allapps", "All apps", R.string.app_all_apps, Icons.Filled.List) {
AllAppsScreen()
},
) )
fun find(id: String): CarApp? = all.firstOrNull { it.id == id } fun find(id: String): CarApp? = all.firstOrNull { it.id == id }
/** The screens [BuiltInApps.entries] offers here, so a choice list never names a hidden one. */
fun shown(launcher: Boolean, canCall: Boolean): List<CarApp> {
val ids = BuiltInApps.entries(launcher, canCall).mapNotNull { (it.builtIn?.screen as? CarScreen.App)?.id }.toSet()
return all.filter { it.id in ids }
}
} }
@@ -40,6 +40,11 @@ import java.security.MessageDigest
* phone's own Bluetooth address (apps get a placeholder), and confirming the pairing code without * phone's own Bluetooth address (apps get a placeholder), and confirming the pairing code without
* asking. So a car may turn the request down, and the phone always shows its pairing dialog. The * asking. So a car may turn the request down, and the phone always shows its pairing dialog. The
* channel stays closed unless the driver taps Pair: every new channel is a risk to cars that work. * channel stays closed unless the driver taps Pair: every new channel is a risk to cars that work.
*
* The one exception is a driver who typed the phone's real address into Settings. A VW MIB2 drops
* an already paired phone's Bluetooth when it connects over USB, because nothing tells it the two
* are the same phone (issue #28). With the address, Gearslip opens the channel on every connection
* and sends the same request, which is how Android Auto says so.
*/ */
object CarBluetooth { object CarBluetooth {
@@ -63,13 +68,15 @@ object CarBluetooth {
@Volatile private var bondStarted = false @Volatile private var bondStarted = false
/** From the Pair tap until it succeeds or fails, whether or not the card still shows. */ /** From the Pair tap until it succeeds or fails, whether or not the card still shows. */
@Volatile private var pairing = false @Volatile private var pairing = false
/** Already paired: the channel is open only to tell the car which phone this is. */
@Volatile private var announcing = false
private var receiver: BroadcastReceiver? = null private var receiver: BroadcastReceiver? = null
/** The car's Bluetooth address, when it named one in its service list. */ /** The car's Bluetooth address, when it named one in its service list. */
val carAddress: String? get() = car?.service?.carAddress val carAddress: String? get() = car?.service?.carAddress
/** The car's Bluetooth channel once the driver has asked to pair, -1 otherwise. */ /** The car's Bluetooth channel once the driver has asked to pair, -1 otherwise. */
val channelId: Int get() = car?.takeIf { pairing }?.service?.serviceId ?: -1 val channelId: Int get() = car?.takeIf { pairing || announcing }?.service?.serviceId ?: -1
fun init(context: Context) { fun init(context: Context) {
appContext = context.applicationContext appContext = context.applicationContext
@@ -83,9 +90,15 @@ object CarBluetooth {
) { ) {
car = Car(service, open, send) car = Car(service, open, send)
bondStarted = false bondStarted = false
announcing = false
log.i("the car offers Bluetooth pairing, methods=${service.pairingMethods}") log.i("the car offers Bluetooth pairing, methods=${service.pairingMethods}")
val device = device() ?: return val device = device() ?: return
when { when {
device.bondState == BluetoothDevice.BOND_BONDED && typedAddress() != null -> {
log.i("already paired with this car - telling it which phone this is")
announcing = true
open()
}
device.bondState == BluetoothDevice.BOND_BONDED -> log.i("already paired with this car") 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") declined(service.carAddress) -> log.i("not paired, and the driver said not now for this car")
else -> _prompt.value = Prompt.ASK else -> _prompt.value = Prompt.ASK
@@ -95,6 +108,7 @@ object CarBluetooth {
fun onCarDisconnected() { fun onCarDisconnected() {
car = null car = null
pairing = false pairing = false
announcing = false
main.post { main.post {
stopWatching() stopWatching()
_prompt.value = Prompt.NONE _prompt.value = Prompt.NONE
@@ -139,7 +153,8 @@ object CarBluetooth {
val status = Protobuf.readInt32Field(body, 1) ?: 0 val status = Protobuf.readInt32Field(body, 1) ?: 0
log.i("<- ChannelOpenResponse: status=$status") log.i("<- ChannelOpenResponse: status=$status")
if (status != 0) { if (status != 0) {
main.post { startBond("the car wouldn't open its Bluetooth channel") } if (announcing) log.i("the car wouldn't open its Bluetooth channel")
else main.post { startBond("the car wouldn't open its Bluetooth channel") }
return return
} }
val method = c.service.pairingMethods.firstOrNull { it == METHOD_NUMERIC_COMPARISON } val method = c.service.pairingMethods.firstOrNull { it == METHOD_NUMERIC_COMPARISON }
@@ -153,6 +168,7 @@ object CarBluetooth {
MSG_PAIRING_RESPONSE -> { MSG_PAIRING_RESPONSE -> {
val fields = Wire.fields(body) val fields = Wire.fields(body)
log.i("<- PairingResponse status=${Wire.varint(fields, 1)} already_paired=${Wire.varint(fields, 2) == 1L}") log.i("<- PairingResponse status=${Wire.varint(fields, 1)} already_paired=${Wire.varint(fields, 2) == 1L}")
if (!pairing) return
// Some cars start pairing themselves once they answer; give them a moment first. // Some cars start pairing themselves once they answer; give them a moment first.
main.postDelayed({ startBond("the car answered") }, CAR_BOND_WAIT_MS) main.postDelayed({ startBond("the car answered") }, CAR_BOND_WAIT_MS)
} }
@@ -229,12 +245,30 @@ object CarBluetooth {
return adapter.getRemoteDevice(address) return adapter.getRemoteDevice(address)
} }
/** Apps get 02:00:00:00:00:00 here, not the real address. Sent anyway, as the field is required. */ /**
* Apps get 02:00:00:00:00:00 here, not the real address, so the one the driver typed in wins.
* Sent either way, as the field is required. Never logged: it identifies the phone.
*/
@SuppressLint("MissingPermission", "HardwareIds") @SuppressLint("MissingPermission", "HardwareIds")
private fun phoneAddress(): String = private fun phoneAddress(): String =
runCatching { appContext?.getSystemService(BluetoothManager::class.java)?.adapter?.address }.getOrNull() typedAddress()
?: runCatching { appContext?.getSystemService(BluetoothManager::class.java)?.adapter?.address }.getOrNull()
?: "02:00:00:00:00:00" ?: "02:00:00:00:00:00"
private fun typedAddress(): String? = appContext?.let { typedAddress(it) }
/** The phone's own address as the driver typed it in Settings, or null. */
fun typedAddress(context: Context): String? =
AppSettings.getString(context, KEY_PHONE_ADDRESS, "").takeIf { BluetoothAdapter.checkBluetoothAddress(it) }
/** Saves [address] after tidying it up; false when it isn't one. Empty clears it. */
fun setTypedAddress(context: Context, address: String): Boolean {
val tidy = address.trim().uppercase().replace('-', ':')
if (tidy.isNotEmpty() && !BluetoothAdapter.checkBluetoothAddress(tidy)) return false
AppSettings.putString(context, KEY_PHONE_ADDRESS, tidy)
return true
}
private fun declined(address: String): Boolean { private fun declined(address: String): Boolean {
val context = appContext ?: return false val context = appContext ?: return false
return hash(address) in AppSettings.getString(context, KEY_DECLINED, "").split(",") return hash(address) in AppSettings.getString(context, KEY_DECLINED, "").split(",")
@@ -246,6 +280,7 @@ object CarBluetooth {
.take(8).joinToString("") { "%02x".format(it) } .take(8).joinToString("") { "%02x".format(it) }
private const val KEY_DECLINED = "bluetooth_pair_declined" private const val KEY_DECLINED = "bluetooth_pair_declined"
private const val KEY_PHONE_ADDRESS = "phone_bluetooth_address"
private const val CAR_REPLY_WAIT_MS = 5_000L private const val CAR_REPLY_WAIT_MS = 5_000L
private const val CAR_BOND_WAIT_MS = 3_000L private const val CAR_BOND_WAIT_MS = 3_000L
private const val GIVE_UP_MS = 90_000L private const val GIVE_UP_MS = 90_000L
@@ -37,6 +37,9 @@ object CarEnvironment {
private val frameFlow = MutableStateFlow(Frame(800, 480, 160)) private val frameFlow = MutableStateFlow(Frame(800, 480, 160))
val frame: StateFlow<Frame> = frameFlow val frame: StateFlow<Frame> = frameFlow
/** The density the car reported; [frame] carries the one the car UI and its apps draw at. */
private var carDensityDpi = 160
val phoneDark: StateFlow<Boolean> = phoneDarkFlow val phoneDark: StateFlow<Boolean> = phoneDarkFlow
val darkOutside: StateFlow<Boolean> = darkOutsideFlow val darkOutside: StateFlow<Boolean> = darkOutsideFlow
val battery: StateFlow<Battery> = batteryFlow val battery: StateFlow<Battery> = batteryFlow
@@ -95,8 +98,18 @@ object CarEnvironment {
} }
fun setDisplay(width: Int, height: Int, densityDpi: Int) { fun setDisplay(width: Int, height: Int, densityDpi: Int) {
displayFlow.value = "${width}x$height @ ${densityDpi}dpi" carDensityDpi = densityDpi
frameFlow.value = Frame(width, height, densityDpi) val dpi = CarSettings.effectiveDensity(densityDpi)
displayFlow.value = "${width}x$height @ ${dpi}dpi"
frameFlow.value = Frame(width, height, dpi)
}
/**
* Takes up the density setting. It's read only as a session starts, so the display and the
* apps on it can't disagree mid-drive; a session with no car behind it, the preview, asks here.
*/
fun applyDensity() {
frameFlow.value = frameFlow.value.copy(densityDpi = CarSettings.effectiveDensity(carDensityDpi))
} }
fun start(context: Context) { fun start(context: Context) {
@@ -185,10 +185,13 @@ private fun tilesFor(
internal fun rememberAllEntries(): List<Entry> { internal fun rememberAllEntries(): List<Entry> {
val context = LocalContext.current val context = LocalContext.current
val installed by LauncherCache.state.collectAsState() val installed by LauncherCache.state.collectAsState()
val launcher by LauncherMode.active.collectAsState()
LaunchedEffect(context) { LaunchedEffect(context) {
if (LauncherCache.entries == null) withContext(Dispatchers.IO) { LauncherCache.load(context) } if (LauncherCache.entries == null) withContext(Dispatchers.IO) { LauncherCache.load(context) }
} }
return remember(installed) { BuiltInApps.entries + installed.orEmpty() } return remember(installed, launcher) {
BuiltInApps.entries(launcher, LauncherMode.canCall(context)) + installed.orEmpty()
}
} }
/** The launcher's groups, in the order they're laid out. */ /** The launcher's groups, in the order they're laid out. */
@@ -224,9 +227,10 @@ private fun groupOf(entry: Entry, mediaPackages: Set<String>): AppGroup {
* The launcher's tiles in the order they're drawn: grouped by [AppGroup], alphabetical inside a * The launcher's tiles in the order they're drawn: grouped by [AppGroup], alphabetical inside a
* group with Settings last of all, then with the driver's own moves from [custom] laid over it. * group with Settings last of all, then with the driver's own moves from [custom] laid over it.
* Moved apps only trade places among themselves, so an app installed later still lands in its group. * Moved apps only trade places among themselves, so an app installed later still lands in its group.
* [hidden] leaves tiles out of the drawer only: a pinned app stays on the nav bar.
*/ */
internal fun launcherOrder(all: List<Entry>, showVehicleData: Boolean, custom: List<String>): List<Entry> { internal fun launcherOrder(all: List<Entry>, showVehicleData: Boolean, custom: List<String>, hidden: Set<String>): List<Entry> {
val shown = all.filter { it.builtIn !== BuiltInApps.VehicleData || showVehicleData } val shown = all.filter { (it.builtIn !== BuiltInApps.VehicleData || showVehicleData) && it.componentId !in hidden }
val mediaPackages = shown.mapNotNull { it.media?.component?.packageName }.toSet() val mediaPackages = shown.mapNotNull { it.media?.component?.packageName }.toSet()
val grouped = shown.sortedWith( val grouped = shown.sortedWith(
compareBy<Entry>({ groupOf(it, mediaPackages) }, { it.builtIn === BuiltInApps.Settings }, { it.label.lowercase() }), compareBy<Entry>({ groupOf(it, mediaPackages) }, { it.builtIn === BuiltInApps.Settings }, { it.label.lowercase() }),
@@ -240,7 +244,7 @@ internal fun launcherOrder(all: List<Entry>, showVehicleData: Boolean, custom: L
/** An app by the id a pin or the running list knows it by, built-in or installed. */ /** An app by the id a pin or the running list knows it by, built-in or installed. */
internal fun findEntry(id: String): Entry? = internal fun findEntry(id: String): Entry? =
BuiltInApps.entries.find { it.componentId == id } ?: LauncherCache.entries?.find { it.componentId == id } BuiltInApps.everyEntry.find { it.componentId == id } ?: LauncherCache.entries?.find { it.componentId == id }
/** Rescans the car and media apps now, replacing the cached list; the launcher keeps showing the old one meanwhile. */ /** Rescans the car and media apps now, replacing the cached list; the launcher keeps showing the old one meanwhile. */
fun warmLauncherApps(context: Context) { LauncherCache.load(context, force = true) } fun warmLauncherApps(context: Context) { LauncherCache.load(context, force = true) }
@@ -290,7 +294,8 @@ fun CarLauncher() {
} }
val appOrder by CarSettings.appOrder.collectAsState() val appOrder by CarSettings.appOrder.collectAsState()
val tiles = launcherOrder(all, showVehicleData, appOrder).map(::tileOf) val hidden by CarSettings.hiddenApps.collectAsState()
val tiles = launcherOrder(all, showVehicleData, appOrder, hidden).map(::tileOf)
LazyVerticalGrid( LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 108.dp), columns = GridCells.Adaptive(minSize = 108.dp),
@@ -75,6 +75,7 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.vectorResource import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import app.seb3thehacker.gearslip.R import app.seb3thehacker.gearslip.R
import app.seb3thehacker.gearslip.notify.PhoneNavigation
import app.seb3thehacker.gearslip.car.theme.GsColors import app.seb3thehacker.gearslip.car.theme.GsColors
import app.seb3thehacker.gearslip.car.theme.withTintedSurfaces import app.seb3thehacker.gearslip.car.theme.withTintedSurfaces
import app.seb3thehacker.gearslip.car.theme.GsIconBox import app.seb3thehacker.gearslip.car.theme.GsIconBox
@@ -197,12 +198,13 @@ internal fun CarNavBar(navigator: CarNavigator) {
// Next turn first, off the map only; the player shares the space when it fits, // Next turn first, off the map only; the player shares the space when it fits,
// and gives way to the turn when it doesn't. // and gives way to the turn when it doesn't.
// A call takes the player's place until it ends. // A call takes the player's place until it ends.
val turn = rememberNextTurn().takeIf { navigator.current != CarScreen.Home } // A turn from the phone has no map in the car to show it, so it shows on Home too.
val turn = rememberNextTurn()?.takeIf { it.fromPhone || navigator.current != CarScreen.Home }
val call = CarCalls.state.collectAsState().value as? CallUiState.Active val call = CarCalls.state.collectAsState().value as? CallUiState.Active
BoxWithConstraints(Modifier.weight(1f).padding(horizontal = 16.dp), contentAlignment = Alignment.Center) { BoxWithConstraints(Modifier.weight(1f).padding(horizontal = 16.dp), contentAlignment = Alignment.Center) {
val roomForBoth = maxWidth >= TURN_AND_PLAYER_MIN_WIDTH val roomForBoth = maxWidth >= TURN_AND_PLAYER_MIN_WIDTH
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
if (call == null || roomForBoth) turn?.let { NavTurnPill(it) { navigator.home() } } if (call == null || roomForBoth) turn?.let { NavTurnPill(it) { if (!it.fromPhone) navigator.home() } }
if (call != null) { if (call != null) {
NavCallPill(call, Modifier.weight(1f, fill = false)) NavCallPill(call, Modifier.weight(1f, fill = false))
} else if (turn == null || roomForBoth) { } else if (turn == null || roomForBoth) {
@@ -416,8 +418,8 @@ private const val TITLE_SHOWN_MS = 3_000L
/** Below this, the nav bar's middle only has room for the turn, not the player beside it. */ /** Below this, the nav bar's middle only has room for the turn, not the player beside it. */
private val TURN_AND_PLAYER_MIN_WIDTH = 520.dp private val TURN_AND_PLAYER_MIN_WIDTH = 520.dp
/** The next manoeuvre, as the running nav app last described it. */ /** The next manoeuvre, as the running nav app last described it, or [fromPhone] a map guiding on the phone. */
private class NextTurn(val icon: CarIcon?, val distance: String, val road: String) private class NextTurn(val icon: CarIcon?, val distance: String, val road: String, val fromPhone: Boolean = false)
/** /**
* Read straight off the nav app's own template: a navigation app keeps sending its routing card * Read straight off the nav app's own template: a navigation app keeps sending its routing card
@@ -429,10 +431,10 @@ private fun rememberNextTurn(): NextTurn? {
val template by CarServices.nav.template.collectAsState() val template by CarServices.nav.template.collectAsState()
// Read so a change of units redraws the distance straight away, not at the next update. // Read so a change of units redraws the distance straight away, not at the next update.
CarSettings.units.collectAsState().value CarSettings.units.collectAsState().value
if (status.phase != CarAppConnection.Phase.RUNNING) return null val phone by PhoneNavigation.turn.collectAsState()
val routing = (template as? NavigationTemplate)?.navigationInfo as? RoutingInfo ?: return null val routing = ((template as? NavigationTemplate)?.navigationInfo as? RoutingInfo)
if (routing.isLoading) return null ?.takeIf { status.phase == CarAppConnection.Phase.RUNNING }
val step = routing.currentStep ?: return null val step = routing?.takeIf { !it.isLoading }?.currentStep ?: return phone?.let { phoneTurn(it) }
return NextTurn( return NextTurn(
icon = step.maneuver?.icon, icon = step.maneuver?.icon,
distance = routing.currentDistance.display(), distance = routing.currentDistance.display(),
@@ -440,6 +442,15 @@ private fun rememberNextTurn(): NextTurn? {
) )
} }
/** Google Maps' or Waze's own arrow, and the distance as the app wrote it. */
@Composable
private fun phoneTurn(turn: PhoneNavigation.Turn): NextTurn {
val icon = remember(turn.arrow) {
turn.arrow?.let { CarIcon.Builder(androidx.core.graphics.drawable.IconCompat.createWithBitmap(it)).build() }
}
return NextTurn(icon, turn.distance?.display() ?: turn.distanceText, turn.road, fromPhone = true)
}
/** The next turn in the nav bar: arrow, how far, onto what. Tapping goes back to the map. */ /** The next turn in the nav bar: arrow, how far, onto what. Tapping goes back to the map. */
@Composable @Composable
private fun NavTurnPill(turn: NextTurn, onClick: () -> Unit) { private fun NavTurnPill(turn: NextTurn, onClick: () -> Unit) {
@@ -2,6 +2,7 @@ package app.seb3thehacker.gearslip.car
import android.content.ComponentName import android.content.ComponentName
import android.content.Context import android.content.Context
import android.media.session.MediaController
import android.media.session.MediaSessionManager import android.media.session.MediaSessionManager
import android.media.session.PlaybackState import android.media.session.PlaybackState
import app.seb3thehacker.gearslip.audio.CarAudio import app.seb3thehacker.gearslip.audio.CarAudio
@@ -254,7 +255,41 @@ object CarServices {
* just the one Gearslip opened: anything the car was playing was going through the car. * just the one Gearslip opened: anything the car was playing was going through the car.
*/ */
fun pausePhoneMedia(why: String = "the car left") { fun pausePhoneMedia(why: String = "the car left") {
val ctx = context ?: return pausedForNow = null
pausedOwnForNow = false
pause(why)
}
/** What [pausePhoneMediaForNow] paused, for [resumePhoneMedia] to play again. */
@Volatile private var pausedForNow: List<MediaController>? = null
/** Without notification access, whether it paused the app Gearslip drives itself. */
@Volatile private var pausedOwnForNow = false
/** The car wants the speakers for a moment: pause, and remember what to play after. */
fun pausePhoneMediaForNow() {
pausedOwnForNow = false
pausedForNow = pause("the car needs the speakers for a moment")
}
/** The car handed the speakers back: play what it paused, unless the driver has since. */
fun resumePhoneMedia() {
val paused = pausedForNow ?: return
pausedForNow = null
if (pausedOwnForNow) {
pausedOwnForNow = false
mediaOrNull()?.let { if (!it.now.value.playing) it.togglePlay() }
return
}
val waiting = paused.filter { it.playbackState?.state == PlaybackState.STATE_PAUSED }
waiting.forEach { runCatching { it.transportControls.play() } }
if (waiting.isNotEmpty()) {
app.seb3thehacker.gearslip.GearslipLog.i("media: playing ${waiting.joinToString { it.packageName }} again: the car gave the speakers back")
}
}
/** What it paused. Without notification access it can only reach the app Gearslip drives itself. */
private fun pause(why: String): List<MediaController> {
val ctx = context ?: return emptyList()
val manager = ctx.getSystemService(MediaSessionManager::class.java) val manager = ctx.getSystemService(MediaSessionManager::class.java)
val listener = ComponentName(ctx, GearslipNotificationListener::class.java) val listener = ComponentName(ctx, GearslipNotificationListener::class.java)
val playing = try { val playing = try {
@@ -264,13 +299,19 @@ object CarServices {
} }
if (playing == null) { if (playing == null) {
// No notification access: the app Gearslip itself is driving is all it can reach. // No notification access: the app Gearslip itself is driving is all it can reach.
mediaOrNull()?.let { if (it.now.value.playing) it.togglePlay() } mediaOrNull()?.let {
return if (it.now.value.playing) {
it.togglePlay()
pausedOwnForNow = true
}
}
return emptyList()
} }
playing.forEach { runCatching { it.transportControls.pause() } } playing.forEach { runCatching { it.transportControls.pause() } }
if (playing.isNotEmpty()) { if (playing.isNotEmpty()) {
app.seb3thehacker.gearslip.GearslipLog.i("media: paused ${playing.joinToString { it.packageName }}: $why") app.seb3thehacker.gearslip.GearslipLog.i("media: paused ${playing.joinToString { it.packageName }}: $why")
} }
return playing
} }
/** The car went away: nothing should keep running for a screen that no longer exists. */ /** The car went away: nothing should keep running for a screen that no longer exists. */
@@ -53,10 +53,12 @@ enum class NavBarMode { SHOW, HIDE_NAVIGATING, HIDE }
object CarSettings { object CarSettings {
private const val KEY_SCALE = "car_scale" private const val KEY_SCALE = "car_scale"
private const val KEY_DENSITY = "car_density"
private const val KEY_OPEN = "car_open_on_connect" private const val KEY_OPEN = "car_open_on_connect"
private const val KEY_NIGHT = "car_night_mode" private const val KEY_NIGHT = "car_night_mode"
private const val KEY_PIPE_AUDIO = "car_pipe_audio" private const val KEY_PIPE_AUDIO = "car_pipe_audio"
private const val KEY_SPEAK_DIRECTIONS = "car_speak_directions" 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" private const val KEY_BLUETOOTH_WARNING = "car_bluetooth_warning"
private const val KEY_LOCATION_WARNING = "car_location_warning" private const val KEY_LOCATION_WARNING = "car_location_warning"
private const val KEY_AUDIO_CHOICE_ASKED = "car_audio_choice_asked" private const val KEY_AUDIO_CHOICE_ASKED = "car_audio_choice_asked"
@@ -71,6 +73,7 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
private const val KEY_DARK_STYLE = "car_dark_style" private const val KEY_DARK_STYLE = "car_dark_style"
private const val KEY_PINNED = "car_pinned_apps" private const val KEY_PINNED = "car_pinned_apps"
private const val KEY_APP_ORDER = "car_app_order" private const val KEY_APP_ORDER = "car_app_order"
private const val KEY_HIDDEN_APPS = "car_hidden_apps"
private const val KEY_EXPERIMENTAL_FEATURES = "car_experimental_features" private const val KEY_EXPERIMENTAL_FEATURES = "car_experimental_features"
private const val KEY_SYSTEM_SPEECH = "car_system_speech" private const val KEY_SYSTEM_SPEECH = "car_system_speech"
private const val KEY_CAR_SENSORS = "car_sensors_enabled" private const val KEY_CAR_SENSORS = "car_sensors_enabled"
@@ -97,10 +100,12 @@ private const val KEY_CLUSTER_VIDEO = "cluster_video_enabled"
private lateinit var app: Context private lateinit var app: Context
private val scaleFlow = MutableStateFlow(1f) private val scaleFlow = MutableStateFlow(1f)
private val carDensityFlow = MutableStateFlow(DENSITY_FROM_CAR)
private val openFlow = MutableStateFlow(HOME) private val openFlow = MutableStateFlow(HOME)
private val nightFlow = MutableStateFlow(NightMode.AUTO) private val nightFlow = MutableStateFlow(NightMode.AUTO)
private val pipeAudioFlow = MutableStateFlow(true) private val pipeAudioFlow = MutableStateFlow(true)
private val speakDirectionsFlow = MutableStateFlow(true) private val speakDirectionsFlow = MutableStateFlow(true)
private val directionsOverMusicFlow = MutableStateFlow(false)
private val bluetoothWarningFlow = MutableStateFlow(true) private val bluetoothWarningFlow = MutableStateFlow(true)
private val locationWarningFlow = MutableStateFlow(true) private val locationWarningFlow = MutableStateFlow(true)
private val lastNavFlow = MutableStateFlow<String?>(null) private val lastNavFlow = MutableStateFlow<String?>(null)
@@ -121,6 +126,7 @@ private const val KEY_CLUSTER_VIDEO = "cluster_video_enabled"
val uiTheme: StateFlow<String> = uiThemeFlow val uiTheme: StateFlow<String> = uiThemeFlow
private val pinnedFlow = MutableStateFlow<Set<String>>(emptySet()) private val pinnedFlow = MutableStateFlow<Set<String>>(emptySet())
private val appOrderFlow = MutableStateFlow<List<String>>(emptyList()) private val appOrderFlow = MutableStateFlow<List<String>>(emptyList())
private val hiddenAppsFlow = MutableStateFlow<Set<String>>(emptySet())
private val experimentalFeaturesFlow = MutableStateFlow(false) private val experimentalFeaturesFlow = MutableStateFlow(false)
private val systemSpeechFlow = MutableStateFlow(false) private val systemSpeechFlow = MutableStateFlow(false)
private val carSensorsFlow = MutableStateFlow(false) private val carSensorsFlow = MutableStateFlow(false)
@@ -218,6 +224,9 @@ private const val KEY_CLUSTER_VIDEO = "cluster_video_enabled"
/** The launcher's order once the driver has moved a tile; empty keeps the grouped default. */ /** The launcher's order once the driver has moved a tile; empty keeps the grouped default. */
val appOrder: StateFlow<List<String>> = appOrderFlow val appOrder: StateFlow<List<String>> = appOrderFlow
/** The tiles left out of the car's app drawer, by [Entry.componentId]. */
val hiddenApps: StateFlow<Set<String>> = hiddenAppsFlow
/** Flattened component names of the map and media apps used last, brought back on connect. */ /** Flattened component names of the map and media apps used last, brought back on connect. */
val lastNav: StateFlow<String?> = lastNavFlow val lastNav: StateFlow<String?> = lastNavFlow
val lastMedia: StateFlow<String?> = lastMediaFlow val lastMedia: StateFlow<String?> = lastMediaFlow
@@ -238,6 +247,16 @@ private const val KEY_CLUSTER_VIDEO = "cluster_video_enabled"
val moreLyrics: StateFlow<Boolean> = moreLyricsFlow val moreLyrics: StateFlow<Boolean> = moreLyricsFlow
val scale: StateFlow<Float> = scaleFlow 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)
/** An absolute DPI for the car UI and its apps; the video's resolution doesn't change with it. */
val carDensity: StateFlow<Int> = carDensityFlow
/** The density to draw at, for a car or preview that would otherwise use [carDpi]. */
fun effectiveDensity(carDpi: Int) = carDensityFlow.value.takeIf { it != DENSITY_FROM_CAR } ?: carDpi
val openOnConnect: StateFlow<String> = openFlow val openOnConnect: StateFlow<String> = openFlow
val nightMode: StateFlow<NightMode> = nightFlow val nightMode: StateFlow<NightMode> = nightFlow
@@ -253,6 +272,9 @@ private const val KEY_CLUSTER_VIDEO = "cluster_video_enabled"
/** Gearslip reads the map app's turns aloud on the car's directions channel; see DirectionsVoice. */ /** Gearslip reads the map app's turns aloud on the car's directions channel; see DirectionsVoice. */
val speakDirections: StateFlow<Boolean> = speakDirectionsFlow val speakDirections: StateFlow<Boolean> = speakDirectionsFlow
/** The turns go out on the music channel instead, for cars the directions channel upsets. */
val directionsOverMusic: StateFlow<Boolean> = directionsOverMusicFlow
/** With Bluetooth audio, warn when the phone isn't connected to the car; see BluetoothAudioCheck. */ /** With Bluetooth audio, warn when the phone isn't connected to the car; see BluetoothAudioCheck. */
val bluetoothWarning: StateFlow<Boolean> = bluetoothWarningFlow val bluetoothWarning: StateFlow<Boolean> = bluetoothWarningFlow
@@ -326,6 +348,7 @@ private const val KEY_CLUSTER_VIDEO = "cluster_video_enabled"
fun init(context: Context) { fun init(context: Context) {
app = context.applicationContext app = context.applicationContext
scaleFlow.value = AppSettings.getString(app, KEY_SCALE, "1.0").toFloatOrNull() ?: 1f scaleFlow.value = AppSettings.getString(app, KEY_SCALE, "1.0").toFloatOrNull() ?: 1f
carDensityFlow.value = AppSettings.getString(app, KEY_DENSITY, "").toIntOrNull()?.takeIf { it in DENSITIES } ?: DENSITY_FROM_CAR
openFlow.value = AppSettings.getString(app, KEY_OPEN, HOME) openFlow.value = AppSettings.getString(app, KEY_OPEN, HOME)
lastNavFlow.value = AppSettings.getString(app, KEY_LAST_NAV, "").ifEmpty { null } lastNavFlow.value = AppSettings.getString(app, KEY_LAST_NAV, "").ifEmpty { null }
lastMediaFlow.value = AppSettings.getString(app, KEY_LAST_MEDIA, "").ifEmpty { null } lastMediaFlow.value = AppSettings.getString(app, KEY_LAST_MEDIA, "").ifEmpty { null }
@@ -344,6 +367,7 @@ private const val KEY_CLUSTER_VIDEO = "cluster_video_enabled"
// Until the driver sets it, on for USB audio and off for Bluetooth, where the map app's // Until the driver sets it, on for USB audio and off for Bluetooth, where the map app's
// own voice already reaches the car. // own voice already reaches the car.
speakDirectionsFlow.value = AppSettings.getString(app, KEY_SPEAK_DIRECTIONS, pipeAudioFlow.value.toString()) == "true" speakDirectionsFlow.value = AppSettings.getString(app, KEY_SPEAK_DIRECTIONS, pipeAudioFlow.value.toString()) == "true"
directionsOverMusicFlow.value = AppSettings.getString(app, KEY_DIRECTIONS_OVER_MUSIC, "false") == "true"
bluetoothWarningFlow.value = AppSettings.getString(app, KEY_BLUETOOTH_WARNING, "true") == "true" bluetoothWarningFlow.value = AppSettings.getString(app, KEY_BLUETOOTH_WARNING, "true") == "true"
locationWarningFlow.value = AppSettings.getString(app, KEY_LOCATION_WARNING, "true") == "true" locationWarningFlow.value = AppSettings.getString(app, KEY_LOCATION_WARNING, "true") == "true"
nightFlow.value = runCatching { nightFlow.value = runCatching {
@@ -353,6 +377,8 @@ private const val KEY_CLUSTER_VIDEO = "cluster_video_enabled"
.split(",").filter { it.isNotEmpty() }.toSet() .split(",").filter { it.isNotEmpty() }.toSet()
appOrderFlow.value = AppSettings.getString(app, KEY_APP_ORDER, "") appOrderFlow.value = AppSettings.getString(app, KEY_APP_ORDER, "")
.split(",").filter { it.isNotEmpty() } .split(",").filter { it.isNotEmpty() }
hiddenAppsFlow.value = AppSettings.getString(app, KEY_HIDDEN_APPS, "")
.split(",").filter { it.isNotEmpty() }.toSet()
experimentalFeaturesFlow.value = AppSettings.getString(app, KEY_EXPERIMENTAL_FEATURES, "false") == "true" experimentalFeaturesFlow.value = AppSettings.getString(app, KEY_EXPERIMENTAL_FEATURES, "false") == "true"
systemSpeechFlow.value = AppSettings.getString(app, KEY_SYSTEM_SPEECH, "false") == "true" systemSpeechFlow.value = AppSettings.getString(app, KEY_SYSTEM_SPEECH, "false") == "true"
carSensorsFlow.value = AppSettings.getString(app, KEY_CAR_SENSORS, "false") == "true" carSensorsFlow.value = AppSettings.getString(app, KEY_CAR_SENSORS, "false") == "true"
@@ -383,6 +409,11 @@ private const val KEY_CLUSTER_VIDEO = "cluster_video_enabled"
AppSettings.putString(app, KEY_SCALE, value.toString()) AppSettings.putString(app, KEY_SCALE, value.toString())
} }
fun setCarDensity(dpi: Int) {
carDensityFlow.value = dpi
AppSettings.putString(app, KEY_DENSITY, dpi.toString())
}
fun setOpenOnConnect(id: String) { fun setOpenOnConnect(id: String) {
openFlow.value = id openFlow.value = id
AppSettings.putString(app, KEY_OPEN, id) AppSettings.putString(app, KEY_OPEN, id)
@@ -451,6 +482,11 @@ private const val KEY_CLUSTER_VIDEO = "cluster_video_enabled"
AppSettings.putString(app, KEY_SPEAK_DIRECTIONS, on.toString()) AppSettings.putString(app, KEY_SPEAK_DIRECTIONS, on.toString())
} }
fun setDirectionsOverMusic(on: Boolean) {
directionsOverMusicFlow.value = on
AppSettings.putString(app, KEY_DIRECTIONS_OVER_MUSIC, on.toString())
}
fun setBluetoothWarning(on: Boolean) { fun setBluetoothWarning(on: Boolean) {
bluetoothWarningFlow.value = on bluetoothWarningFlow.value = on
AppSettings.putString(app, KEY_BLUETOOTH_WARNING, on.toString()) AppSettings.putString(app, KEY_BLUETOOTH_WARNING, on.toString())
@@ -505,6 +541,12 @@ private const val KEY_CLUSTER_VIDEO = "cluster_video_enabled"
AppSettings.putString(app, KEY_PINNED, next.joinToString(",")) AppSettings.putString(app, KEY_PINNED, next.joinToString(","))
} }
fun setAppHidden(id: String, hide: Boolean) {
val next = if (hide) hiddenAppsFlow.value + id else hiddenAppsFlow.value - id
hiddenAppsFlow.value = next
AppSettings.putString(app, KEY_HIDDEN_APPS, next.joinToString(","))
}
fun setAppOrder(ids: List<String>) { fun setAppOrder(ids: List<String>) {
appOrderFlow.value = ids appOrderFlow.value = ids
AppSettings.putString(app, KEY_APP_ORDER, ids.joinToString(",")) AppSettings.putString(app, KEY_APP_ORDER, ids.joinToString(","))
@@ -58,6 +58,7 @@ import kotlinx.coroutines.withContext
fun CarSettingsScreen() { fun CarSettingsScreen() {
val scale by CarSettings.scale.collectAsState() val scale by CarSettings.scale.collectAsState()
val open by CarSettings.openOnConnect.collectAsState() val open by CarSettings.openOnConnect.collectAsState()
val launcher by LauncherMode.active.collectAsState()
val night by CarSettings.nightMode.collectAsState() val night by CarSettings.nightMode.collectAsState()
val units by CarSettings.units.collectAsState() val units by CarSettings.units.collectAsState()
val navBar by CarSettings.navBarMode.collectAsState() val navBar by CarSettings.navBarMode.collectAsState()
@@ -66,6 +67,7 @@ fun CarSettingsScreen() {
val whatsNew by CarSettings.whatsNewAfterUpdates.collectAsState() val whatsNew by CarSettings.whatsNewAfterUpdates.collectAsState()
val pipeAudio by CarSettings.pipeAudio.collectAsState() val pipeAudio by CarSettings.pipeAudio.collectAsState()
val speakDirections by CarSettings.speakDirections.collectAsState() val speakDirections by CarSettings.speakDirections.collectAsState()
val directionsOverMusic by CarSettings.directionsOverMusic.collectAsState()
val bluetoothWarning by CarSettings.bluetoothWarning.collectAsState() val bluetoothWarning by CarSettings.bluetoothWarning.collectAsState()
val locationWarning by CarSettings.locationWarning.collectAsState() val locationWarning by CarSettings.locationWarning.collectAsState()
val autoplay by CarSettings.autoplay.collectAsState() val autoplay by CarSettings.autoplay.collectAsState()
@@ -109,7 +111,7 @@ fun CarSettingsScreen() {
if (section == chosen) item { if (section == chosen) item {
ChoiceRow( ChoiceRow(
stringResource(R.string.car_open_on_connect), stringResource(R.string.car_open_on_connect),
listOf(CarSettings.HOME to stringResource(R.string.choice_open_home)) + CarApps.all.filter { it.id != "settings" }.map { it.id to stringResource(it.labelRes) }, listOf(CarSettings.HOME to stringResource(R.string.choice_open_home)) + CarApps.shown(launcher, LauncherMode.canCall(context)).map { it.id to stringResource(it.labelRes) },
open, open,
) { CarSettings.setOpenOnConnect(it) } ) { CarSettings.setOpenOnConnect(it) }
} }
@@ -286,6 +288,22 @@ fun CarSettingsScreen() {
) )
} }
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 { if (section == chosen) item {
ExperimentalFeatureRow( ExperimentalFeatureRow(
title = stringResource(R.string.car_phone_speech), title = stringResource(R.string.car_phone_speech),
@@ -125,7 +125,12 @@ fun CarUi() {
val colors = (if (dark) dynamicDarkColorScheme(appContext) else dynamicLightColorScheme(appContext)).withTintedSurfaces(dark, darkStyle) val colors = (if (dark) dynamicDarkColorScheme(appContext) else dynamicLightColorScheme(appContext)).withTintedSurfaces(dark, darkStyle)
val base = LocalDensity.current val base = LocalDensity.current
val insets by CarEnvironment.insets.collectAsState() val insets by CarEnvironment.insets.collectAsState()
val navigator = remember { CarNavigator(CarSettings.openOnConnect.value) } val navigator = remember {
// A start screen picked for the car may be one launcher mode hides, like Screen sharing.
val start = CarSettings.openOnConnect.value
val shown = CarApps.shown(LauncherMode.active.value, LauncherMode.canCall(appContext)).any { it.id == start }
CarNavigator(if (shown) start else CarSettings.HOME)
}
val controlsWake = remember { MapControlsWake() } val controlsWake = remember { MapControlsWake() }
LaunchedEffect(controlsWake) { LaunchedEffect(controlsWake) {
while (true) { while (true) {
@@ -325,6 +330,7 @@ fun CarUi() {
} }
} }
CarToastOverlay(Modifier.align(Alignment.BottomCenter).padding(bottom = homeInset)) CarToastOverlay(Modifier.align(Alignment.BottomCenter).padding(bottom = homeInset))
CarAlertOverlay(Modifier.align(Alignment.TopCenter))
CallPromptOverlay() CallPromptOverlay()
BluetoothPairOverlay() BluetoothPairOverlay()
PopupTimer() PopupTimer()
@@ -8,6 +8,7 @@ import androidx.car.app.navigation.model.RoutingInfo
import androidx.car.app.navigation.model.Step import androidx.car.app.navigation.model.Step
import app.seb3thehacker.gearslip.GearslipLog import app.seb3thehacker.gearslip.GearslipLog
import app.seb3thehacker.gearslip.Protobuf import app.seb3thehacker.gearslip.Protobuf
import app.seb3thehacker.gearslip.notify.PhoneNavigation
/** /**
* The next turn on the car's instrument cluster, over its NavigationStatusService (Service * The next turn on the car's instrument cluster, over its NavigationStatusService (Service
@@ -200,6 +201,14 @@ internal class NavTurn(val step: Step, val distance: Distance?, val seconds: Lon
/** The map app's next turn: from its trip (what clusters get), or its routing card. */ /** The map app's next turn: from its trip (what clusters get), or its routing card. */
fun current(): NavTurn? { fun current(): NavTurn? {
val nav = CarServices.navOrNull() ?: return null val nav = CarServices.navOrNull() ?: return null
hosted(nav)?.let { return it }
// Google Maps or Waze guiding on the phone, when no map in the car is.
val phone = PhoneNavigation.turn.value ?: return null
val step = Step.Builder(phone.road).setManeuver(Maneuver.Builder(phone.maneuver).build()).build()
return NavTurn(step, phone.distance, null, false)
}
private fun hosted(nav: app.seb3thehacker.gearslip.host.CarAppConnection): NavTurn? {
nav.trip.value?.let { trip -> nav.trip.value?.let { trip ->
val step = trip.steps.firstOrNull() ?: return@let val step = trip.steps.firstOrNull() ?: return@let
val estimate = trip.stepTravelEstimates.firstOrNull() val estimate = trip.stepTravelEstimates.firstOrNull()
@@ -3,6 +3,9 @@ package app.seb3thehacker.gearslip.car
import app.seb3thehacker.gearslip.R import app.seb3thehacker.gearslip.R
import android.content.Context import android.content.Context
import android.os.Bundle import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.os.SystemClock
import android.speech.tts.TextToSpeech import android.speech.tts.TextToSpeech
import android.speech.tts.UtteranceProgressListener import android.speech.tts.UtteranceProgressListener
import androidx.car.app.model.CarText import androidx.car.app.model.CarText
@@ -11,6 +14,7 @@ import androidx.car.app.navigation.model.Maneuver
import androidx.car.app.navigation.model.Step import androidx.car.app.navigation.model.Step
import app.seb3thehacker.gearslip.GearslipLog import app.seb3thehacker.gearslip.GearslipLog
import app.seb3thehacker.gearslip.audio.AudioLink import app.seb3thehacker.gearslip.audio.AudioLink
import app.seb3thehacker.gearslip.audio.CarAudio
import java.io.File import java.io.File
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.nio.ByteOrder import java.nio.ByteOrder
@@ -40,11 +44,16 @@ object DirectionsVoice {
private var appContext: Context? = null private var appContext: Context? = null
private var tts: TextToSpeech? = null private var tts: TextToSpeech? = null
@Volatile private var ttsReady = false @Volatile private var ttsReady = false
/** When the engine was last started; one not ready by [RESTART_MS] later gets another try. */
@Volatile private var ttsStartedAt = 0L
private val pending = ConcurrentHashMap<String, CountDownLatch>() private val pending = ConcurrentHashMap<String, CountDownLatch>()
private val main = Handler(Looper.getMainLooper())
@Volatile private var link: AudioLink? = null @Volatile private var link: AudioLink? = null
@Volatile private var running = false @Volatile private var running = false
@Volatile private var musicIsOurs: () -> Boolean = { false } @Volatile private var musicIsOurs: () -> Boolean = { false }
/** The car's music sink once it's set up, for [CarSettings.directionsOverMusic]. */
@Volatile private var musicSink: () -> AudioLink? = { null }
private val speaker = Executors.newSingleThreadScheduledExecutor { Thread(it, "gearslip-directions").apply { isDaemon = true } } private val speaker = Executors.newSingleThreadScheduledExecutor { Thread(it, "gearslip-directions").apply { isDaemon = true } }
/** Counts announcements, so a hand-back scheduled after one is dropped if another has begun. */ /** Counts announcements, so a hand-back scheduled after one is dropped if another has begun. */
private var spoken = 0 private var spoken = 0
@@ -56,7 +65,19 @@ object DirectionsVoice {
fun init(context: Context) { fun init(context: Context) {
if (tts != null) return if (tts != null) return
appContext = context.applicationContext appContext = context.applicationContext
tts = TextToSpeech(appContext) { status -> ttsReady = status == TextToSpeech.SUCCESS }.apply { start()
}
private fun start() {
ttsReady = false
ttsStartedAt = SystemClock.elapsedRealtime()
tts = TextToSpeech(appContext) { status ->
ttsReady = status == TextToSpeech.SUCCESS
if (!ttsReady) GearslipLog.w("guidance: text-to-speech didn't start (status $status)")
// The turns are worded in Gearslip's language; the engine's default voice may be
// another one, and read German words with an English voice (issue #70).
else speakIn(appContext?.resources?.configuration?.locales?.get(0))
}.apply {
setOnUtteranceProgressListener(object : UtteranceProgressListener() { setOnUtteranceProgressListener(object : UtteranceProgressListener() {
override fun onStart(utteranceId: String?) {} override fun onStart(utteranceId: String?) {}
override fun onDone(utteranceId: String?) { pending.remove(utteranceId)?.countDown() } override fun onDone(utteranceId: String?) { pending.remove(utteranceId)?.countDown() }
@@ -66,10 +87,21 @@ object DirectionsVoice {
} }
} }
/** Keeps the engine's own voice when it has none for [locale]. */
private fun speakIn(locale: java.util.Locale?) {
val engine = tts ?: return
locale ?: return
val result = engine.setLanguage(locale)
if (result == TextToSpeech.LANG_MISSING_DATA || result == TextToSpeech.LANG_NOT_SUPPORTED) {
GearslipLog.w("guidance: the speech engine has no ${locale.displayLanguage} voice - using its default")
}
}
/** The car opened its directions sink: start following the map app's turns. */ /** The car opened its directions sink: start following the map app's turns. */
fun attach(link: AudioLink, musicIsOurs: () -> Boolean) { fun attach(link: AudioLink, musicIsOurs: () -> Boolean, musicSink: () -> AudioLink? = { null }) {
this.link = link this.link = link
this.musicIsOurs = musicIsOurs this.musicIsOurs = musicIsOurs
this.musicSink = musicSink
GearslipLog.i("guidance: the car offered a directions sink at ${link.sampleRate}Hz x${link.channels}") GearslipLog.i("guidance: the car offered a directions sink at ${link.sampleRate}Hz x${link.channels}")
if (running) return if (running) return
running = true running = true
@@ -232,6 +264,16 @@ object DirectionsVoice {
val sink = link ?: return val sink = link ?: return
if (!ttsReady) { if (!ttsReady) {
GearslipLog.w("guidance: text-to-speech isn't ready, a turn went unspoken") GearslipLog.w("guidance: text-to-speech isn't ready, a turn went unspoken")
// A failed or unanswered start was final, so one bad start silenced a whole drive
// (issue #35). Try again, once the last try has had its time.
if (SystemClock.elapsedRealtime() - ttsStartedAt > RESTART_MS) {
ttsStartedAt = SystemClock.elapsedRealtime()
GearslipLog.i("guidance: starting text-to-speech again")
main.post {
runCatching { engine.shutdown() }
start()
}
}
return return
} }
val file = File(context.cacheDir, "directions.wav") val file = File(context.cacheDir, "directions.wav")
@@ -247,10 +289,16 @@ object DirectionsVoice {
GearslipLog.w("guidance: text-to-speech took too long, a turn went unspoken") GearslipLog.w("guidance: text-to-speech took too long, a turn went unspoken")
return return
} }
val pcm = Wav.read(file)?.let { Wav.convert(it, sink.sampleRate, sink.channels) } ?: run { val wav = Wav.read(file) ?: run {
GearslipLog.w("guidance: text-to-speech gave audio Gearslip can't read") GearslipLog.w("guidance: text-to-speech gave audio Gearslip can't read")
return return
} }
val music = musicSink()
if (CarSettings.directionsOverMusic.value && music != null) {
speakOverMusic(music, Wav.convert(wav, music.sampleRate, music.channels))
return
}
val pcm = Wav.convert(wav, sink.sampleRate, sink.channels)
// Logged before speaking too, so a car that dies mid-announcement shows what it was doing. // Logged before speaking too, so a car that dies mid-announcement shows what it was doing.
GearslipLog.i("guidance: speaking a turn") GearslipLog.i("guidance: speaking a turn")
// Held through back-to-back prompts rather than asked for and given back each time. // Held through back-to-back prompts rather than asked for and given back each time.
@@ -264,18 +312,44 @@ object DirectionsVoice {
GearslipLog.i("guidance: spoke a turn (${pcm.size / (2 * sink.channels) * 1000 / sink.sampleRate} ms)") GearslipLog.i("guidance: spoke a turn (${pcm.size / (2 * sink.channels) * 1000 / sink.sampleRate} ms)")
} }
/**
* A 2025 Nissan's Bosch unit drops the link about a second after any turn on its directions
* channel while its music channel is open, and plays music through nothing else (issue #35).
* So the directions channel is left alone: the turn rides in the music, or, with no music
* playing, goes out on the music channel by itself.
*/
private fun speakOverMusic(music: AudioLink, pcm: ByteArray) {
GearslipLog.i("guidance: speaking a turn over the music channel")
if (!CarAudio.speakOverMusic(pcm)) {
music.startForTurn()
try {
stream(music, pcm, alive = { musicSink() === music }, startAndStop = false)
} finally {
music.stopForTurn()
// Music that started during the turn found the stream running and didn't start its own.
if (CarAudio.capture.value == CarAudio.Capture.ON) music.start()
}
}
GearslipLog.i("guidance: spoke a turn over the music channel (${pcm.size / (2 * music.channels) * 1000 / music.sampleRate} ms)")
}
/** Start, the words in 20 ms chunks at about real time, a little silence, Stop. */ /** Start, the words in 20 ms chunks at about real time, a little silence, Stop. */
private fun stream(sink: AudioLink, pcm: ByteArray) { private fun stream(
sink: AudioLink,
pcm: ByteArray,
alive: () -> Boolean = { link === sink },
startAndStop: Boolean = true,
) {
val frame = 2 * sink.channels val frame = 2 * sink.channels
val chunk = sink.sampleRate / 50 * frame val chunk = sink.sampleRate / 50 * frame
val chunkMs = 20L val chunkMs = 20L
val padded = pcm + ByteArray(sink.sampleRate / 5 * frame) // 200 ms tail so the last word isn't clipped val padded = pcm + ByteArray(sink.sampleRate / 5 * frame) // 200 ms tail so the last word isn't clipped
sink.start() if (startAndStop) sink.start()
try { try {
val began = System.currentTimeMillis() val began = System.currentTimeMillis()
var offset = 0 var offset = 0
var sent = 0 var sent = 0
while (offset < padded.size && link === sink) { while (offset < padded.size && alive()) {
val size = minOf(chunk, padded.size - offset) val size = minOf(chunk, padded.size - offset)
sink.write(padded.copyOfRange(offset, offset + size), size) sink.write(padded.copyOfRange(offset, offset + size), size)
offset += size offset += size
@@ -286,7 +360,7 @@ object DirectionsVoice {
// Let the car play what it has before closing the stream on it. // Let the car play what it has before closing the stream on it.
Thread.sleep(LEAD_MS) Thread.sleep(LEAD_MS)
} finally { } finally {
sink.stop() if (startAndStop) sink.stop()
} }
} }
@@ -360,6 +434,7 @@ object DirectionsVoice {
private const val LEAD_MS = 100L private const val LEAD_MS = 100L
private const val HOLD_MS = 2_000L private const val HOLD_MS = 2_000L
private const val SYNTH_TIMEOUT_S = 10L private const val SYNTH_TIMEOUT_S = 10L
private const val RESTART_MS = 30_000L
private val ORDINALS = listOf( private val ORDINALS = listOf(
R.string.dir_ordinal_1, R.string.dir_ordinal_2, R.string.dir_ordinal_3, R.string.dir_ordinal_4, R.string.dir_ordinal_1, R.string.dir_ordinal_2, R.string.dir_ordinal_3, R.string.dir_ordinal_4,
R.string.dir_ordinal_5, R.string.dir_ordinal_6, R.string.dir_ordinal_7, R.string.dir_ordinal_8, R.string.dir_ordinal_5, R.string.dir_ordinal_6, R.string.dir_ordinal_7, R.string.dir_ordinal_8,
@@ -0,0 +1,79 @@
package app.seb3thehacker.gearslip.car
import android.app.ActivityOptions
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.provider.Settings
import android.view.Display
import app.seb3thehacker.gearslip.GearslipLog
import app.seb3thehacker.gearslip.LauncherActivity
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* Gearslip as the home screen of the device it runs on: a head unit that runs Android itself, or
* a spare phone or tablet on the dash. The car UI fills the device's own screen, with no car in
* between. Off until it's turned on in Settings, so a phone never lists Gearslip as a home app.
*/
object LauncherMode {
private val _active = MutableStateFlow(false)
/** The car UI is up on this device's own screen, as its home screen. */
val active: StateFlow<Boolean> = _active
internal fun setActive(on: Boolean) {
_active.value = on
}
private fun component(context: Context) = ComponentName(context, LauncherActivity::class.java)
/** Whether Android may offer Gearslip as the home app. */
fun enabled(context: Context): Boolean =
context.packageManager.getComponentEnabledSetting(component(context)) ==
PackageManager.COMPONENT_ENABLED_STATE_ENABLED
fun setEnabled(context: Context, on: Boolean) {
context.packageManager.setComponentEnabledSetting(
component(context),
if (on) PackageManager.COMPONENT_ENABLED_STATE_ENABLED else PackageManager.COMPONENT_ENABLED_STATE_DISABLED,
PackageManager.DONT_KILL_APP,
)
GearslipLog.i("launcher mode ${if (on) "on" else "off"}")
}
/** Android's own "Default home app" choice, where Gearslip is picked once it's offered. */
fun openHomeChoice(context: Context) {
runCatching {
context.startActivity(Intent(Settings.ACTION_HOME_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
}.onFailure { GearslipLog.w("launcher mode: no home app settings on this device (${it.message})") }
}
/** Head units and most tablets have no phone of their own to call with. */
fun canCall(context: Context): Boolean {
val feature = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
PackageManager.FEATURE_TELEPHONY_CALLING
} else {
@Suppress("DEPRECATION")
PackageManager.FEATURE_TELEPHONY
}
return context.packageManager.hasSystemFeature(feature)
}
/**
* Opens an ordinary app full screen, Gearslip's own phone screens included. The car UI lives
* on a display of its own, and a context from it would start the app there, which Android
* refuses; the application context and the device's own display put it on the real screen.
*/
fun launch(context: Context, packageName: String) {
val app = context.applicationContext
val intent = app.packageManager.getLaunchIntentForPackage(packageName) ?: return
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
val options = ActivityOptions.makeBasic().setLaunchDisplayId(Display.DEFAULT_DISPLAY)
runCatching { app.startActivity(intent, options.toBundle()) }
.onFailure { GearslipLog.w("launcher mode: couldn't open $packageName (${it.message})") }
}
}
@@ -19,6 +19,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.List
import androidx.compose.material.icons.filled.Call import androidx.compose.material.icons.filled.Call
import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Search
@@ -83,11 +84,28 @@ internal object BuiltInApps {
val VehicleData = BuiltInApp("vehicle", "Vehicle data", R.string.car_vehicle_data, Icons.Filled.Info, CarScreen.VehicleData) { it.vehicleData() } val VehicleData = BuiltInApp("vehicle", "Vehicle data", R.string.car_vehicle_data, Icons.Filled.Info, CarScreen.VehicleData) { it.vehicleData() }
val Settings = BuiltInApp("settings", "Settings", R.string.settings_title, Icons.Filled.Settings, CarScreen.Settings) { it.settings() } val Settings = BuiltInApp("settings", "Settings", R.string.settings_title, Icons.Filled.Settings, CarScreen.Settings) { it.settings() }
/** Launcher mode only: every app on the device, opened full screen. */
val AllApps = BuiltInApp("allapps", "All apps", R.string.app_all_apps, Icons.Filled.List, CarScreen.App("allapps")) { it.open("allapps") }
/** Hands the screen back to the car's own interface, like Android Auto's Exit. */ /** Hands the screen back to the car's own interface, like Android Auto's Exit. */
val Exit = BuiltInApp("exit", "Exit", R.string.app_exit, CarGlyph, screen = null) { CarFocus.exitToCar() } val Exit = BuiltInApp("exit", "Exit", R.string.app_exit, CarGlyph, screen = null) { CarFocus.exitToCar() }
val all = listOf(Web, ScreenSharing, Phone, VehicleData, Settings, Exit) val all = listOf(Web, ScreenSharing, Phone, VehicleData, Settings, Exit, AllApps)
val entries: List<Entry> = all.map { Entry(it.label, icon = null, builtIn = it) } private val byApp = all.associateWith { Entry(it.label, icon = null, builtIn = it) }
val everyEntry: List<Entry> = byApp.values.toList()
/**
* The ones that make sense where the car UI is showing. As a device's own home screen there
* is no car to share the screen with or exit to, and no phone line on most head units.
*/
fun entries(launcher: Boolean, canCall: Boolean): List<Entry> = byApp.filterKeys { app ->
when (app) {
AllApps -> launcher
ScreenSharing, Exit -> !launcher
Phone -> !launcher || canCall
else -> true
}
}.values.toList()
} }
/** The name a driver sees: translated for Gearslip's own apps, the app's own name otherwise. */ /** The name a driver sees: translated for Gearslip's own apps, the app's own name otherwise. */
@@ -276,7 +294,8 @@ internal fun AppMenu(navigator: CarNavigator, running: Map<String, RunningSlot>)
@Composable @Composable
private fun MoveButtons(id: String) { private fun MoveButtons(id: String) {
val custom by CarSettings.appOrder.collectAsState() val custom by CarSettings.appOrder.collectAsState()
val order = launcherOrder(rememberAllEntries(), rememberVehicleDataShown(), custom).mapNotNull { it.componentId } val hidden by CarSettings.hiddenApps.collectAsState()
val order = launcherOrder(rememberAllEntries(), rememberVehicleDataShown(), custom, hidden).mapNotNull { it.componentId }
val index = order.indexOf(id) val index = order.indexOf(id)
if (index < 0) return if (index < 0) return
fun move(by: Int) { fun move(by: Int) {
@@ -106,7 +106,7 @@ private fun TouchRelayWarning() {
} }
@Composable @Composable
private fun PhoneAppTile(app: PhoneApp, onClick: () -> Unit) { internal fun PhoneAppTile(app: PhoneApp, onClick: () -> Unit) {
GsIconBox( GsIconBox(
onClick = onClick, onClick = onClick,
modifier = Modifier.height(120.dp), modifier = Modifier.height(120.dp),
@@ -659,8 +659,16 @@ class CarAppConnection(private val context: Context) {
} }
override fun sendLocation(location: Location?) = Unit override fun sendLocation(location: Location?) = Unit
override fun showAlert(alert: Bundleable?) = Unit override fun showAlert(alert: Bundleable?) {
override fun dismissAlert(alertId: Int) = Unit val shown = runCatching { alert?.get() as? androidx.car.app.model.Alert }
.onFailure { GearslipLog.w("host: couldn't read an alert: ${it.message}") }
.getOrNull() ?: return
main.post { if (toastOwner === owner) app.seb3thehacker.gearslip.car.CarAlerts.show(owner, shown) }
}
override fun dismissAlert(alertId: Int) {
main.post { app.seb3thehacker.gearslip.car.CarAlerts.dismiss(owner, alertId) }
}
override fun openMicrophone(request: Bundleable?): Bundleable? = null override fun openMicrophone(request: Bundleable?): Bundleable? = null
} }
@@ -839,6 +847,7 @@ class CarAppConnection(private val context: Context) {
private fun clearToasts() { private fun clearToasts() {
CarToasts.clear(toastOwner) CarToasts.clear(toastOwner)
app.seb3thehacker.gearslip.car.CarAlerts.clear(toastOwner)
toastOwner = Any() toastOwner = Any()
} }
@@ -306,6 +306,20 @@ fun OnContentRefreshDelegate?.refresh() {
.onFailure { GearslipLog.w("host: could not request a refresh: ${it.message}") } .onFailure { GearslipLog.w("host: could not request a refresh: ${it.message}") }
} }
/** An alert left the screen without the app taking it down: timed out, or closed by the driver. */
fun androidx.car.app.model.AlertCallbackDelegate?.cancel(reason: Int) {
val delegate = this ?: return
runCatching { delegate.sendCancel(reason, doneCallback("an alert cancel")) }
.onFailure { GearslipLog.w("host: could not cancel an alert: ${it.message}") }
}
/** An alert left the screen another way: a newer one replaced it, the app took it down, or a tap. */
fun androidx.car.app.model.AlertCallbackDelegate?.dismissed() {
val delegate = this ?: return
runCatching { delegate.sendDismiss(doneCallback("an alert dismiss")) }
.onFailure { GearslipLog.w("host: could not dismiss an alert: ${it.message}") }
}
private fun doneCallback(label: String) = object : OnDoneCallback { private fun doneCallback(label: String) = object : OnDoneCallback {
// onSuccess is annotated nullable and onFailure isn't - the asymmetry is real. // onSuccess is annotated nullable and onFailure isn't - the asymmetry is real.
override fun onSuccess(response: Bundleable?) = Unit override fun onSuccess(response: Bundleable?) = Unit
@@ -1,5 +1,6 @@
package app.seb3thehacker.gearslip.media package app.seb3thehacker.gearslip.media
import app.seb3thehacker.gearslip.R
import android.content.ComponentName import android.content.ComponentName
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
@@ -391,7 +392,7 @@ class CarMedia(private val context: Context) {
speed = state?.playbackSpeed ?: 1f, speed = state?.playbackSpeed ?: 1f,
updatedAt = state?.lastPositionUpdateTime ?: 0, updatedAt = state?.lastPositionUpdateTime ?: 0,
actions = state?.actions ?: 0, actions = state?.actions ?: 0,
custom = state?.customActions.orEmpty().map { CustomAction(it.action, it.name.toString(), it.extras, it.icon, iconName(it.icon)) }, custom = withNextChapter(state?.customActions.orEmpty().map { CustomAction(it.action, it.name.toString(), it.extras, it.icon, iconName(it.icon)) }),
error = state?.errorMessage?.toString(), error = state?.errorMessage?.toString(),
activeQueueItemId = state?.activeQueueItemId ?: MediaSession.QueueItem.UNKNOWN_ID.toLong(), activeQueueItemId = state?.activeQueueItemId ?: MediaSession.QueueItem.UNKNOWN_ID.toLong(),
heart = if (controller?.ratingType == Rating.RATING_HEART) { heart = if (controller?.ratingType == Rating.RATING_HEART) {
@@ -405,12 +406,31 @@ class CarMedia(private val context: Context) {
private var appResources: Pair<String, android.content.res.Resources?>? = null private var appResources: Pair<String, android.content.res.Resources?>? = null
/** The icon's resource name in the app's package, or "" when it can't be read. */ /** The icon's resource name in the app's package, or "" when it can't be read. */
private fun iconName(icon: Int): String { /**
val pkg = controller?.packageName ?: return "" * AntennaPod takes a Next chapter command whenever it's playing, but offers the button only
* when the driver turns it on in its notification settings. Gearslip offers it either way,
* with AntennaPod's own icon; on an episode without chapters it does nothing.
*/
private fun withNextChapter(actions: List<CustomAction>): List<CustomAction> {
if (controller?.packageName != ANTENNAPOD) return actions
if (actions.any { it.id in ANTENNAPOD_NEXT_CHAPTER }) return actions
val icon = resourcesOf(ANTENNAPOD)?.getIdentifier("ic_notification_next_chapter", "drawable", ANTENNAPOD) ?: 0
val nextChapter = CustomAction(ANTENNAPOD_NEXT_CHAPTER.first(), context.getString(R.string.player_next_chapter), null, icon)
// The player shows only the first two of an app's own buttons, and AntennaPod's are
// Rewind and Fast-forward: second place keeps Rewind beside it.
return actions.toMutableList().apply { add(minOf(1, size), nextChapter) }
}
private fun resourcesOf(pkg: String): android.content.res.Resources? {
if (appResources?.first != pkg) { if (appResources?.first != pkg) {
appResources = pkg to runCatching { context.packageManager.getResourcesForApplication(pkg) }.getOrNull() appResources = pkg to runCatching { context.packageManager.getResourcesForApplication(pkg) }.getOrNull()
} }
val res = appResources?.second ?: return "" return appResources?.second
}
private fun iconName(icon: Int): String {
val pkg = controller?.packageName ?: return ""
val res = resourcesOf(pkg) ?: return ""
return runCatching { res.getResourceEntryName(icon) }.getOrDefault("") return runCatching { res.getResourceEntryName(icon) }.getOrDefault("")
} }
@@ -515,7 +535,15 @@ class CarMedia(private val context: Context) {
fun next() = controller?.transportControls?.skipToNext() fun next() = controller?.transportControls?.skipToNext()
fun previous() = controller?.transportControls?.skipToPrevious() fun previous() = controller?.transportControls?.skipToPrevious()
fun seek(ms: Long) = controller?.transportControls?.seekTo(ms) fun seek(ms: Long) = controller?.transportControls?.seekTo(ms)
fun custom(action: CustomAction) = controller?.transportControls?.sendCustomAction(action.id, action.extras) fun custom(action: CustomAction) {
val controls = controller?.transportControls ?: return
// AntennaPod's media3 service and its older one each know only their own name for it.
if (controller?.packageName == ANTENNAPOD && action.id in ANTENNAPOD_NEXT_CHAPTER) {
ANTENNAPOD_NEXT_CHAPTER.forEach { controls.sendCustomAction(it, null) }
} else {
controls.sendCustomAction(action.id, action.extras)
}
}
fun playQueueItem(track: QueueTrack) = controller?.transportControls?.skipToQueueItem(track.queueId) fun playQueueItem(track: QueueTrack) = controller?.transportControls?.skipToQueueItem(track.queueId)
/** /**
@@ -798,6 +826,10 @@ class CarMedia(private val context: Context) {
} }
/** Android Auto's browse layout hints, which any app built for it already sends. */ /** Android Auto's browse layout hints, which any app built for it already sends. */
private const val ANTENNAPOD = "de.danoeh.antennapod"
/** The media3 service's name first: that's the one offered as the button's own. */
private val ANTENNAPOD_NEXT_CHAPTER = listOf("next_chapter", "action.de.danoeh.antennapod.core.service.next_chapter")
private const val STYLE_SUPPORTED = "android.media.browse.CONTENT_STYLE_SUPPORTED" private const val STYLE_SUPPORTED = "android.media.browse.CONTENT_STYLE_SUPPORTED"
private const val STYLE_BROWSABLE = "android.media.browse.CONTENT_STYLE_BROWSABLE_HINT" private const val STYLE_BROWSABLE = "android.media.browse.CONTENT_STYLE_BROWSABLE_HINT"
private const val STYLE_PLAYABLE = "android.media.browse.CONTENT_STYLE_PLAYABLE_HINT" private const val STYLE_PLAYABLE = "android.media.browse.CONTENT_STYLE_PLAYABLE_HINT"
@@ -27,7 +27,10 @@ class GearslipNotificationListener : NotificationListenerService() {
override fun onNotificationPosted(sbn: StatusBarNotification) = ingest(sbn, quiet = false) override fun onNotificationPosted(sbn: StatusBarNotification) = ingest(sbn, quiet = false)
override fun onNotificationRemoved(sbn: StatusBarNotification) = CarNotifications.gone(sbn.key) override fun onNotificationRemoved(sbn: StatusBarNotification) {
PhoneNavigation.removed(sbn.key)
CarNotifications.gone(sbn.key)
}
private fun ingest(sbn: StatusBarNotification, quiet: Boolean) { private fun ingest(sbn: StatusBarNotification, quiet: Boolean) {
// Gearslip's own notifications are skipped, except a debug build's test message. // Gearslip's own notifications are skipped, except a debug build's test message.
@@ -35,6 +38,11 @@ class GearslipNotificationListener : NotificationListenerService() {
!(app.seb3thehacker.gearslip.BuildConfig.DEBUG && sbn.notification.channelId == TestMessage.CHANNEL) !(app.seb3thehacker.gearslip.BuildConfig.DEBUG && sbn.notification.channelId == TestMessage.CHANNEL)
) return ) return
val n = sbn.notification val n = sbn.notification
// Google Maps or Waze guiding on the phone: the turn goes to the nav bar and the cluster.
if (n.category == Notification.CATEGORY_NAVIGATION && sbn.packageName in PhoneNavigation.APPS) {
PhoneNavigation.posted(this, sbn)
return
}
// Ongoing entries (media, navigation, downloads) and group headers are status, not news. // Ongoing entries (media, navigation, downloads) and group headers are status, not news.
if (n.flags and (Notification.FLAG_ONGOING_EVENT or Notification.FLAG_GROUP_SUMMARY) != 0) return if (n.flags and (Notification.FLAG_ONGOING_EVENT or Notification.FLAG_GROUP_SUMMARY) != 0) return
if (n.category in QUIET_CATEGORIES) return if (n.category in QUIET_CATEGORIES) return
@@ -0,0 +1,147 @@
package app.seb3thehacker.gearslip.notify
import android.app.Notification
import android.content.Context
import android.graphics.Bitmap
import android.service.notification.StatusBarNotification
import androidx.car.app.model.Distance
import androidx.car.app.navigation.model.Maneuver
import androidx.core.graphics.drawable.toBitmap
import app.seb3thehacker.gearslip.GearslipLog
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* The next turn from a map app guiding on the phone itself. Google Maps and Waze refuse to run on
* Gearslip's car screen, but while one of them navigates on the phone it keeps a notification up
* with the turn's arrow, the distance and the road. Gearslip reads it, through the notification
* access it already has, and puts the turn in the nav bar and on the instrument cluster.
*/
object PhoneNavigation {
/** One turn as the notification described it. [maneuver] is a guess from the words, English only. */
class Turn(
val key: String,
val packageName: String,
val arrow: Bitmap?,
val distance: Distance?,
val distanceText: String,
val road: String,
val maneuver: Int,
)
private val _turn = MutableStateFlow<Turn?>(null)
val turn: StateFlow<Turn?> = _turn
/** The apps whose turns are read; any other map app runs on the car screen itself. */
val APPS = setOf("com.google.android.apps.maps", "com.waze")
private val log = GearslipLog.tagged("nav")
fun posted(context: Context, sbn: StatusBarNotification) {
val turn = parse(context, sbn)
val was = _turn.value
if (turn == null) {
if (was?.key == sbn.key) _turn.value = null
return
}
if (was == null || was.road != turn.road || was.maneuver != turn.maneuver) {
log.i("phone navigation (${sbn.packageName}): ${turn.distanceText} | ${turn.road} | maneuver=${turn.maneuver}")
}
_turn.value = turn
}
fun removed(key: String) {
if (_turn.value?.key == key) {
log.i("phone navigation ended")
_turn.value = null
}
}
/**
* Google Maps puts the distance in the title and the instruction in the text; Waze has been
* seen the other way round. Whichever field starts with a distance is the distance, and the
* other is the instruction.
*/
private fun parse(context: Context, sbn: StatusBarNotification): Turn? {
val extras = sbn.notification.extras
fun field(name: String) = extras.getCharSequence(name)?.toString()?.trim().orEmpty()
val fields = listOf(field(Notification.EXTRA_TITLE), field(Notification.EXTRA_TEXT), field(Notification.EXTRA_BIG_TEXT))
.filter { it.isNotEmpty() }
if (fields.isEmpty()) return null
val withDistance = fields.firstOrNull { DISTANCE.find(it)?.range?.first == 0 }
val match = withDistance?.let { DISTANCE.find(it) }
val rest = withDistance?.substring(match!!.range.last + 1)?.trimStart(' ', '·', '-', '•', ',')?.trim().orEmpty()
val instruction = fields.firstOrNull { it != withDistance }?.takeIf { it.isNotEmpty() } ?: rest
if (instruction.isEmpty() && match == null) return null
return Turn(
key = sbn.key,
packageName = sbn.packageName,
arrow = arrow(context, sbn.notification),
distance = match?.let { distanceOf(it) },
distanceText = match?.value?.trim().orEmpty(),
road = roadOf(instruction),
maneuver = maneuverOf(instruction),
)
}
private fun arrow(context: Context, n: Notification): Bitmap? = runCatching {
n.getLargeIcon()?.loadDrawable(context)?.toBitmap(ARROW_PX, ARROW_PX)
}.getOrNull()
private fun distanceOf(m: MatchResult): Distance? {
val value = m.groupValues[1].replace(',', '.').toDoubleOrNull() ?: return null
val unit = when (m.groupValues[2].lowercase()) {
"km" -> Distance.UNIT_KILOMETERS
"mi" -> Distance.UNIT_MILES
"ft" -> Distance.UNIT_FEET
"yd" -> Distance.UNIT_YARDS
else -> Distance.UNIT_METERS
}
val meters = value * when (unit) {
Distance.UNIT_KILOMETERS -> 1000.0
Distance.UNIT_MILES -> 1609.344
Distance.UNIT_FEET -> 0.3048
Distance.UNIT_YARDS -> 0.9144
else -> 1.0
}
return Distance.create(meters, unit)
}
/** "Turn left onto Main St" shows as "Main St"; anything else shows as it came. */
private fun roadOf(instruction: String): String {
val lower = instruction.lowercase()
for (word in ROAD_WORDS) {
val at = lower.indexOf(word)
if (at >= 0) return instruction.substring(at + word.length).trim()
}
return instruction
}
private fun maneuverOf(instruction: String): Int {
val s = instruction.lowercase()
val left = "left" in s
val right = "right" in s
fun side(l: Int, r: Int) = if (left) l else if (right) r else Maneuver.TYPE_UNKNOWN
return when {
"u-turn" in s || "make a u" in s -> side(Maneuver.TYPE_U_TURN_LEFT, Maneuver.TYPE_U_TURN_RIGHT)
"roundabout" in s -> Maneuver.TYPE_UNKNOWN // the car's icon needs an exit number we don't have
"destination" in s || "arrive" in s -> side(Maneuver.TYPE_DESTINATION_LEFT, Maneuver.TYPE_DESTINATION_RIGHT)
.takeIf { it != Maneuver.TYPE_UNKNOWN } ?: Maneuver.TYPE_DESTINATION
"exit" in s -> side(Maneuver.TYPE_OFF_RAMP_NORMAL_LEFT, Maneuver.TYPE_OFF_RAMP_NORMAL_RIGHT)
"merge" in s -> side(Maneuver.TYPE_MERGE_LEFT, Maneuver.TYPE_MERGE_RIGHT)
.takeIf { it != Maneuver.TYPE_UNKNOWN } ?: Maneuver.TYPE_MERGE_SIDE_UNSPECIFIED
"fork" in s -> side(Maneuver.TYPE_FORK_LEFT, Maneuver.TYPE_FORK_RIGHT)
"keep" in s || "stay" in s -> side(Maneuver.TYPE_KEEP_LEFT, Maneuver.TYPE_KEEP_RIGHT)
"slight" in s -> side(Maneuver.TYPE_TURN_SLIGHT_LEFT, Maneuver.TYPE_TURN_SLIGHT_RIGHT)
"sharp" in s -> side(Maneuver.TYPE_TURN_SHARP_LEFT, Maneuver.TYPE_TURN_SHARP_RIGHT)
"turn" in s -> side(Maneuver.TYPE_TURN_NORMAL_LEFT, Maneuver.TYPE_TURN_NORMAL_RIGHT)
"continue" in s || "straight" in s || "head " in s -> Maneuver.TYPE_STRAIGHT
else -> Maneuver.TYPE_UNKNOWN
}
}
private const val ARROW_PX = 96
private val DISTANCE = Regex("""^\s*(\d+(?:[.,]\d+)?)\s*(km|mi|ft|yd|m)\b""", RegexOption.IGNORE_CASE)
private val ROAD_WORDS = listOf(" onto ", " on to ", " toward ", " towards ", " on ")
}
@@ -226,11 +226,11 @@ object UsageStats {
* build names, not confirmed on a real phone. * build names, not confirmed on a real phone.
*/ */
fun osName(context: Context): String { fun osName(context: Context): String {
fun installed(pkg: String) = runCatching { context.packageManager.getPackageInfo(pkg, 0) }.isSuccess fun installed(pkg: String) = installed(context, pkg)
val names = listOf("ro.build.flavor", "ro.build.display.id", "ro.lineage.version") val names = listOf("ro.build.flavor", "ro.build.display.id", "ro.lineage.version")
.joinToString(" ") { prop(it) } + " " + Build.DISPLAY + " " + Build.FINGERPRINT .joinToString(" ") { prop(it) } + " " + Build.DISPLAY + " " + Build.FINGERPRINT
return when { return when {
installed("app.grapheneos.setupwizard") -> "GrapheneOS" isGrapheneOs(context) -> "GrapheneOS"
prop("ro.calyxos.version").isNotEmpty() -> "CalyxOS" prop("ro.calyxos.version").isNotEmpty() -> "CalyxOS"
installed("foundation.e.apps") -> "/e/OS" installed("foundation.e.apps") -> "/e/OS"
names.contains("iode", ignoreCase = true) -> "iodéOS" names.contains("iode", ignoreCase = true) -> "iodéOS"
@@ -240,6 +240,12 @@ object UsageStats {
} }
} }
/** GrapheneOS, told apart from stock Pixel firmware by its own setup app. */
fun isGrapheneOs(context: Context) = installed(context, "app.grapheneos.setupwizard")
private fun installed(context: Context, pkg: String) =
runCatching { context.packageManager.getPackageInfo(pkg, 0) }.isSuccess
/** A system property, or "" when it isn't set or can't be read. */ /** A system property, or "" when it isn't set or can't be read. */
private fun prop(name: String): String = runCatching { private fun prop(name: String): String = runCatching {
Class.forName("android.os.SystemProperties").getMethod("get", String::class.java) Class.forName("android.os.SystemProperties").getMethod("get", String::class.java)
@@ -24,6 +24,7 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -59,8 +60,7 @@ import app.seb3thehacker.gearslip.car.CarUi
@Composable @Composable
fun CarPreviewScreen(onBack: () -> Unit) { fun CarPreviewScreen(onBack: () -> Unit) {
val context = LocalContext.current val context = LocalContext.current
val frame by CarEnvironment.frame.collectAsStateWithLifecycle() val frame by remember { CarEnvironment.frame.also { CarEnvironment.applyDensity() } }.collectAsStateWithLifecycle()
var viewSize by remember { mutableStateOf(0 to 0) }
// Tied to how the phone itself is held, not a button: turn it sideways and the preview goes // Tied to how the phone itself is held, not a button: turn it sideways and the preview goes
// fullscreen, turn it back and the phone's own chrome (the Back/title above the frame) returns. // fullscreen, turn it back and the phone's own chrome (the Back/title above the frame) returns.
val fullscreen = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE val fullscreen = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
@@ -69,8 +69,6 @@ fun CarPreviewScreen(onBack: () -> Unit) {
val custom by app.seb3thehacker.gearslip.car.CarSettings.previewCustom.collectAsStateWithLifecycle() val custom by app.seb3thehacker.gearslip.car.CarSettings.previewCustom.collectAsStateWithLifecycle()
val (width, height, density) = previewFrame(context, frame, previewSize, custom) val (width, height, density) = previewFrame(context, frame, previewSize, custom)
val projector = remember { ScreenProjector(context) }
// The preview is something to watch, like a map on a dash mount, so the phone stays awake on // The preview is something to watch, like a map on a dash mount, so the phone stays awake on
// it. Only here: the flag goes with this screen, and every other screen sleeps as usual. // it. Only here: the flag goes with this screen, and every other screen sleeps as usual.
val view = androidx.compose.ui.platform.LocalView.current val view = androidx.compose.ui.platform.LocalView.current
@@ -79,22 +77,83 @@ fun CarPreviewScreen(onBack: () -> Unit) {
onDispose { view.keepScreenOn = false } onDispose { view.keepScreenOn = false }
} }
DisposableEffect(width, height, density) { // A launcher already shows the car UI on this screen, and two of it can't run at once.
CarEnvironment.start(context) val launcher by app.seb3thehacker.gearslip.car.LauncherMode.active.collectAsStateWithLifecycle()
// No surface yet: the SurfaceView below hands its own over once it exists, and the
// display draws straight into it. Copying each frame into a Bitmap instead cost two // Held here, not in the frame, so turning the phone between the two layouts keeps the car UI running.
// full-size allocations per frame on the main thread, and taps lagged with the size. val projector = if (launcher) null else rememberCarProjector(width, height, density, "gearslip-preview") {
projector.start(null, width, height, density, name = "gearslip-preview") { CompositionLocalProvider(app.seb3thehacker.gearslip.car.LocalCarPreview provides true) { CarUi() }
androidx.compose.runtime.CompositionLocalProvider(app.seb3thehacker.gearslip.car.LocalCarPreview provides true) { CarUi() }
}
onDispose { projector.stop() }
} }
// The frame is drawn the same way in both layouts; only what surrounds it differs. // The frame is drawn the same way in both layouts; only what surrounds it differs.
val frameContent: @Composable (Modifier) -> Unit = { modifier -> val frameContent: @Composable (Modifier) -> Unit = { modifier ->
if (projector == null) {
Text("Gearslip is this device's home screen, so the car UI is already showing there.", modifier)
} else {
CarFrame(projector, width, height, modifier.aspectRatio(width.toFloat() / height))
}
}
if (fullscreen) {
Fullscreen(width.toFloat() / height, onBack) {
// No fillMax here: left to the bounds alone, aspectRatio takes the whole width or the
// whole height, whichever the car's shape allows, and the rest stays black.
frameContent(Modifier)
}
return
}
Scaffold(containerColor = MaterialTheme.colorScheme.background) { padding ->
Column(Modifier.fillMaxSize().padding(padding).padding(horizontal = 16.dp)) {
Text(
"Car preview",
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(vertical = 12.dp),
)
Text(
"${width}x$height @ ${density}dpi - touches go to the car UI",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
frameContent(Modifier.padding(vertical = 12.dp).fillMaxWidth())
Row {
TextButton(onClick = onBack) { Text("Back") }
}
}
}
}
/**
* Runs [content] on a virtual display [width] x [height] pixels big, the way the car gets it,
* until it leaves the composition. [CarFrame] shows it.
*/
@Composable
internal fun rememberCarProjector(width: Int, height: Int, density: Int, name: String, content: @Composable () -> Unit): ScreenProjector {
val context = LocalContext.current
val projector = remember { ScreenProjector(context) }
val latestContent by androidx.compose.runtime.rememberUpdatedState(content)
DisposableEffect(width, height, density) {
CarEnvironment.start(context)
// No surface yet: the SurfaceView in CarFrame hands its own over once it exists, and the
// display draws straight into it. Copying each frame into a Bitmap instead cost two
// full-size allocations per frame on the main thread, and taps lagged with the size.
projector.start(null, width, height, density, name = name) { latestContent() }
onDispose { projector.stop() }
}
return projector
}
/**
* The [projector]'s display through a SurfaceView, with touches scaled back to the frame's own
* [width] x [height]. The car preview letterboxes it; launcher mode fills the screen with it.
*/
@Composable
internal fun CarFrame(projector: ScreenProjector, width: Int, height: Int, modifier: Modifier) {
var viewSize by remember { mutableStateOf(0 to 0) }
Box( Box(
modifier modifier
.aspectRatio(width.toFloat() / height)
.background(Color.Black) .background(Color.Black)
.onSizeChanged { viewSize = it.width to it.height } .onSizeChanged { viewSize = it.width to it.height }
.pointerInteropFilter { event -> .pointerInteropFilter { event ->
@@ -138,37 +197,6 @@ fun CarPreviewScreen(onBack: () -> Unit) {
) )
} }
} }
}
if (fullscreen) {
Fullscreen(width.toFloat() / height, onBack) {
// No fillMax here: left to the bounds alone, aspectRatio takes the whole width or the
// whole height, whichever the car's shape allows, and the rest stays black.
frameContent(Modifier)
}
return
}
Scaffold(containerColor = MaterialTheme.colorScheme.background) { padding ->
Column(Modifier.fillMaxSize().padding(padding).padding(horizontal = 16.dp)) {
Text(
"Car preview",
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(vertical = 12.dp),
)
Text(
"${width}x$height @ ${density}dpi - touches go to the car UI",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
frameContent(Modifier.padding(vertical = 12.dp).fillMaxWidth())
Row {
TextButton(onClick = onBack) { Text("Back") }
}
}
}
} }
/** /**
@@ -263,7 +291,7 @@ private fun previewFrame(
size: app.seb3thehacker.gearslip.car.PreviewSize, size: app.seb3thehacker.gearslip.car.PreviewSize,
custom: app.seb3thehacker.gearslip.car.ScreenSize?, custom: app.seb3thehacker.gearslip.car.ScreenSize?,
): Triple<Int, Int, Int> { ): Triple<Int, Int, Int> {
fun carLike(w: Int, h: Int) = Triple(w, h, (160 * h / 480).coerceIn(120, 640)) fun carLike(w: Int, h: Int) = Triple(w, h, app.seb3thehacker.gearslip.car.CarSettings.effectiveDensity((160 * h / 480).coerceIn(120, 640)))
return when (size) { return when (size) {
app.seb3thehacker.gearslip.car.PreviewSize.LAST_CAR -> Triple(lastCar.width, lastCar.height, lastCar.densityDpi) app.seb3thehacker.gearslip.car.PreviewSize.LAST_CAR -> Triple(lastCar.width, lastCar.height, lastCar.densityDpi)
app.seb3thehacker.gearslip.car.PreviewSize.CUSTOM -> app.seb3thehacker.gearslip.car.PreviewSize.CUSTOM ->
@@ -5,6 +5,7 @@ import androidx.compose.ui.res.stringResource
import app.seb3thehacker.gearslip.R import app.seb3thehacker.gearslip.R
import app.seb3thehacker.gearslip.BuildConfig import app.seb3thehacker.gearslip.BuildConfig
import app.seb3thehacker.gearslip.car.CarSettings import app.seb3thehacker.gearslip.car.CarSettings
import app.seb3thehacker.gearslip.stats.UsageStats
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -59,11 +60,13 @@ private val FIRST_DRIVE = listOf(
), ),
) )
private fun inTheCar(onRequestCallScreening: () -> Unit) = listOf( private fun inTheCar(grapheneOs: Boolean, onRequestCallScreening: () -> Unit) = listOfNotNull(
Step( Step(
R.string.help_plug_in, R.string.help_plug_in,
R.string.help_plug_in_detail, R.string.help_plug_in_detail,
), ),
// GrapheneOS can turn off data on the USB port, and then the car never sees the phone (#71).
if (grapheneOs) Step(R.string.help_usb_data, R.string.help_usb_data_detail) else null,
Step( Step(
R.string.help_pair, R.string.help_pair,
R.string.help_pair_detail, R.string.help_pair_detail,
@@ -93,9 +96,9 @@ private val USB_AUDIO = listOf(
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun HelpScreen(onBack: () -> Unit, onRequestCallScreening: () -> Unit) { fun HelpScreen(onBack: () -> Unit, onRequestCallScreening: () -> Unit) {
val car = remember(onRequestCallScreening) { inTheCar(onRequestCallScreening) }
val usb by CarSettings.pipeAudio.collectAsState()
val context = LocalContext.current val context = LocalContext.current
val car = remember(onRequestCallScreening) { inTheCar(UsageStats.isGrapheneOs(context), onRequestCallScreening) }
val usb by CarSettings.pipeAudio.collectAsState()
Scaffold( Scaffold(
containerColor = MaterialTheme.colorScheme.background, containerColor = MaterialTheme.colorScheme.background,
@@ -4,19 +4,23 @@ import androidx.annotation.StringRes
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import app.seb3thehacker.gearslip.R import app.seb3thehacker.gearslip.R
import app.seb3thehacker.gearslip.car.theme.DarkStyle import app.seb3thehacker.gearslip.car.theme.DarkStyle
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@@ -24,25 +28,33 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import app.seb3thehacker.gearslip.car.AppTheme import app.seb3thehacker.gearslip.car.AppTheme
import app.seb3thehacker.gearslip.car.CarApps import app.seb3thehacker.gearslip.car.CarApps
import app.seb3thehacker.gearslip.car.LauncherMode
import app.seb3thehacker.gearslip.car.CarSettings import app.seb3thehacker.gearslip.car.CarSettings
import app.seb3thehacker.gearslip.car.LauncherCache
import app.seb3thehacker.gearslip.car.NavBarMode import app.seb3thehacker.gearslip.car.NavBarMode
import app.seb3thehacker.gearslip.car.NightMode import app.seb3thehacker.gearslip.car.NightMode
import app.seb3thehacker.gearslip.car.Units import app.seb3thehacker.gearslip.car.Units
import app.seb3thehacker.gearslip.car.theme.GsThemes import app.seb3thehacker.gearslip.car.theme.GsThemes
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/** The car's setting groups, each opening its own screen from the phone's home screen. */ /** The car's setting groups, each opening its own screen from the phone's home screen. */
enum class CarGroup(@param:StringRes val title: Int) { enum class CarGroup(@param:StringRes val title: Int) {
APPS(R.string.crumb_apps),
SCREEN(R.string.car_group_screen), SCREEN(R.string.car_group_screen),
NAV_BAR(R.string.car_group_nav_bar), NAV_BAR(R.string.car_group_nav_bar),
SOUND(R.string.car_group_sound), SOUND(R.string.car_group_sound),
@@ -54,6 +66,8 @@ enum class CarGroup(@param:StringRes val title: Int) {
@Composable @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)) 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))
@Composable @Composable
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)) 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 @Composable
private fun darkChoices() = listOf( private fun darkChoices() = listOf(
@@ -73,7 +87,11 @@ private fun navBarChoices() = listOf(
NavBarMode.HIDE to stringResource(R.string.choice_nav_hide), NavBarMode.HIDE to stringResource(R.string.choice_nav_hide),
) )
@Composable @Composable
private fun openChoices() = listOf(CarSettings.HOME to stringResource(R.string.choice_open_home)) + CarApps.all.filter { it.id != "settings" }.map { it.id to stringResource(it.labelRes) } private fun openChoices(): List<Pair<String, String>> {
val context = LocalContext.current
val apps = remember(context) { CarApps.shown(LauncherMode.enabled(context), LauncherMode.canCall(context)) }
return listOf(CarSettings.HOME to stringResource(R.string.choice_open_home)) + apps.map { it.id to stringResource(it.labelRes) }
}
/** /**
* The car's settings on the phone's home screen: one card per group with a summary of where it * The car's settings on the phone's home screen: one card per group with a summary of where it
@@ -102,6 +120,7 @@ internal fun HomeCarSettings(onOpen: (CarGroup) -> Unit, modifier: Modifier = Mo
// Read again each time Home shows, so a place saved a moment ago is listed. // Read again each time Home shows, so a place saved a moment ago is listed.
val places = androidx.compose.runtime.remember { app.seb3thehacker.gearslip.car.SavedPlaces.all(context) } val places = androidx.compose.runtime.remember { app.seb3thehacker.gearslip.car.SavedPlaces.all(context) }
fun summary(group: CarGroup) = when (group) { fun summary(group: CarGroup) = when (group) {
CarGroup.APPS -> listOf(str(R.string.car_apps_sub))
CarGroup.SCREEN -> listOf(opens.label(open), sizes.label(scale), themes.label(theme)) CarGroup.SCREEN -> listOf(opens.label(open), sizes.label(scale), themes.label(theme))
CarGroup.NAV_BAR -> listOf(navBars.label(navBar), str(if (playerLeft) R.string.summary_player_left else R.string.summary_player_right)) CarGroup.NAV_BAR -> listOf(navBars.label(navBar), str(if (playerLeft) R.string.summary_player_left else R.string.summary_player_right))
CarGroup.SOUND -> listOf( CarGroup.SOUND -> listOf(
@@ -181,7 +200,8 @@ internal fun CarGroupScreen(group: CarGroup, onBack: () -> Unit) {
} }
else -> Unit else -> Unit
} }
Text( // The car's own Settings has no list of apps.
if (group != CarGroup.APPS) Text(
stringResource(R.string.car_group_same), stringResource(R.string.car_group_same),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -194,11 +214,13 @@ internal fun CarGroupScreen(group: CarGroup, onBack: () -> Unit) {
@Composable @Composable
private fun GroupRows(group: CarGroup) { private fun GroupRows(group: CarGroup) {
val scale by CarSettings.scale.collectAsState() val scale by CarSettings.scale.collectAsState()
val density by CarSettings.carDensity.collectAsState()
val open by CarSettings.openOnConnect.collectAsState() val open by CarSettings.openOnConnect.collectAsState()
val night by CarSettings.nightMode.collectAsState() val night by CarSettings.nightMode.collectAsState()
val units by CarSettings.units.collectAsState() val units by CarSettings.units.collectAsState()
val navBar by CarSettings.navBarMode.collectAsState() val navBar by CarSettings.navBarMode.collectAsState()
val pipeAudio by CarSettings.pipeAudio.collectAsState() val pipeAudio by CarSettings.pipeAudio.collectAsState()
val directionsOverMusic by CarSettings.directionsOverMusic.collectAsState()
val speakDirections by CarSettings.speakDirections.collectAsState() val speakDirections by CarSettings.speakDirections.collectAsState()
val systemSpeech by CarSettings.systemSpeech.collectAsState() val systemSpeech by CarSettings.systemSpeech.collectAsState()
val bluetoothWarning by CarSettings.bluetoothWarning.collectAsState() val bluetoothWarning by CarSettings.bluetoothWarning.collectAsState()
@@ -216,11 +238,14 @@ private fun GroupRows(group: CarGroup) {
val clusterVideo by CarSettings.clusterVideoEnabled.collectAsState() val clusterVideo by CarSettings.clusterVideoEnabled.collectAsState()
when (group) { when (group) {
CarGroup.APPS -> AppRows()
CarGroup.SCREEN -> { CarGroup.SCREEN -> {
Choice(stringResource(R.string.car_open_on_connect), openChoices(), open, CarSettings::setOpenOnConnect) Choice(stringResource(R.string.car_open_on_connect), openChoices(), open, CarSettings::setOpenOnConnect)
SettingsDivider() SettingsDivider()
Choice(stringResource(R.string.car_size), sizeChoices(), scale, CarSettings::setScale) Choice(stringResource(R.string.car_size), sizeChoices(), scale, CarSettings::setScale)
SettingsDivider() SettingsDivider()
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) Choice(stringResource(R.string.car_app_theme), themeChoices(), theme, CarSettings::setAppTheme)
SettingsDivider() SettingsDivider()
if (theme != AppTheme.LIGHT) { if (theme != AppTheme.LIGHT) {
@@ -256,6 +281,15 @@ private fun GroupRows(group: CarGroup) {
CarSettings::setSpeakDirections, CarSettings::setSpeakDirections,
note = stringResource(R.string.car_spoken_directions_note), note = stringResource(R.string.car_spoken_directions_note),
) )
if (speakDirections) {
SettingsDivider()
Toggle(
stringResource(R.string.car_directions_over_music),
directionsOverMusic,
CarSettings::setDirectionsOverMusic,
note = stringResource(R.string.car_directions_over_music_note),
)
}
SettingsDivider() SettingsDivider()
Toggle( Toggle(
stringResource(R.string.car_phone_speech), stringResource(R.string.car_phone_speech),
@@ -303,15 +337,38 @@ private fun GroupRows(group: CarGroup) {
} }
} }
/** One row per tile the installed apps put in the car's drawer, ticked while it shows there. */
@Composable
private fun AppRows() {
val context = androidx.compose.ui.platform.LocalContext.current
val apps by LauncherCache.state.collectAsState()
val hidden by CarSettings.hiddenApps.collectAsState()
// Its own scan, so an app installed since the last drive is here to choose.
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))
rows.forEachIndexed { i, (id, entry) ->
val shown = id !in hidden
if (i > 0) SettingsDivider()
SettingsRow(
entry.label,
Modifier.toggleable(shown, role = Role.Checkbox) { CarSettings.setAppHidden(id, !it) },
leading = { entry.icon?.let { Image(it, contentDescription = null, Modifier.size(40.dp)) } },
trailing = { Checkbox(checked = shown, onCheckedChange = null) },
)
}
}
private fun <T> List<Pair<T, String>>.label(value: T): String = firstOrNull { it.first == value }?.second.orEmpty() private fun <T> List<Pair<T, String>>.label(value: T): String = firstOrNull { it.first == value }?.second.orEmpty()
/** A setting with a few named choices: its current one shown, the rest a tap away. */ /** A setting with a few named choices: its current one shown, the rest a tap away. */
@Composable @Composable
private fun <T> Choice(title: String, choices: List<Pair<T, String>>, selected: T, onSelect: (T) -> Unit) { private fun <T> Choice(title: String, choices: List<Pair<T, String>>, selected: T, onSelect: (T) -> Unit, note: String? = null) {
var menu by remember { mutableStateOf(false) } var menu by remember { mutableStateOf(false) }
SettingsRow( SettingsRow(
title, title,
Modifier.clickable { menu = true }, Modifier.clickable { menu = true },
subtitle = note,
trailing = { trailing = {
// The menu hangs from the value it changes, at the row's end. // The menu hangs from the value it changes, at the row's end.
Box { Box {
@@ -25,6 +25,15 @@ import app.seb3thehacker.gearslip.car.CarSettings
import app.seb3thehacker.gearslip.car.BluetoothAudioCheck import app.seb3thehacker.gearslip.car.BluetoothAudioCheck
import android.provider.Settings import android.provider.Settings
import android.content.Intent import android.content.Intent
import android.content.BroadcastReceiver
import android.content.IntentFilter
import android.os.BatteryManager
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import app.seb3thehacker.gearslip.stats.UsageStats
import kotlinx.coroutines.delay
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@@ -258,6 +267,9 @@ private fun StatusCard(status: SessionStatus.Snapshot) {
} }
val live = status.phase == SessionStatus.Phase.CONNECTING || status.phase == SessionStatus.Phase.PROJECTING val live = status.phase == SessionStatus.Phase.CONNECTING || status.phase == SessionStatus.Phase.PROJECTING
val context = LocalContext.current
val grapheneOs = remember { UsageStats.isGrapheneOs(context) }
val noCar = chargingWithNoCar(status.phase == SessionStatus.Phase.IDLE)
Surface(color = container, contentColor = content, shape = MaterialTheme.shapes.extraLarge) { Surface(color = container, contentColor = content, shape = MaterialTheme.shapes.extraLarge) {
Column(Modifier.fillMaxWidth().padding(24.dp)) { Column(Modifier.fillMaxWidth().padding(24.dp)) {
Row( Row(
@@ -286,9 +298,20 @@ private fun StatusCard(status: SessionStatus.Snapshot) {
SessionStatus.Phase.FAILED -> status.headline SessionStatus.Phase.FAILED -> status.headline
} }
val detail = when (status.phase) { val detail = when (status.phase) {
SessionStatus.Phase.IDLE -> stringResource(R.string.status_idle_detail) SessionStatus.Phase.IDLE -> stringResource(
when {
!noCar -> R.string.status_idle_detail
grapheneOs -> R.string.status_no_car_graphene
else -> R.string.status_no_car
},
)
SessionStatus.Phase.PROJECTING -> stringResource(R.string.status_projecting_detail) SessionStatus.Phase.PROJECTING -> stringResource(R.string.status_projecting_detail)
SessionStatus.Phase.DISCONNECTED -> stringResource(R.string.status_disconnected_detail) SessionStatus.Phase.DISCONNECTED -> stringResource(R.string.status_disconnected_detail)
SessionStatus.Phase.CONNECTING -> when (status.stuckOn) {
SessionStatus.Link.CABLE -> stringResource(R.string.status_stuck_cable)
SessionStatus.Link.WIRELESS -> stringResource(R.string.status_stuck_wireless)
null -> status.detail
}
else -> status.detail else -> status.detail
} }
Text(headline, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.SemiBold) Text(headline, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.SemiBold)
@@ -296,6 +319,13 @@ private fun StatusCard(status: SessionStatus.Snapshot) {
Text(detail, style = MaterialTheme.typography.bodyMedium) Text(detail, style = MaterialTheme.typography.bodyMedium)
} }
} }
// GrapheneOS keeps its USB-C port setting under Security & privacy.
if (noCar && grapheneOs) {
Spacer(Modifier.height(12.dp))
Button(onClick = {
runCatching { context.startActivity(Intent(Settings.ACTION_SECURITY_SETTINGS)) }
}) { Text(stringResource(R.string.status_open_settings)) }
}
// The other way in: wireless, right under the cable's instructions. // The other way in: wireless, right under the cable's instructions.
if (!live) { if (!live) {
Spacer(Modifier.height(20.dp)) Spacer(Modifier.height(20.dp))
@@ -305,6 +335,37 @@ private fun StatusCard(status: SessionStatus.Snapshot) {
} }
} }
/**
* True once the phone has charged over a cable for [NO_CAR_MS] while Gearslip sat idle. With
* USB data blocked, by a charge-only cable or GrapheneOS's port setting, Android never tells
* Gearslip a car is there, so charging with no car is the only sign there is. A wall charger
* looks the same, which is why the hint only asks the driver to check.
*/
@Composable
private fun chargingWithNoCar(idle: Boolean): Boolean {
val context = LocalContext.current
var plugged by remember { mutableStateOf(false) }
DisposableEffect(context) {
val receiver = object : BroadcastReceiver() {
override fun onReceive(c: Context, intent: Intent) {
val how = intent.getIntExtra(BatteryManager.EXTRA_PLUGGED, 0)
plugged = how == BatteryManager.BATTERY_PLUGGED_USB || how == BatteryManager.BATTERY_PLUGGED_AC
}
}
// The battery broadcast is sticky, so registering hands back the current state at once.
context.registerReceiver(receiver, IntentFilter(Intent.ACTION_BATTERY_CHANGED))?.let { receiver.onReceive(context, it) }
onDispose { context.unregisterReceiver(receiver) }
}
var waited by remember { mutableStateOf(false) }
LaunchedEffect(idle && plugged) {
waited = false
if (idle && plugged) { delay(NO_CAR_MS); waited = true }
}
return idle && plugged && waited
}
private const val NO_CAR_MS = 10_000L
/** The version shown on screen, straight from the manifest - one place to bump per release. */ /** The version shown on screen, straight from the manifest - one place to bump per release. */
private fun Context.appVersionName(): String = private fun Context.appVersionName(): String =
runCatching { packageManager.getPackageInfo(packageName, 0).versionName }.getOrNull() ?: "?" runCatching { packageManager.getPackageInfo(packageName, 0).versionName }.getOrNull() ?: "?"
@@ -91,6 +91,7 @@ internal fun SettingsRow(
icon: ImageVector? = null, icon: ImageVector? = null,
tint: Color = MaterialTheme.colorScheme.primary, tint: Color = MaterialTheme.colorScheme.primary,
titleColor: Color = MaterialTheme.colorScheme.onSurface, titleColor: Color = MaterialTheme.colorScheme.onSurface,
leading: (@Composable () -> Unit)? = null,
trailing: (@Composable () -> Unit)? = null, trailing: (@Composable () -> Unit)? = null,
) { ) {
Row( Row(
@@ -99,6 +100,7 @@ internal fun SettingsRow(
horizontalArrangement = Arrangement.spacedBy(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
if (icon != null) Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(24.dp)) if (icon != null) Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(24.dp))
leading?.invoke()
Column(Modifier.weight(1f)) { Column(Modifier.weight(1f)) {
Text(title, style = MaterialTheme.typography.bodyLarge, color = titleColor) Text(title, style = MaterialTheme.typography.bodyLarge, color = titleColor)
subtitle?.let { subtitle?.let {
@@ -1,7 +1,9 @@
package app.seb3thehacker.gearslip.ui package app.seb3thehacker.gearslip.ui
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import app.seb3thehacker.gearslip.R import app.seb3thehacker.gearslip.R
import app.seb3thehacker.gearslip.car.LauncherMode
import androidx.compose.runtime.withFrameNanos import androidx.compose.runtime.withFrameNanos
import android.net.Uri import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
@@ -18,6 +20,8 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.toggleable import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.text.input.KeyboardCapitalization
import app.seb3thehacker.gearslip.car.CarBluetooth
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
@@ -32,6 +36,7 @@ import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.filled.Warning import androidx.compose.material.icons.filled.Warning
@@ -136,6 +141,7 @@ fun SettingsScreen(
icon = Icons.Filled.Star, icon = Icons.Filled.Star,
trailing = { Arrow() }, trailing = { Arrow() },
) )
LanguageRow()
} }
SettingsSection( SettingsSection(
stringResource(R.string.settings_support), stringResource(R.string.settings_support),
@@ -167,6 +173,35 @@ private fun Arrow() = Icon(
tint = MaterialTheme.colorScheme.onSurfaceVariant, tint = MaterialTheme.colorScheme.onSurfaceVariant,
) )
/**
* Android 13 and later choose an app's language in their own settings, from the languages
* Gearslip declares; this opens that page, which few people find on their own (issue #51).
*/
@Composable
private fun LanguageRow() {
if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.TIRAMISU) return
val context = LocalContext.current
// A new choice arrives as a configuration change, since the manifest keeps the activity.
val chosen = remember(androidx.compose.ui.platform.LocalConfiguration.current) {
context.getSystemService(android.app.LocaleManager::class.java)?.applicationLocales?.get(0)
}
SettingsDivider()
SettingsRow(
stringResource(R.string.settings_language),
Modifier.clickable {
runCatching {
context.startActivity(
android.content.Intent(android.provider.Settings.ACTION_APP_LOCALE_SETTINGS, Uri.fromParts("package", context.packageName, null)),
)
}
},
subtitle = chosen?.let { it.getDisplayName(it).replaceFirstChar { c -> c.titlecase(it) } }
?: stringResource(R.string.settings_language_phone),
icon = androidx.compose.ui.graphics.vector.ImageVector.vectorResource(R.drawable.ic_language),
trailing = { Arrow() },
)
}
/** The setup guide replay, and where Gearslip lives outside the app. */ /** The setup guide replay, and where Gearslip lives outside the app. */
@Composable @Composable
private fun AboutSection(onReplayTutorial: () -> Unit) { private fun AboutSection(onReplayTutorial: () -> Unit) {
@@ -199,7 +234,67 @@ private fun ConnectionSection() {
icon = Icons.Filled.Settings, icon = Icons.Filled.Settings,
trailing = { Switch(checked = enabled, onCheckedChange = null) }, trailing = { Switch(checked = enabled, onCheckedChange = null) },
) )
// For a head unit or a tablet on the dash: the car UI becomes the device's home screen.
var launcher by remember { mutableStateOf(LauncherMode.enabled(context)) }
SettingsRow(
stringResource(R.string.settings_use_as_launcher),
Modifier.toggleable(launcher, role = Role.Switch) {
launcher = it
LauncherMode.setEnabled(context, it)
// Android doesn't switch home apps by itself; this is where the driver picks Gearslip.
if (it) LauncherMode.openHomeChoice(context)
},
subtitle = stringResource(if (launcher) R.string.settings_use_as_launcher_on else R.string.settings_use_as_launcher_off),
icon = Icons.Filled.Home,
trailing = { Switch(checked = launcher, onCheckedChange = null) },
)
var address by remember { mutableStateOf(CarBluetooth.typedAddress(context)) }
var editing by remember { mutableStateOf(false) }
SettingsRow(
stringResource(R.string.settings_bt_address),
Modifier.clickable { editing = true },
subtitle = address ?: stringResource(R.string.settings_bt_address_off),
icon = Icons.Filled.Info,
)
if (editing) {
AddressDialog(
initial = address.orEmpty(),
onDismiss = { editing = false },
onSave = { typed ->
CarBluetooth.setTypedAddress(context, typed).also { saved ->
if (saved) {
address = CarBluetooth.typedAddress(context)
editing = false
} }
}
},
)
}
}
}
/** The phone's Bluetooth address, typed in by hand: Android doesn't let apps read it. */
@Composable
private fun AddressDialog(initial: String, onDismiss: () -> Unit, onSave: (String) -> Boolean) {
var text by remember { mutableStateOf(initial) }
var invalid by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.settings_bt_address)) },
text = {
OutlinedTextField(
value = text,
onValueChange = { text = it; invalid = false },
singleLine = true,
placeholder = { Text("12:34:56:78:9A:BC") },
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Characters, autoCorrectEnabled = false),
isError = invalid,
supportingText = { Text(stringResource(if (invalid) R.string.settings_bt_address_invalid else R.string.settings_bt_address_hint)) },
)
},
confirmButton = { TextButton(onClick = { invalid = !onSave(text) }) { Text(stringResource(R.string.save)) } },
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } },
)
} }
/** Tools for testing a debug build; release builds never show this section. */ /** Tools for testing a debug build; release builds never show this section. */
@@ -116,6 +116,21 @@ internal fun WirelessButton() {
} }
.padding(vertical = 14.dp), .padding(vertical = 14.dp),
) )
// A car the driver once said no to never asks again, so offer the way back here.
if (app.seb3thehacker.gearslip.wireless.WirelessAuto.declinedEarlier(context, device)) {
Text(
stringResource(R.string.wireless_auto_off),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
TextButton(
onClick = {
devices = null
app.seb3thehacker.gearslip.wireless.WirelessAuto.askAgain(context, device)
},
modifier = Modifier.padding(bottom = 4.dp),
) { Text(stringResource(R.string.wireless_auto_turn_on)) }
}
} }
// The car has to be paired before it shows up here, and pairing happens in Android's settings. // The car has to be paired before it shows up here, and pairing happens in Android's settings.
if (list.isNotEmpty()) { if (list.isNotEmpty()) {
@@ -72,6 +72,20 @@ object WirelessAuto {
}, Handler(Looper.getMainLooper())) }, Handler(Looper.getMainLooper()))
} }
/** True when the driver said no to this car connecting by itself, and it still can't. */
fun declinedEarlier(context: Context, device: BluetoothDevice): Boolean {
val manager = context.getSystemService(CompanionDeviceManager::class.java) ?: return false
return !isCompanion(manager, device.address) &&
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getBoolean(device.address, false)
}
/** Forgets the earlier no and shows Android's dialog for [device] again. */
fun askAgain(context: Context, device: BluetoothDevice) {
log.i("auto-connect: asking again about ${WirelessLink.name(device)}")
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().remove(device.address).apply()
connectedByHand(context, device)
}
/** The driver allowed it in Android's dialog. */ /** The driver allowed it in Android's dialog. */
fun approved(context: Context, address: String) { fun approved(context: Context, address: String) {
log.i("auto-connect: allowed") log.i("auto-connect: allowed")
@@ -0,0 +1,57 @@
package app.seb3thehacker.gearslip.wireless
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.IBinder
import app.seb3thehacker.gearslip.GearslipLog
/**
* Keeps Gearslip in the foreground while a wireless car is connected, from the moment its
* Bluetooth channel opens until the drive ends. Android treats an app in the background as
* unwanted and may refuse its Wi-Fi request or kill it, which is what happens to a start-up with
* the phone locked in a pocket. It does no work of its own; the notification is the cost.
*/
class WirelessKeepAlive : Service() {
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val manager = getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(
NotificationChannel(CHANNEL, "Wireless car connection", NotificationManager.IMPORTANCE_LOW),
)
val notification = Notification.Builder(this, CHANNEL)
.setSmallIcon(android.R.drawable.stat_sys_data_bluetooth)
.setContentTitle("Gearslip is connected to the car wirelessly")
.setOngoing(true)
.build()
// Refused if Android thinks Gearslip may not start one now; the start-up carries on
// without it, as it did before this service existed.
runCatching {
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE)
}.onFailure {
GearslipLog.w("wireless keep-alive: could not start the foreground service - ${it.message}")
stopSelf()
}
return START_NOT_STICKY
}
companion object {
private const val CHANNEL = "gearslip_wireless"
private const val NOTIFICATION_ID = 45
fun start(context: Context) {
runCatching { context.startForegroundService(Intent(context, WirelessKeepAlive::class.java)) }
.onFailure { GearslipLog.w("wireless keep-alive: start refused - ${it.message}") }
}
fun stop(context: Context) {
runCatching { context.stopService(Intent(context, WirelessKeepAlive::class.java)) }
}
}
}
@@ -73,6 +73,9 @@ object WirelessLink {
private const val LISTEN_MS = 120_000 // time to reconnect the phone from the car's Bluetooth menu private const val LISTEN_MS = 120_000 // time to reconnect the phone from the car's Bluetooth menu
private const val TCP_TRIES = 12 // about a minute: a car's projection listener can start well after its Wi-Fi private const val TCP_TRIES = 12 // about a minute: a car's projection listener can start well after its Wi-Fi
private const val WIFI_WAIT_MS = 60_000 private const val WIFI_WAIT_MS = 60_000
private const val KNOCK_MS = 8_000L // some cars publish their service only a while after Bluetooth connects
private const val START_RETRIES = 3
private const val RETRY_PAUSE_MS = 2_000L
/** Set by the activity: starts a session on the car's socket. */ /** Set by the activity: starts a session on the car's socket. */
@Volatile var onConnected: ((Socket) -> Unit)? = null @Volatile var onConnected: ((Socket) -> Unit)? = null
@@ -91,17 +94,21 @@ object WirelessLink {
@Volatile private var activeSsid: String? = null @Volatile private var activeSsid: String? = null
/** Bumped on each Wi-Fi join and on stop, so an older run of TCP tries ends instead of racing the new one. */ /** 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 @Volatile private var tcpRun = 0
/** The socket reached the car's projection: whatever ends the start-up now, it isn't retried. */
@Volatile private var handedOver = false
/** A failure starting over can't fix, such as a VPN in the way. */
@Volatile private var noRetry = false
private val writeLock = Any() private val writeLock = Any()
/** Paired Bluetooth devices, cars or not: the driver picks theirs. Needs BLUETOOTH_CONNECT. */ /** Paired Bluetooth devices, cars or not: the driver picks theirs. Needs BLUETOOTH_CONNECT. */
@SuppressLint("MissingPermission") @SuppressLint("MissingPermission")
fun pairedDevices(context: Context): List<BluetoothDevice> = fun pairedDevices(context: Context): List<BluetoothDevice> =
context.getSystemService(BluetoothManager::class.java)?.adapter?.bondedDevices context.getSystemService(BluetoothManager::class.java)?.adapter?.bondedDevices
?.sortedBy { it.name ?: it.address } ?.sortedBy { name(it) }
.orEmpty() .orEmpty()
@SuppressLint("MissingPermission") @SuppressLint("MissingPermission")
fun name(device: BluetoothDevice): String = device.name ?: device.address fun name(device: BluetoothDevice): String = device.alias ?: device.name ?: device.address
fun start(context: Context, device: BluetoothDevice) { fun start(context: Context, device: BluetoothDevice) {
stop(context) stop(context)
@@ -109,7 +116,7 @@ object WirelessLink {
worker = Thread({ worker = Thread({
// A test feature: anything unexpected ends the attempt, not the app. // A test feature: anything unexpected ends the attempt, not the app.
try { try {
run(app, device) startUp(app, device)
} catch (e: Exception) { } catch (e: Exception) {
log.e("wireless start-up failed", e) log.e("wireless start-up failed", e)
SessionStatus.failed("Wireless start-up failed", "${e.javaClass.simpleName}: ${e.message}") SessionStatus.failed("Wireless start-up failed", "${e.javaClass.simpleName}: ${e.message}")
@@ -117,12 +124,66 @@ object WirelessLink {
}, "gearslip-wireless").apply { isDaemon = true; start() } }, "gearslip-wireless").apply { isDaemon = true; start() }
} }
/**
* A step that fails after the car's Bluetooth channel opened (the channel closing, the Wi-Fi
* join, TCP) often works the second time, so the start-up begins again, up to
* [START_RETRIES] times. Waiting for a car that never called isn't repeated: that wait is
* two minutes already.
*/
private fun startUp(context: Context, device: BluetoothDevice) {
val me = Thread.currentThread()
try {
tryAndRetry(context, device, me)
} finally {
// A newer start-up has the service now; stop() already ended this one's.
if (worker === me) WirelessKeepAlive.stop(context)
}
}
private fun tryAndRetry(context: Context, device: BluetoothDevice, me: Thread) {
var tries = 0
while (true) {
handedOver = false
noRetry = false
val reached = run(context, device)
if (worker !== me || handedOver || noRetry || !reached) return
if (++tries > START_RETRIES) {
log.w("start-up failed ${tries} times; giving up")
return
}
log.i("start-up failed before the car's screen came up; starting over ($tries of $START_RETRIES)")
release(context)
SessionStatus.connecting("Starting over with ${name(device)} ($tries of $START_RETRIES)")
try {
Thread.sleep(RETRY_PAUSE_MS)
} catch (_: InterruptedException) {
return
}
if (worker !== me) return
}
}
/** Ends this try: the Bluetooth read loop stops, and [startUp] decides whether to go again. */
private fun endTry() {
runCatching { rfcomm?.close() }
}
/** Closes the Bluetooth channel, the socket and the Wi-Fi request. Safe to call any time. */ /** Closes the Bluetooth channel, the socket and the Wi-Fi request. Safe to call any time. */
fun stop(context: Context) { fun stop(context: Context) {
tcpRun++
val starting = worker != null && SessionStatus.state.value.phase == SessionStatus.Phase.CONNECTING val starting = worker != null && SessionStatus.state.value.phase == SessionStatus.Phase.CONNECTING
worker?.interrupt() worker?.interrupt()
worker = null worker = null
release(context)
WirelessKeepAlive.stop(context)
// Stopped before the car's session began, so nothing else resets Home's card.
if (starting) {
log.i("wireless start-up stopped")
SessionStatus.disconnected()
}
}
private fun release(context: Context) {
tcpRun++
runCatching { listener?.close() } runCatching { listener?.close() }
listener = null listener = null
runCatching { rfcomm?.close() } runCatching { rfcomm?.close() }
@@ -134,11 +195,6 @@ object WirelessLink {
} }
network = null network = null
activeSsid = null activeSsid = null
// Stopped before the car's session began, so nothing else resets Home's card.
if (starting) {
log.i("wireless start-up stopped")
SessionStatus.disconnected()
}
} }
/** A stable id for this phone as this app sees it, standing in for the serial a real phone sends. */ /** A stable id for this phone as this app sees it, standing in for the serial a real phone sends. */
@@ -146,8 +202,9 @@ object WirelessLink {
private fun deviceId(context: Context): String = private fun deviceId(context: Context): String =
Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID) ?: "gearslip" Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID) ?: "gearslip"
/** One try. True if the car's Bluetooth channel opened, so that a failure after it is worth retrying. */
@SuppressLint("MissingPermission") @SuppressLint("MissingPermission")
private fun run(context: Context, device: BluetoothDevice) { private fun run(context: Context, device: BluetoothDevice): Boolean {
val name = name(device) val name = name(device)
log.i("--- wireless start-up with $name (${device.address}) ---") log.i("--- wireless start-up with $name (${device.address}) ---")
SessionStatus.connecting("Bluetooth: reaching $name") SessionStatus.connecting("Bluetooth: reaching $name")
@@ -163,15 +220,17 @@ object WirelessLink {
"Gearslip needs the Nearby devices permission to reach $name. Allow it in the app's " + "Gearslip needs the Nearby devices permission to reach $name. Allow it in the app's " +
"settings, then try again.", "settings, then try again.",
) )
return return false
} catch (e: IOException) { } catch (e: IOException) {
// Most factory head units publish no service of their own: they look for the phone's // Most factory head units publish no service of their own: they look for the phone's
// and connect to it, so wait for the car instead. // and connect to it, so wait for the car instead.
log.i("Bluetooth: $name has no Android Auto service to call (${e.message}); waiting for it to call us") log.i("Bluetooth: $name has no Android Auto service to call (${e.message}); waiting for it to call us")
waitForCar(context, name) ?: return waitForCar(context, device, name) ?: return false
} }
rfcomm = socket rfcomm = socket
log.i("Bluetooth: channel open") log.i("Bluetooth: channel open")
// Before the Wi-Fi request: Android may refuse one from an app it sees as in the background.
WirelessKeepAlive.start(context)
SessionStatus.connecting("Bluetooth: talking to $name") SessionStatus.connecting("Bluetooth: talking to $name")
val input = DataInputStream(socket.inputStream) val input = DataInputStream(socket.inputStream)
@@ -190,14 +249,18 @@ object WirelessLink {
log.hex(" version", body) log.hex(" version", body)
// Laid out the way a real phone's reply is (aa-proxy-rs and LIVI both // Laid out the way a real phone's reply is (aa-proxy-rs and LIVI both
// parse it from real phones): 1 and 2 the version, echoing the car's; // parse it from real phones): 1 and 2 the version, echoing the car's;
// 3 a serial; 4 the status; 6 the device's ids. // 3 a serial; 4 the status; 5 the Wi-Fi channel type the car asked for;
// 6 the device's ids. Strict head units, such as Toyota's, hold back the
// StartRequest until the reply carries more than the status.
val f = Wire.fields(body) val f = Wire.fields(body)
val serial = deviceId(context) val serial = deviceId(context)
val ids = Protobuf.stringField(1, serial) + Protobuf.stringField(2, UUID.randomUUID().toString()) val ids = Protobuf.stringField(1, serial) + Protobuf.stringField(2, UUID.randomUUID().toString())
val channelType = Wire.varint(f, 3)
val reply = Protobuf.varintField(1, Wire.varint(f, 1) ?: 1) + val reply = Protobuf.varintField(1, Wire.varint(f, 1) ?: 1) +
Protobuf.varintField(2, Wire.varint(f, 2) ?: 0) + Protobuf.varintField(2, Wire.varint(f, 2) ?: 0) +
Protobuf.stringField(3, serial) + Protobuf.stringField(3, serial) +
Protobuf.varintField(4, STATUS_SUCCESS) + Protobuf.varintField(4, STATUS_SUCCESS) +
(channelType?.let { Protobuf.varintField(5, it) } ?: ByteArray(0)) +
Protobuf.varint(((6 shl 3) or 2).toLong()) + Protobuf.varint(ids.size.toLong()) + ids Protobuf.varint(((6 shl 3) or 2).toLong()) + Protobuf.varint(ids.size.toLong()) + ids
send(output, MSG_VERSION_RESPONSE, reply) send(output, MSG_VERSION_RESPONSE, reply)
log.i("-> VersionResponse") log.i("-> VersionResponse")
@@ -257,6 +320,7 @@ object WirelessLink {
} catch (e: IOException) { } catch (e: IOException) {
if (rfcomm != null) log.w("Bluetooth: channel closed (${e.message})") if (rfcomm != null) log.w("Bluetooth: channel closed (${e.message})")
} }
return true
} }
/** /**
@@ -265,7 +329,7 @@ object WirelessLink {
* Bluetooth, so the driver may have to reconnect the phone from the car's menu. * Bluetooth, so the driver may have to reconnect the phone from the car's menu.
*/ */
@SuppressLint("MissingPermission") @SuppressLint("MissingPermission")
private fun waitForCar(context: Context, name: String): BluetoothSocket? { private fun waitForCar(context: Context, device: BluetoothDevice, name: String): BluetoothSocket? {
val adapter = context.getSystemService(BluetoothManager::class.java)?.adapter ?: return null val adapter = context.getSystemService(BluetoothManager::class.java)?.adapter ?: return null
val server = try { val server = try {
adapter.listenUsingRfcommWithServiceRecord("Android Auto", AA_UUID) adapter.listenUsingRfcommWithServiceRecord("Android Auto", AA_UUID)
@@ -277,12 +341,40 @@ object WirelessLink {
listener = server listener = server
SessionStatus.connecting("Waiting for $name. If nothing happens, disconnect the phone in the car's Bluetooth menu and connect it again") SessionStatus.connecting("Waiting for $name. If nothing happens, disconnect the phone in the car's Bluetooth menu and connect it again")
log.i("Bluetooth: Android Auto service published, waiting ${LISTEN_MS / 1000}s for $name") log.i("Bluetooth: Android Auto service published, waiting ${LISTEN_MS / 1000}s for $name")
// Meanwhile, keep calling the car's own service: a car that publishes it late answers one
// of these instead of calling us. Whichever connects first is the channel.
val done = java.util.concurrent.atomic.AtomicBoolean(false)
val knocked = java.util.concurrent.atomic.AtomicReference<BluetoothSocket?>(null)
val knocker = Thread({
while (!done.get()) {
try {
Thread.sleep(KNOCK_MS)
} catch (_: InterruptedException) {
return@Thread
}
if (done.get()) return@Thread
val socket = runCatching { device.createRfcommSocketToServiceRecord(AA_UUID).also { it.connect() } }
.getOrNull() ?: continue
if (done.compareAndSet(false, true)) {
log.i("Bluetooth: $name's own Android Auto service answered")
knocked.set(socket)
runCatching { server.close() }
} else {
runCatching { socket.close() }
}
return@Thread
}
}, "gearslip-wireless-knock").apply { isDaemon = true; start() }
return try { return try {
server.accept(LISTEN_MS).also { server.accept(LISTEN_MS).also {
if (!done.compareAndSet(false, true)) runCatching { knocked.get()?.close() }
val from = runCatching { it.remoteDevice.name }.getOrNull() ?: "a device" val from = runCatching { it.remoteDevice.name }.getOrNull() ?: "a device"
log.i("Bluetooth: $from connected to the Android Auto service") log.i("Bluetooth: $from connected to the Android Auto service")
} }
} catch (e: IOException) { } catch (e: IOException) {
knocker.join(500)
knocked.get()?.let { return it }
done.set(true)
if (listener == null) return null // stopped if (listener == null) return null // stopped
log.w("Bluetooth: no car connected within ${LISTEN_MS / 1000}s (${e.message})") log.w("Bluetooth: no car connected within ${LISTEN_MS / 1000}s (${e.message})")
SessionStatus.failed( SessionStatus.failed(
@@ -292,6 +384,8 @@ object WirelessLink {
) )
null null
} finally { } finally {
done.set(true)
knocker.interrupt()
runCatching { server.close() } runCatching { server.close() }
listener = null listener = null
} }
@@ -351,6 +445,7 @@ object WirelessLink {
"Android didn't connect to $name's network. If a \"Connect to device\" prompt " + "Android didn't connect to $name's network. If a \"Connect to device\" prompt " +
"appeared, it has to be accepted.", "appeared, it has to be accepted.",
) )
endTry()
} }
// Android can hold an app's traffic back on a network it has joined (battery or data // Android can hold an app's traffic back on a network it has joined (battery or data
@@ -467,6 +562,7 @@ object WirelessLink {
socket.tcpNoDelay = true socket.tcpNoDelay = true
socket.connect(InetSocketAddress(ip, port), 5_000) socket.connect(InetSocketAddress(ip, port), 5_000)
tcp = socket tcp = socket
handedOver = true
log.i("TCP: connected to $ip:$port (try ${attempt + 1})") log.i("TCP: connected to $ip:$port (try ${attempt + 1})")
val start = onConnected val start = onConnected
if (start == null) { if (start == null) {
@@ -494,11 +590,14 @@ object WirelessLink {
"Gearslip joined $name's network, but a VPN on this phone won't let it reach the car. " + "Gearslip joined $name's network, but a VPN on this phone won't let it reach the car. " +
"Turn the VPN off, or exclude Gearslip in its settings, then try again.", "Turn the VPN off, or exclude Gearslip in its settings, then try again.",
) )
noRetry = true
endTry()
return return
} }
SessionStatus.failed( SessionStatus.failed(
"Car's Wi-Fi joined, but no connection", "Car's Wi-Fi joined, but no connection",
"Gearslip joined $name's network but couldn't reach Android Auto at $ip:$port.", "Gearslip joined $name's network but couldn't reach Android Auto at $ip:$port.",
) )
endTry()
} }
} }
@@ -0,0 +1,10 @@
<!-- Material Icons "language" (Apache 2.0). -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M11.99,2C6.47,2 2,6.48 2,12s4.47,10 9.99,10C17.52,22 22,17.52 22,12S17.52,2 11.99,2zM18.92,8h-2.95c-0.32,-1.25 -0.78,-2.45 -1.38,-3.56 1.84,0.63 3.37,1.91 4.33,3.56zM12,4.04c0.83,1.2 1.48,2.53 1.91,3.96h-3.82c0.43,-1.43 1.08,-2.76 1.91,-3.96zM4.26,14C4.1,13.36 4,12.69 4,12s0.1,-1.36 0.26,-2h3.38c-0.08,0.66 -0.14,1.32 -0.14,2 0,0.68 0.06,1.34 0.14,2L4.26,14zM5.08,16h2.95c0.32,1.25 0.78,2.45 1.38,3.56 -1.84,-0.63 -3.37,-1.9 -4.33,-3.56zM8.03,8L5.08,8c0.96,-1.66 2.49,-2.93 4.33,-3.56C8.81,5.55 8.35,6.75 8.03,8zM12,19.96c-0.83,-1.2 -1.48,-2.53 -1.91,-3.96h3.82c-0.43,1.43 -1.08,2.76 -1.91,3.96zM14.34,14L9.66,14c-0.09,-0.66 -0.16,-1.32 -0.16,-2 0,-0.68 0.07,-1.35 0.16,-2h4.68c0.09,0.65 0.16,1.32 0.16,2 0,0.68 -0.07,1.34 -0.16,2zM14.59,19.56c0.6,-1.11 1.06,-2.31 1.38,-3.56h2.95c-0.96,1.65 -2.49,2.93 -4.33,3.56zM16.36,14c0.08,-0.66 0.14,-1.32 0.14,-2 0,-0.68 -0.06,-1.34 -0.14,-2h3.38c0.16,0.64 0.26,1.31 0.26,2s-0.1,1.36 -0.26,2h-3.38z" />
</vector>
@@ -376,6 +376,8 @@
<string name="wireless_which_car">أي سيارة؟</string> <string name="wireless_which_car">أي سيارة؟</string>
<string name="wireless_none_paired">هاتفك غير مقترن بأي جهاز بعد. أقرنه بالسيارة أولًا.</string> <string name="wireless_none_paired">هاتفك غير مقترن بأي جهاز بعد. أقرنه بالسيارة أولًا.</string>
<string name="wireless_not_listed">السيارة غير موجودة في القائمة؟ أقرنها بهاتفك أولًا.</string> <string name="wireless_not_listed">السيارة غير موجودة في القائمة؟ أقرنها بهاتفك أولًا.</string>
<string name="wireless_auto_off">لا يتصل تلقائيًا</string>
<string name="wireless_auto_turn_on">اسمح له بالاتصال تلقائيًا</string>
<string name="bluetooth_settings">إعدادات البلوتوث</string> <string name="bluetooth_settings">إعدادات البلوتوث</string>
<!-- Voice models --> <!-- Voice models -->
@@ -861,4 +863,7 @@
<string name="places_finding">جارٍ البحث…</string> <string name="places_finding">جارٍ البحث…</string>
<string name="places_not_found">لم يعثر Gearslip على موقعك. تأكد من تشغيل الموقع.</string> <string name="places_not_found">لم يعثر Gearslip على موقعك. تأكد من تشغيل الموقع.</string>
<string name="places_clear">مسح</string> <string name="places_clear">مسح</string>
<string name="settings_language">اللغة</string>
<string name="settings_language_phone">مثل لغة الهاتف</string>
<string name="player_next_chapter">الفصل التالي</string>
</resources> </resources>
@@ -374,6 +374,8 @@
<string name="wireless_which_car">Které auto?</string> <string name="wireless_which_car">Které auto?</string>
<string name="wireless_none_paired">Telefon zatím není s ničím spárovaný. Nejdřív ho spárujte s autem.</string> <string name="wireless_none_paired">Telefon zatím není s ničím spárovaný. Nejdřív ho spárujte s autem.</string>
<string name="wireless_not_listed">Auto v seznamu není? Nejdřív ho spárujte s telefonem.</string> <string name="wireless_not_listed">Auto v seznamu není? Nejdřív ho spárujte s telefonem.</string>
<string name="wireless_auto_off">Nepřipojuje se samo</string>
<string name="wireless_auto_turn_on">Povolit automatické připojení</string>
<string name="bluetooth_settings">Nastavení Bluetooth</string> <string name="bluetooth_settings">Nastavení Bluetooth</string>
<!-- Voice models --> <!-- Voice models -->
@@ -845,4 +847,7 @@
<string name="places_finding">Hledám…</string> <string name="places_finding">Hledám…</string>
<string name="places_not_found">Gearslip nenašel vaši polohu. Zkontrolujte, že je poloha zapnutá.</string> <string name="places_not_found">Gearslip nenašel vaši polohu. Zkontrolujte, že je poloha zapnutá.</string>
<string name="places_clear">Vymazat</string> <string name="places_clear">Vymazat</string>
<string name="settings_language">Jazyk</string>
<string name="settings_language_phone">Stejný jako v telefonu</string>
<string name="player_next_chapter">Další kapitola</string>
</resources> </resources>
+260
View File
@@ -0,0 +1,260 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="touch_relay_description">Lad Gearslip afspille ved berøringer fra bilens skærm under telefon spejling. Gearslip læser ikke skærmens indhold.</string>
<string name="guide_usage_title">Hjælp andre bilister.</string>
<string name="guide_safety_title">Kør forsigtigt.</string>
<string name="guide_safety_body">Gearslip er uofficielt. Det er hverken udviklet eller testet af nogen bilproducent, og det kan svigte uden forvarsel. Indstil det, før du kører, og hold derefter øjnene på vejen.</string>
<string name="guide_mic_title">Mikrofon.</string>
<string name="guide_mic_body">Gearslip bruger den til at sende din musik til bilen og til at lytte når du spørger om noget eller svarer på en besked. Det du siger forbliver på din telefon.</string>
<string name="guide_location_title">Lokation.</string>
<string name="guide_location_body">Bilens kort og søgninger på ting i nærheden har brug for dette. Uden lokation vil der ikke være et kort.</string>
<string name="guide_calendar_title">Kalender.</string>
<string name="guide_calendar_body">Bilens skærm viser din næste aftale. Gearslip læser kun i din kalender.</string>
<string name="guide_calls_title">Opkald og kontakter.</string>
<string name="guide_calls_body">Bilen viser navnet på den der ringer, telefon appen kan lave opkald til et nummer eller sine kontakter. Gearslip læser dem kun.</string>
<string name="guide_bluetooth_title">Bluetooth.</string>
<string name="guide_bluetooth_body">Første gang du tilslutter Gearslip spørger den efter at parre din telefon med bilen, så du kan foretage opkald igennem bilen højtalere.</string>
<string name="guide_notifications_title">Notifikationer.</string>
<string name="guide_notifications_body">Imens Gearslip er forbundet vises notifikationer med knap til at slukke dem. Uden denne vil Android skulle dem.</string>
<string name="guide_messages_title">Beskeder.</string>
<string name="guide_messages_body">Bilen viser dine beskeder og læser dem højt, hvorefter du fpr mulighed for at svare. Gearslip skal startes i listen for at dette sker.</string>
<string name="guide_android_auto_title">Deaktiverer Android Auto.</string>
<string name="guide_android_auto_body">Android auto er stadig installeret og det kæmper med Gearslip om bilen USB forbindelse. Deaktiver eller afinstaller på næste skærm billede.</string>
<string name="guide_default_title">Vælg Gearslip.</string>
<string name="guide_default_body">Hvis din telefon spørg, hvilken app den skal åbne når du tilslutter bilen så vælg Gearslip og tryk på altid.</string>
<string name="guide_audio_title">Bil lyd.</string>
<string name="guide_audio_body">Vælg hvordan musik overføres til bilen. Opkaldt går altid over Bluetooth.</string>
<string name="guide_voice_title">Tale.</string>
<string name="guide_voice_body">Spørg efter rutevejledning, musik eller ring op med tale. Din telefon laver tale til tekst og intet du siger forlader den. Den har brug for at downloade %1$d MB en gang. Brug WiFi hvis du kan .</string>
<string name="guide_audio_prompt_title">Ingen lyd prompt.</string>
<string name="guide_audio_prompt_body">Kør dette fra din pc med USB debugging til, og Android acceptere Gearslip med lyd fremover. Ellers tap start ved alle kørsler.</string>
<string name="guide_readable_title">Læselige notifikationer.</string>
<string name="guide_readable_body">Mens lyden sendes til bilen, skjuler Android indholdet af notifikationerne, ligesom når du deler din skærm. På den måde kan Gearslip vise dem under kørslen og skjule dem igen bagefter.</string>
<string name="guide_answer_title">Besvar opkald.</string>
<string name="guide_answer_body">Gør Gearslip til din app til visning af opkalds-ID, så kan bilen besvare og afvise opkald. Uden dette ringer telefonen stadig, men det er kun telefonen, der kan besvare opkaldene.</string>
<string name="guide_apps_title">Godt sleskab.</string>
<string name="guide_buttons_title">Dine knapper.</string>
<string name="guide_buttons_body">Vælg, hvordan bilens knapper skal se ud. Du kan ændre det når som helst i bilens indstillinger.</string>
<string name="guide_open_notification_access">Åben notifikations indstillinger</string>
<string name="guide_open_android_auto">Åbn appoplysningerne for Android Auto</string>
<string name="guide_download_voice">Download tale</string>
<string name="guide_set_up">Opsætning</string>
<string name="guide_voice_downloading">Downloader, %1$d%%. Du kan bare fortsætte; det forsætter i baggrunden.</string>
<string name="guide_voice_ready">Tale er klar.</string>
<string name="guide_voice_failed">Downloaden blev ikke afsluttet. Tryk på Download stemme for at prøve igen.</string>
<string name="guide_audio_bluetooth">Bluetooth</string>
<string name="guide_recommended">Anbefalet</string>
<string name="guide_audio_bluetooth_body">Der vises ingen meddelelse ved hvert kørsel, og stemmen fra din kortapp kan høres i bilen.</string>
<string name="guide_audio_usb">Gennem Gearslip</string>
<string name="guide_audio_usb_body">Bedre lyd uden forsinkelse. Hver gang du starter appen, beder Android dig om at dele din skærm, hvilket gør det muligt for Gearslip at sende lyden.</string>
<string name="guide_optional">Valgfri</string>
<string name="guide_already_allowed">Allerede tilladt.</string>
<string name="guide_scroll_more">Rul ned for at se mere ↓</string>
<string name="guide_allow">Tillad</string>
<string name="guide_skip">Spring over</string>
<string name="guide_open">Åben</string>
<string name="guide_next">Næste</string>
<string name="guide_done">Færdig</string>
<string name="guide_back">Tilbage</string>
<string name="usage_intro">Aktivér denne funktion, så sender Gearslip en kort besked, når du åbner appen og efter hver køretur. &lt;b&gt;Beskeden indeholder hverken navn, kontooplysninger eller placering. Et tilfældigt tal tæller din telefon én gang, og serveren gemmer ingen IP-adresse.&lt;/b&gt; Beskederne viser, hvilke biler der fungerer, og hvor de andre svigter.</string>
<string name="usage_send">Send forbrug information</string>
<string name="usage_also_share">Del også</string>
<string name="usage_share_android">Android version og system</string>
<string name="usage_share_phone">Telefon model</string>
<string name="usage_share_cars">Hver bil og om den var tilsluttet</string>
<string name="usage_share_drives">Hvert tur og hvordan det endte</string>
<string name="usage_what_sent">Hvad bliver sendt</string>
<string name="usage_what_sent_sub">Hvert felt, præcis som det vises</string>
<string name="usage_hide_note">Skjul noten</string>
<string name="usage_show_note">Vis noten</string>
<string name="usage_example">Eksempel, ikke sendt: OneDrive føjer dette til \"sessioner\"</string>
<string name="usage_no_server">Denne build har ingen statistikserver, så den sender intet</string>
<string name="usage_debug_build">Debug-version: Noter holdes adskilt og medregnes aldrig i statistikken</string>
<string name="worked_title">Gearslip har arbejdet med dine %1$s</string>
<string name="worked_title_unknown">Gearslip virker med din bil</string>
<string name="worked_body">Del dette, så den næste bilist ved, at det virker? Ingen navn, konto eller placering.</string>
<string name="worked_not_now">Ikke nu</string>
<string name="worked_share">Del</string>
<string name="recommended_intro">Disse gratis apps kører på Gearslips bilskærm. Hver knap åbner appens side på GitHub.</string>
<string name="recommended_title">Anbefalede apps</string>
<string name="back">Tilbage</string>
<string name="recommended_installed">Installaret</string>
<string name="recommended_maps">Kort</string>
<string name="recommended_music">Musik</string>
<string name="recommended_podcasts">Podcast</string>
<string name="recommended_other">Andre seje apps</string>
<string name="recommended_about_organic">Offline-kort og ruteplanlægning.</string>
<string name="recommended_about_vela">Trin-for-trin-navigation uden Google.</string>
<string name="recommended_about_ytm">En YouTube Music-afspiller.</string>
<string name="recommended_about_antennapod">Podcasts, kapitler og alt det der.</string>
<string name="recommended_about_rfsentinel">Find sporingsenheder og kameraer i nærheden.</string>
<string name="settings_title">Indstillinger</string>
<string name="settings_whats_new">Hvad er nyt</string>
<string name="settings_whats_new_sub">Læs %1$s udgivelsesnoterne på GitHub</string>
<string name="settings_support">Støt Gearslip</string>
<string name="settings_support_footer">Gearslip er gratis, uden reklamer og uden sporing. Det er jeres støtte, der holder det kørende.</string>
<string name="settings_usage_notes">Bemærkninger til brugen</string>
<string name="settings_updates">Opdateringer</string>
<string name="settings_about">Om</string>
<string name="settings_replay">Se opsætningsvejledningen igen</string>
<string name="settings_replay_sub">Gennemfør trinene til første opstart igen</string>
<string name="settings_connection">Forbindelse</string>
<string name="settings_use_in_car">Brug Gearslip i bilen</string>
<string name="settings_use_in_car_on">Gearslip reagerer, når du tilslutter enheden til en bil</string>
<string name="settings_use_in_car_off">Fra: Android Auto kan i stedet oprette forbindelsen</string>
<string name="settings_apps">Programmer (Apps)</string>
<string name="settings_certificate">Certifikat</string>
<string name="cert_next_connection">Ændringerne træder i kraft ved næste forbindelse.</string>
<string name="cert_checking">Kontrollerer…</string>
<string name="cert_valid_until">Gyldig indtil %1$s</string>
<string name="cert_hide_details">Skjul detaljer</string>
<string name="cert_show_details">Vis detaljer</string>
<string name="cert_subject">Emne emne</string>
<string name="cert_issuer">Udsteder</string>
<string name="cert_downloading">Downloader…</string>
<string name="cert_download">Hent fra aasdk</string>
<string name="cert_downloaded">Certifikat downloadet.</string>
<string name="cert_download_failed">Download mislykkedes: %1$s</string>
<string name="cert_download_sub">Certifikatet udgives på GitHub. De fleste biler accepterer det, men nogle fra omkring 2020 for at afvise det</string>
<string name="cert_import">Importér min egen fil</string>
<string name="cert_import_sub">En .p12 fil med sin private nøgle. Brug din egen, når din bil afviser aasdk-certifikatet</string>
<string name="cert_remove_imported">Fjern importeret certifikat</string>
<string name="cert_remove_downloaded">Fjern downloadede certifikat</string>
<string name="cert_removed_imported">Fjernet det importerede certifikat.</string>
<string name="cert_removed_downloaded">Fjernet den downloadede certifikat.</string>
<string name="cert_imported">Certifikat importeret.</string>
<string name="cert_import_failed">Kan ikke importere: forkert adgangskode eller ikke en PKCS#12-fil med en privat nøgle.</string>
<string name="cert_password_title">Indtast adgangskode</string>
<string name="cert_password">Adgangskode nulstilling</string>
<string name="cert_password_hint">Efterlad tom, hvis filen ikke har nogen informationer.</string>
<string name="cancel">Annuller</string>
<string name="cert_none">Ingen indlæst (selv-signed; biler afviser det)</string>
<string name="cert_kind_imported">Importeret: %1$s</string>
<string name="cert_kind_downloaded">Downloadet: %1$s</string>
<string name="cert_kind_adb">Trinet over adb: %1$s</string>
<string name="cert_kind_self">Selvsigned: %1$s</string>
<string name="car_group_screen">Skærm</string>
<string name="car_group_nav_bar">Navigations bar og afspiller</string>
<string name="car_group_sound">Lyd og stemme</string>
<string name="car_group_warnings">Advarsel</string>
<string name="car_group_more">Mere</string>
<string name="choice_size_normal">Normal</string>
<string name="choice_size_large">Stor</string>
<string name="choice_size_xlarge">Ekstra stor</string>
<string name="choice_theme_phone">Følg telefonen</string>
<string name="choice_theme_light">Lys</string>
<string name="choice_theme_dark">Mørk</string>
<string name="choice_dark_black">Sort</string>
<string name="choice_dark_black_colour">Sort og farve</string>
<string name="choice_dark_deep_colour">Dyb farve</string>
<string name="choice_dark_grey">Grå</string>
<string name="choice_auto">Automatisk</string>
<string name="choice_night_day">Dag</string>
<string name="choice_night_night">Nat</string>
<string name="choice_units_imperial">Imperisk</string>
<string name="choice_units_metric">Metrisk</string>
<string name="choice_nav_show">Vis altid</string>
<string name="choice_nav_hide_navigating">Skjul mens du navigerer</string>
<string name="choice_nav_hide">Skjul indtil tryk</string>
<string name="choice_open_home">Hjem</string>
<string name="summary_player_left">Spiller på venstre side</string>
<string name="summary_player_right">Spiller på højre side</string>
<string name="summary_usb_audio">Lyd gennem Gearslip</string>
<string name="summary_bluetooth_audio">Bluetooth lyd</string>
<string name="summary_directions_on">Anvisninger til</string>
<string name="summary_directions_off">Anvisninger fra</string>
<string name="summary_bluetooth_on">Bluetooth til</string>
<string name="summary_bluetooth_off">Bluetooth fra</string>
<string name="summary_location_on">Lokation til</string>
<string name="summary_location_off">Lokation fra</string>
<string name="web_page">Webside</string>
<string name="summary_experimental_shown">Eksperimentelle funktioner vist</string>
<string name="summary_experimental_hidden">Eksperimentelle funktioner skjult</string>
<string name="home_car">Bil</string>
<string name="car_group_same">Disse er de samme indstillinger som Indstillinger i bilen. En ændring her viser der også.</string>
<string name="car_open_on_connect">Åben forbindelse ved tilslutning</string>
<string name="car_size">Størrelse</string>
<string name="car_app_theme">App tema</string>
<string name="car_dark_mode">Mørk tilstand</string>
<string name="car_buttons">Knapper</string>
<string name="car_map_day_night">Kort dag/nat</string>
<string name="car_units">Enheder</string>
<string name="car_nav_bar">Navigationsbar</string>
<string name="car_player_nav_bar">Afspiller i navigationsbaren</string>
<string name="car_skip_buttons">Spring over knapper</string>
<string name="car_song_title">Sang titel</string>
<string name="car_player_beside_map">Spiller ved siden af kortet</string>
<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>
<string name="car_phone_speech">Brug telefonens talegenkendelse</string>
<string name="car_phone_speech_note">På de fleste telefoner, der er Googles system, som kan sende din stemme til Google.</string>
<string name="car_warn_bluetooth">Advar når Bluetooth ikke er forbundet</string>
<string name="car_warn_location">Advar når lokation er slået fra</string>
<string name="car_experimental">Eksperimentelle funktioner</string>
<string name="car_vehicle_data">Køretøjsdata</string>
<string name="car_vehicle_data_note">Tilføjer en køretøjsdata knap: dump af bilens sensorer. Feltnavnene gættes, indtil du har set en ændring.</string>
<string name="car_adaptive_video">Tilpasset videokvalitet</string>
<string name="car_adaptive_video_note">Sænker billedets kvalitet, når bilen eller dens Wi-Fi ikke kan følge med, og hæver det igen, når den kan. Gælder fra den næste forbindelse.</string>
<string name="web_page_note">Vist af Browseren i bilen. Ikke angivet åbner DuckDuckGo. Gælder fra den næste forbindelse.</string>
<string name="car_audio">Bil lyd</string>
<string name="car_audio_bt">Bluetooth (anbefalet)</string>
<string name="car_audio_bt_sub">Der vises ingen meddelelse ved hvert kørsel, og stemmen fra din kortapp kan høres i bilen</string>
<string name="car_audio_usb_sub">Bedre lyd uden forsinkelse. Hver gang du starter appen, beder Android dig om at dele din skærm, hvilket gør det muligt for Gearslip at sende lyden</string>
<string name="car_audio_usb_footer">Kør kommandoen i Hjælp én gang, og Android stopper med at spørge.</string>
<string name="car_audio_bt_footer">Opkald går altid over Bluetooth.</string>
<string name="screen_car_size">Bil skærm størrelse</string>
<string name="screen_use_cars">Brug bilens</string>
<string name="screen_preview_size">Forhåndsvisningsstørrelse</string>
<string name="screen_preview_note">Størrelsen af bilens forhåndsvisning på skærmen, såsom 1920x720.</string>
<string name="screen_car_screen">Bil skærm</string>
<string name="screen_footer">Gearslip bruger den størrelse bilens rapporter, medmindre du indstiller en.</string>
<string name="screen_size">Skærmstørrelse</string>
<string name="screen_from_car">Fra bilen</string>
<string name="screen_crop">Beskær kanterne</string>
<string name="screen_crop_sub">Til en bil, der skærer top og bund, eller siderne af billedet</string>
<string name="screen_squash">Tilpas størrelsen</string>
<string name="screen_squash_sub">Til biler, der maser billedet, så cirkler ligner ovale</string>
<string name="screen_preview_title">Bilen forhåndsvisnings størrelse</string>
<string name="screen_last_car">Seneste bil</string>
<string name="screen_last_car_sub">Størrelsen af den sidste bil, du har forbindelse til</string>
<string name="screen_this_screen">Denne skærm</string>
<string name="screen_this_screen_sub">Fylder denne enheds skærm, til en enhed, der kører Gearslip selv</string>
<string name="screen_custom">Brugertilpasset</string>
<string name="screen_enter_size">Indtast en størrelse</string>
<string name="screen_change_custom">Ændre den brugerdefinerede størrelse</string>
<string name="screen_width">Bredde</string>
<string name="screen_height">Højde</string>
<string name="save">Gem</string>
<string name="screen_car_size_note">Brug dette, hvis bilens billede er udstrakt. Indtast skærmens størrelse, såsom 1280x480. Det virker fra næste gang du opretter forbindelse.</string>
<string name="screen_set_by_you">%1$s, sat af dig</string>
<string name="help_title">Sådan opretter du forbindelse</string>
<string name="help_which_cars">Hvilke biler virker</string>
<string name="help_full_list">Se hele listen</string>
<string name="help_first_drive">Før din første tur</string>
<string name="help_in_car">I bilen</string>
<string name="help_usb_audio">Lyd gennem Gearslip</string>
<string name="help_aa_off">Deaktiverer Android Auto.</string>
<string name="help_aa_off_detail">Deaktiverer eller afinstaller Android Auto appen, så bilen taler til Gearslip i stedet.</string>
<string name="help_cert">Indlæs et certifikat.</string>
<string name="help_cert_detail">I Indstillinger skal du downloade et aasdk-certifikatet, som de fleste biler accepterer. Hvis din bil afviser det, importerer din egen i stedet.</string>
<string name="help_mic">Tillad mikrofonen.</string>
<string name="help_mic_detail">Android vil ikke lade en app sende musik til bilen uden dette. Gearslip lytter kun, når du trykker på mikrofonen eller trykker på taleknappen.</string>
<string name="help_plug_in">Tilslut eller opret forbindelse trådløst.</string>
<string name="help_plug_in_detail">Gearslip åbner sig selv, når bilen forbinder. For trådløst, par telefonen med bilen først, og tryk derefter på Tilslut trådløst i Gearslip appen.</string>
<string name="help_pair">Pair Bluetooth til opkald.</string>
<string name="help_pair_detail">Opkald virker gennem bilen, når din telefon og bilen er parret. Gearslip tilbyder at parre første gang du tilslutter.</string>
<string name="help_answer">Besvar opkald fra bilens skærm.</string>
<string name="help_answer_detail">Indstil Gearslip som din caller ID og spam app. Uden dette, ringer telefonen stadig, men det er kun telefonen kan besvare opkaldet.</string>
<string name="help_prompt">Stop lydprompten efter hver kørsel.</string>
<string name="help_prompt_detail">Tilslut telefonen til en computer med USB debugging til, og kør en gang. Ellers skal du trykke på Start hver gang du starter.</string>
<string name="help_readable">Hold meddelelser læselige.</string>
<string name="settings_language">Sprog</string>
<string name="settings_language_phone">Samme som telefonen</string>
<string name="player_next_chapter">Næste kapitel</string>
</resources>
File diff suppressed because it is too large. Load diff
+19 -10
View File
@@ -69,12 +69,12 @@
<string name="choice_dark_black">Negro</string> <string name="choice_dark_black">Negro</string>
<string name="choice_dark_black_colour">Negro y color</string> <string name="choice_dark_black_colour">Negro y color</string>
<string name="choice_dark_deep_colour">Color profundo</string> <string name="choice_dark_deep_colour">Color profundo</string>
<string name="choice_dark_grey">Grey</string> <string name="choice_dark_grey">Gris</string>
<string name="choice_auto">Auto</string> <string name="choice_auto">Auto</string>
<string name="choice_night_day">Día</string> <string name="choice_night_day">Día</string>
<string name="choice_night_night">Buenas noches</string> <string name="choice_night_night">Noche</string>
<string name="choice_units_imperial">Imperial</string> <string name="choice_units_imperial">Imperial</string>
<string name="choice_units_metric">Métrica</string> <string name="choice_units_metric">Métrico</string>
<string name="choice_nav_show">Mostrar siempre</string> <string name="choice_nav_show">Mostrar siempre</string>
<string name="choice_nav_hide_navigating">Esconder mientras navega</string> <string name="choice_nav_hide_navigating">Esconder mientras navega</string>
<string name="choice_nav_hide">Escóndete hasta que te toquen</string> <string name="choice_nav_hide">Escóndete hasta que te toquen</string>
@@ -88,13 +88,13 @@
<string name="summary_bluetooth_on">Bluetooth activado</string> <string name="summary_bluetooth_on">Bluetooth activado</string>
<string name="summary_bluetooth_off">Bluetooth apagado</string> <string name="summary_bluetooth_off">Bluetooth apagado</string>
<string name="summary_location_on">Ubicación</string> <string name="summary_location_on">Ubicación</string>
<string name="summary_location_off">Ubicación fuera</string> <string name="summary_location_off">Ubicación desactivada</string>
<string name="web_page">Página</string> <string name="web_page">Página web</string>
<string name="summary_experimental_shown">Características experimentales mostradas</string> <string name="summary_experimental_shown">Mostrar características experimentales</string>
<string name="summary_experimental_hidden">Características experimentales ocultas</string> <string name="summary_experimental_hidden">Características experimentales ocultas</string>
<string name="home_car">Coche</string> <string name="home_car">Coche</string>
<string name="car_group_same">Estas son las mismas configuraciones que Ajustes en el coche. Un cambio aquí también muestra allí.</string> <string name="car_group_same">Estas son las mismas configuraciones que Ajustes en el coche. Un cambio aquí también muestra allí.</string>
<string name="car_open_on_connect">Abra la conexión</string> <string name="car_open_on_connect">Abrir al conectar</string>
<string name="car_size">Tamaño</string> <string name="car_size">Tamaño</string>
<string name="car_app_theme">Tema de aplicación</string> <string name="car_app_theme">Tema de aplicación</string>
<string name="car_dark_mode">Modo oscuro</string> <string name="car_dark_mode">Modo oscuro</string>
@@ -254,6 +254,8 @@
<string name="wireless_which_car">¿Qué coche?</string> <string name="wireless_which_car">¿Qué coche?</string>
<string name="wireless_none_paired">Tu teléfono no está emparejado con nada todavía. Encima con el coche primero.</string> <string name="wireless_none_paired">Tu teléfono no está emparejado con nada todavía. Encima con el coche primero.</string>
<string name="wireless_not_listed">¿El auto no aparece? Pásala primero con tu teléfono.</string> <string name="wireless_not_listed">¿El auto no aparece? Pásala primero con tu teléfono.</string>
<string name="wireless_auto_off">No se conecta solo</string>
<string name="wireless_auto_turn_on">Dejar que se conecte solo</string>
<string name="bluetooth_settings">Ajustes Bluetooth</string> <string name="bluetooth_settings">Ajustes Bluetooth</string>
<string name="voice_live_title">Voz: palabras en vivo</string> <string name="voice_live_title">Voz: palabras en vivo</string>
<string name="voice_final_title">Voz: palabras finales</string> <string name="voice_final_title">Voz: palabras finales</string>
@@ -589,10 +591,10 @@
<item quantity="one">%1$d metro</item> <item quantity="one">%1$d metro</item>
<item quantity="other">%1$d metros</item> <item quantity="other">%1$d metros</item>
</plurals> </plurals>
<string name="touch_relay_description">Permite a Gearslip reproducir toques desde la pantalla del coche en el teléfono mientras se refleja. Gearslip no lee contenido de pantalla.</string> <string name="touch_relay_description">Permite a Gearslip reproducir toques desde la pantalla del coche en el teléfono mientras la conexión este activa. Gearslip no lee contenido de pantalla.</string>
<string name="guide_usage_title">Ayuda a otros conductores.</string> <string name="guide_usage_title">Ayuda a otros conductores.</string>
<string name="guide_safety_title">Conduce con cuidado.</string> <string name="guide_safety_title">Conduce con cuidado.</string>
<string name="guide_safety_body">Gearslip no es oficial. Ningún fabricante de coches lo hizo o lo comprobó, y puede fallar sin aviso. Arréglalo antes de que te vayas, entonces mantén los ojos en el camino.</string> <string name="guide_safety_body">Gearslip no es una app oficial. No la ha desarrollado ni verificado ningún fabricante de coches y puede fallar sin previo aviso. Configúrala antes de arrancar y, una vez en marcha, mantén la vista en la carretera.</string>
<string name="guide_mic_title">Micrófono.</string> <string name="guide_mic_title">Micrófono.</string>
<string name="guide_mic_body">Gearslip lo usa para enviar tu música al coche y escucharte cuando pides algo o respondes a un mensaje. Lo que dices se queda por teléfono.</string> <string name="guide_mic_body">Gearslip lo usa para enviar tu música al coche y escucharte cuando pides algo o respondes a un mensaje. Lo que dices se queda por teléfono.</string>
<string name="guide_location_title">Ubicación.</string> <string name="guide_location_title">Ubicación.</string>
@@ -602,7 +604,7 @@
<string name="guide_calls_title">Llamadas y contactos.</string> <string name="guide_calls_title">Llamadas y contactos.</string>
<string name="guide_calls_body">El coche muestra quién llama por nombre, y su aplicación Phone puede marcar un número o llamar a uno de sus contactos. Gearslip sólo los lee.</string> <string name="guide_calls_body">El coche muestra quién llama por nombre, y su aplicación Phone puede marcar un número o llamar a uno de sus contactos. Gearslip sólo los lee.</string>
<string name="guide_bluetooth_title">Bluetooth.</string> <string name="guide_bluetooth_title">Bluetooth.</string>
<string name="guide_bluetooth_body">La primera vez que se conecta, Gearslip ofrece para emparejar su teléfono con el coche, así que las llamadas juegan a través de sus altavoces.</string> <string name="guide_bluetooth_body">La primera vez que se conecta, Gearslip ofrece emparejar su teléfono con el coche, para que las llamadas suenen a través de los altavoces.</string>
<string name="guide_notifications_title">Notificaciones.</string> <string name="guide_notifications_title">Notificaciones.</string>
<string name="guide_notifications_body">Mientras que Gearslip está conectado, muestra una notificación con un botón para detenerse. Sin esto, Android lo esconde.</string> <string name="guide_notifications_body">Mientras que Gearslip está conectado, muestra una notificación con un botón para detenerse. Sin esto, Android lo esconde.</string>
<string name="guide_messages_title">Mensajes.</string> <string name="guide_messages_title">Mensajes.</string>
@@ -707,4 +709,11 @@
<string name="places_finding">Buscando…</string> <string name="places_finding">Buscando…</string>
<string name="places_not_found">Gearslip no encontró dónde estás. Comprueba que la ubicación esté activada.</string> <string name="places_not_found">Gearslip no encontró dónde estás. Comprueba que la ubicación esté activada.</string>
<string name="places_clear">Borrar</string> <string name="places_clear">Borrar</string>
<string name="certsetup_installed_hint">Si tu coche no se conecta, puede que rechace este certificado. Importa el tuyo en Ajustes, en Certificado.</string>
<string name="cert_downloaded_footer">¿El coche no se conecta? Algunos coches de 2020 en adelante rechazan el certificado de aasdk, así que importa el tuyo. Los cambios se aplican en la próxima conexión.</string>
<string name="car_cluster_video">Tarjeta de giro en la pantalla del cuadro</string>
<string name="car_cluster_video_note">Para coches con una segunda pantalla en el cuadro de instrumentos: Gearslip muestra ahí el próximo giro. Se aplica en la próxima conexión.</string>
<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>
</resources> </resources>
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="back">Tagasi</string>
<string name="recommended_installed">Paigaldatud</string>
<string name="recommended_maps">Kaardid</string>
<string name="recommended_music">Muusika</string>
<string name="recommended_podcasts">Taskuhäälingud</string>
<string name="recommended_other">Muud vahvad rakendused</string>
<string name="recommended_about_organic">Kaardid ja tee juhatamine, mis ei vaja võrguühendust.</string>
<string name="recommended_about_vela">Samm-sammult tee juhatamine ilma Google\'ita.</string>
<string name="recommended_about_ytm">Kuula muusikat teenusest YouTube Music.</string>
<string name="recommended_about_antennapod">Tasukuhäälingud ja kõik mis sellega kaasneb.</string>
<string name="recommended_about_rfsentinel">Tuvastab lähikonnast jälitajad ja kaamerad.</string>
<string name="settings_title">Seadistused</string>
<string name="settings_whats_new">Mida on meil uut</string>
<string name="settings_whats_new_sub">Loe GitHubist teavet %1$s versiooni kohta</string>
<string name="settings_support">Toeta Gearslipi arendust</string>
<string name="settings_support_footer">Gearslip on vaba ja tasuta tarkvara, milles pole reklaame ega jälitamist. Sinu rahaline tugi aitab arendajatel edasi tegutseda.</string>
</resources>
@@ -373,6 +373,8 @@
<string name="wireless_which_car">Quelle voiture ?</string> <string name="wireless_which_car">Quelle voiture ?</string>
<string name="wireless_none_paired">Votre téléphone n’est encore associé à rien. Associez-le d’abord à la voiture.</string> <string name="wireless_none_paired">Votre téléphone n’est encore associé à rien. Associez-le d’abord à la voiture.</string>
<string name="wireless_not_listed">La voiture n’apparaît pas ? Associez-la d’abord à votre téléphone.</string> <string name="wireless_not_listed">La voiture n’apparaît pas ? Associez-la d’abord à votre téléphone.</string>
<string name="wireless_auto_off">Ne se connecte pas tout seul</string>
<string name="wireless_auto_turn_on">Le laisser se connecter tout seul</string>
<string name="bluetooth_settings">Paramètres Bluetooth</string> <string name="bluetooth_settings">Paramètres Bluetooth</string>
<!-- Voice models --> <!-- Voice models -->
@@ -837,4 +839,7 @@
<string name="places_finding">Recherche…</string> <string name="places_finding">Recherche…</string>
<string name="places_not_found">Gearslip n’a pas trouvé votre position. Vérifiez que la localisation est activée.</string> <string name="places_not_found">Gearslip n’a pas trouvé votre position. Vérifiez que la localisation est activée.</string>
<string name="places_clear">Effacer</string> <string name="places_clear">Effacer</string>
<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>
</resources> </resources>
@@ -373,6 +373,8 @@
<string name="wireless_which_car">Quale auto?</string> <string name="wireless_which_car">Quale auto?</string>
<string name="wireless_none_paired">Il telefono non è ancora associato a nulla. Associalo prima all’auto.</string> <string name="wireless_none_paired">Il telefono non è ancora associato a nulla. Associalo prima all’auto.</string>
<string name="wireless_not_listed">L’auto non è in elenco? Associala prima al telefono.</string> <string name="wireless_not_listed">L’auto non è in elenco? Associala prima al telefono.</string>
<string name="wireless_auto_off">Non si collega da solo</string>
<string name="wireless_auto_turn_on">Lascia che si colleghi da solo</string>
<string name="bluetooth_settings">Impostazioni Bluetooth</string> <string name="bluetooth_settings">Impostazioni Bluetooth</string>
<!-- Voice models --> <!-- Voice models -->
@@ -837,4 +839,7 @@
<string name="places_finding">Ricerca…</string> <string name="places_finding">Ricerca…</string>
<string name="places_not_found">Gearslip non ha trovato la tua posizione. Controlla che la localizzazione sia attiva.</string> <string name="places_not_found">Gearslip non ha trovato la tua posizione. Controlla che la localizzazione sia attiva.</string>
<string name="places_clear">Cancella</string> <string name="places_clear">Cancella</string>
<string name="settings_language">Lingua</string>
<string name="settings_language_phone">Come il telefono</string>
<string name="player_next_chapter">Capitolo successivo</string>
</resources> </resources>
@@ -371,6 +371,8 @@
<string name="wireless_which_car">どの車ですか?</string> <string name="wireless_which_car">どの車ですか?</string>
<string name="wireless_none_paired">スマートフォンはまだ何ともペア設定されていません。先に車とペア設定してください。</string> <string name="wireless_none_paired">スマートフォンはまだ何ともペア設定されていません。先に車とペア設定してください。</string>
<string name="wireless_not_listed">車が表示されない場合は、先にスマートフォンとペア設定してください。</string> <string name="wireless_not_listed">車が表示されない場合は、先にスマートフォンとペア設定してください。</string>
<string name="wireless_auto_off">自動では接続しません</string>
<string name="wireless_auto_turn_on">自動で接続する</string>
<string name="bluetooth_settings">Bluetooth の設定</string> <string name="bluetooth_settings">Bluetooth の設定</string>
<!-- Voice models --> <!-- Voice models -->
@@ -821,4 +823,7 @@
<string name="places_finding">検索中…</string> <string name="places_finding">検索中…</string>
<string name="places_not_found">現在地が見つかりませんでした。位置情報がオンになっているか確認してください。</string> <string name="places_not_found">現在地が見つかりませんでした。位置情報がオンになっているか確認してください。</string>
<string name="places_clear">消去</string> <string name="places_clear">消去</string>
<string name="settings_language">言語</string>
<string name="settings_language_phone">スマートフォンと同じ</string>
<string name="player_next_chapter">次のチャプター</string>
</resources> </resources>
@@ -371,6 +371,8 @@
<string name="wireless_which_car">어느 차량인가요?</string> <string name="wireless_which_car">어느 차량인가요?</string>
<string name="wireless_none_paired">휴대전화가 아직 아무것과도 페어링되지 않았습니다. 먼저 차량과 페어링하세요.</string> <string name="wireless_none_paired">휴대전화가 아직 아무것과도 페어링되지 않았습니다. 먼저 차량과 페어링하세요.</string>
<string name="wireless_not_listed">차량이 목록에 없나요? 먼저 휴대전화와 페어링하세요.</string> <string name="wireless_not_listed">차량이 목록에 없나요? 먼저 휴대전화와 페어링하세요.</string>
<string name="wireless_auto_off">자동으로 연결되지 않음</string>
<string name="wireless_auto_turn_on">자동으로 연결하도록 허용</string>
<string name="bluetooth_settings">블루투스 설정</string> <string name="bluetooth_settings">블루투스 설정</string>
<!-- Voice models --> <!-- Voice models -->
@@ -821,4 +823,7 @@
<string name="places_finding">찾는 중…</string> <string name="places_finding">찾는 중…</string>
<string name="places_not_found">Gearslip이 현재 위치를 찾지 못했습니다. 위치가 켜져 있는지 확인하세요.</string> <string name="places_not_found">Gearslip이 현재 위치를 찾지 못했습니다. 위치가 켜져 있는지 확인하세요.</string>
<string name="places_clear">지우기</string> <string name="places_clear">지우기</string>
<string name="settings_language">언어</string>
<string name="settings_language_phone">휴대전화와 동일</string>
<string name="player_next_chapter">다음 챕터</string>
</resources> </resources>
@@ -372,6 +372,8 @@
<string name="wireless_which_car">Welke auto?</string> <string name="wireless_which_car">Welke auto?</string>
<string name="wireless_none_paired">Je telefoon is nog met niets gekoppeld. Koppel hem eerst met de auto.</string> <string name="wireless_none_paired">Je telefoon is nog met niets gekoppeld. Koppel hem eerst met de auto.</string>
<string name="wireless_not_listed">Auto staat er niet bij? Koppel hem eerst met je telefoon.</string> <string name="wireless_not_listed">Auto staat er niet bij? Koppel hem eerst met je telefoon.</string>
<string name="wireless_auto_off">Maakt niet vanzelf verbinding</string>
<string name="wireless_auto_turn_on">Vanzelf verbinding laten maken</string>
<string name="bluetooth_settings">Bluetooth-instellingen</string> <string name="bluetooth_settings">Bluetooth-instellingen</string>
<!-- Voice models --> <!-- Voice models -->
@@ -829,4 +831,7 @@
<string name="places_finding">Zoeken…</string> <string name="places_finding">Zoeken…</string>
<string name="places_not_found">Gearslip kon je locatie niet vinden. Staat locatie aan?</string> <string name="places_not_found">Gearslip kon je locatie niet vinden. Staat locatie aan?</string>
<string name="places_clear">Wissen</string> <string name="places_clear">Wissen</string>
<string name="settings_language">Taal</string>
<string name="settings_language_phone">Zelfde als de telefoon</string>
<string name="player_next_chapter">Volgend hoofdstuk</string>
</resources> </resources>
@@ -374,6 +374,8 @@
<string name="wireless_which_car">Który samochód?</string> <string name="wireless_which_car">Który samochód?</string>
<string name="wireless_none_paired">Telefon nie jest jeszcze z niczym sparowany. Najpierw sparuj go z samochodem.</string> <string name="wireless_none_paired">Telefon nie jest jeszcze z niczym sparowany. Najpierw sparuj go z samochodem.</string>
<string name="wireless_not_listed">Nie ma samochodu na liście? Najpierw sparuj go z telefonem.</string> <string name="wireless_not_listed">Nie ma samochodu na liście? Najpierw sparuj go z telefonem.</string>
<string name="wireless_auto_off">Nie łączy się samo</string>
<string name="wireless_auto_turn_on">Pozwól łączyć się samodzielnie</string>
<string name="bluetooth_settings">Ustawienia Bluetooth</string> <string name="bluetooth_settings">Ustawienia Bluetooth</string>
<!-- Voice models --> <!-- Voice models -->
@@ -845,4 +847,7 @@
<string name="places_finding">Szukam…</string> <string name="places_finding">Szukam…</string>
<string name="places_not_found">Gearslip nie znalazł Twojej lokalizacji. Sprawdź, czy lokalizacja jest włączona.</string> <string name="places_not_found">Gearslip nie znalazł Twojej lokalizacji. Sprawdź, czy lokalizacja jest włączona.</string>
<string name="places_clear">Wyczyść</string> <string name="places_clear">Wyczyść</string>
<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>
</resources> </resources>
@@ -373,6 +373,8 @@
<string name="wireless_which_car">Qual carro?</string> <string name="wireless_which_car">Qual carro?</string>
<string name="wireless_none_paired">Seu celular ainda não está pareado com nada. Pareie com o carro primeiro.</string> <string name="wireless_none_paired">Seu celular ainda não está pareado com nada. Pareie com o carro primeiro.</string>
<string name="wireless_not_listed">O carro não aparece? Pareie com o celular primeiro.</string> <string name="wireless_not_listed">O carro não aparece? Pareie com o celular primeiro.</string>
<string name="wireless_auto_off">Não se conecta sozinho</string>
<string name="wireless_auto_turn_on">Deixar conectar sozinho</string>
<string name="bluetooth_settings">Configurações de Bluetooth</string> <string name="bluetooth_settings">Configurações de Bluetooth</string>
<!-- Voice models --> <!-- Voice models -->
@@ -837,4 +839,7 @@
<string name="places_finding">Procurando…</string> <string name="places_finding">Procurando…</string>
<string name="places_not_found">O Gearslip não encontrou onde você está. Veja se a localização está ligada.</string> <string name="places_not_found">O Gearslip não encontrou onde você está. Veja se a localização está ligada.</string>
<string name="places_clear">Limpar</string> <string name="places_clear">Limpar</string>
<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>
</resources> </resources>
+578
View File
@@ -0,0 +1,578 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="touch_relay_description">Permite aplicației Gearslip să redirecționeze atingerile de pe ecranul mașinii către telefon în timpul oglindirii ecranului. Gearslip nu citește conținutul ecranului.</string>
<string name="guide_usage_title">Ajută-i pe ceilalți șoferi.</string>
<string name="guide_safety_title">Condu cu prudență.</string>
<string name="guide_safety_body">Gearslip nu este o aplicație oficială. Niciun producător auto nu a creat-o sau verificat-o, iar aceasta se poate defecta fără avertisment. Configureaz-o înainte de a porni la drum, apoi concentrează-te pe șosea.</string>
<string name="guide_mic_title">Microfon.</string>
<string name="guide_mic_body">Gearslip folosește această funcție pentru a transmite muzica ta către mașină și pentru a te auzi atunci când soliciți ceva sau răspunzi la un mesaj. Ceea ce spui rămâne pe telefon.</string>
<string name="guide_location_title">Locație.</string>
<string name="guide_location_body">Harta mașinii și căutările din apropiere au nevoie de ea. Fără ea, tabloul de bord nu afișează nicio hartă.</string>
<string name="guide_calendar_title">Calendar.</string>
<string name="guide_calendar_body">Ecranul mașinii afișează următorul eveniment din agenda ta. Gearslip citește doar calendarul tău.</string>
<string name="guide_calls_title">Apeluri și contacte.</string>
<string name="guide_calls_body">Mașina afișează numele persoanei care sună, iar aplicația sa de telefon poate forma un număr sau poate apela unul dintre contactele tale. Gearslip doar le citește.</string>
<string name="guide_bluetooth_title">Bluetooth.</string>
<string name="guide_bluetooth_body">La prima conectare, Gearslip îți propune să îți conectezi telefonul la mașină, astfel încât apelurile să fie redate prin difuzoarele acesteia.</string>
<string name="guide_notifications_title">Notificări.</string>
<string name="guide_notifications_body">Atâta timp cât Gearslip este conectat, afișează o notificare cu un buton de oprire. În lipsa acestuia, Android o ascunde.</string>
<string name="guide_messages_title">Mesaje.</string>
<string name="guide_messages_body">Mașina îți afișează mesajele, ți le citește cu voce tare și îți permite să răspunzi. Activează opțiunea „Gearslip” din lista care se deschide.</string>
<string name="guide_android_auto_title">Dezactivează Android Auto.</string>
<string name="guide_android_auto_body">Android Auto este încă instalat și intră în conflict cu Gearslip pentru conexiunea USB a mașinii. Dezactivează-l sau dezinstalează-l din fereastra care se deschide.</string>
<string name="guide_default_title">Alege Gearslip.</string>
<string name="guide_default_body">Dacă telefonul te întreabă ce aplicație să deschidă atunci când îl conectezi la mașină, alege „Gearslip” și atinge „Întotdeauna”.</string>
<string name="guide_audio_title">Sunet în automobil.</string>
<string name="guide_audio_body">Alege modul în care muzica ajunge în mașină. Apelurile se transmit prin Bluetooth în ambele cazuri.</string>
<string name="guide_voice_title">Voce.</string>
<string name="guide_voice_body">Cere indicații de orientare, muzică sau un apel prin comandă vocală. Telefonul tău transformă automat vorbirea în text, iar nimic din ceea ce spui nu părăsește dispozitivul. Are nevoie de %1$d MB, se descarcă o singură dată. Folosește Wi-Fi dacă poți.</string>
<string name="guide_audio_prompt_title">Fără solicitare de sunet.</string>
<string name="guide_audio_prompt_body">Execută această comandă de pe un computer pe care este activată opțiunea de depanare USB, iar Android va încredința definitiv gestionarea sunetului aplicației Gearslip. Fără aceasta, trebuie să apeși o dată pe „Start” la fiecare cursă.</string>
<string name="guide_readable_title">Notificări ușor de citit.</string>
<string name="guide_readable_body">În timp ce sunetul este redat în mașină, Android ascunde conținutul notificărilor, ca și cum ai partaja ecranul. Astfel, Gearslip le poate afișa în timpul călătoriei și le poate ascunde din nou după aceea.</string>
<string name="guide_answer_title">Răspunde la apeluri.</string>
<string name="guide_answer_body">Setează Gearslip ca aplicație pentru identificarea apelantului, iar mașina va putea prelua sau respinge apelurile. Fără această aplicație, apelurile vor suna în continuare, dar numai telefonul le va putea prelua.</string>
<string name="guide_apps_title">O companie plăcută.</string>
<string name="guide_buttons_title">Butoanele tale.</string>
<string name="guide_buttons_body">Alege aspectul butoanelor mașinii. Poți să-l modifici oricând din setările mașinii.</string>
<string name="guide_open_notification_access">Activare acces la notificări</string>
<string name="guide_open_android_auto">Deschide informațiile despre aplicația Android Auto</string>
<string name="guide_download_voice">Descarcă vocea</string>
<string name="guide_set_up">Configurare</string>
<string name="guide_voice_downloading">Se descarcă, %1$d%%. Poți continua; procesul merge mai departe.</string>
<string name="guide_voice_ready">Vocea este pregătită.</string>
<string name="guide_voice_failed">Descărcarea nu s-a finalizat. Atinge „Descarcă vocea” pentru a încerca din nou.</string>
<string name="guide_audio_bluetooth">Bluetooth</string>
<string name="guide_recommended">Recomandat</string>
<string name="guide_audio_bluetooth_body">Nu mai primești mesaje la fiecare deplasare, iar vocea aplicației de hărți se aude direct în mașină.</string>
<string name="guide_audio_usb">Prin Gearslip</string>
<string name="guide_audio_usb_body">Un sunet mai clar, fără întârziere. La fiecare pornire, Android îți cere să partajezi ecranul, ceea ce permite aplicației Gearslip să transmită sunetul.</string>
<string name="guide_optional">OPȚIONAL</string>
<string name="guide_already_allowed">Este deja permis.</string>
<string name="guide_scroll_more">Derulează pentru mai multe ↓</string>
<string name="guide_allow">Permite</string>
<string name="guide_skip">Omite deocamdată</string>
<string name="guide_open">Deschide</string>
<string name="guide_next">Înainte</string>
<string name="guide_done">Finalizat</string>
<string name="guide_back">Înapoi</string>
<string name="usage_intro">Activează această opțiune, iar Gearslip îți va trimite o notă scurtă atunci când deschizi aplicația și după fiecare călătorie. &lt;b&gt;Nota nu conține niciun nume, cont sau locație. Un număr aleatoriu identifică telefonul tău o singură dată, iar serverul nu păstrează nicio adresă IP.&lt;/b&gt; Notele indică mașinile care funcționează și locurile în care celelalte nu funcționează.</string>
<string name="usage_send">Trimite observații privind utilizarea</string>
<string name="usage_also_share">Distribuie</string>
<string name="usage_share_android">Versiunea Android și sistemul de operare</string>
<string name="usage_share_phone">Modelul telefonului</string>
<string name="usage_share_cars">Fiecare mașină și dacă aceasta este conectată</string>
<string name="usage_share_drives">Fiecare călătorie și cum s-a încheiat aceasta</string>
<string name="usage_what_sent">Ce se trimite</string>
<string name="usage_what_sent_sub">Fiecare câmp, exact așa cum este afișat</string>
<string name="usage_hide_note">Ascunde nota</string>
<string name="usage_show_note">Afișează nota</string>
<string name="usage_example">Exemplu, nu se trimite: o singură călătorie adaugă acest lucru la „sessions”</string>
<string name="usage_no_server">Această versiune nu are server de statistici, așa că nu transmite nimic</string>
<string name="usage_debug_build">Versiune de depanare: notele sunt păstrate separat și nu sunt niciodată incluse în statistici</string>
<string name="worked_title">Gearslip a funcționat cu %1$s</string>
<string name="worked_title_unknown">Gearslip a funcționat cu mașina ta</string>
<string name="worked_body">Distribui, ca următorul șofer să știe că funcționează? Fără nume, cont sau locație.</string>
<string name="worked_not_now">Nu acum</string>
<string name="worked_share">Distribuie</string>
<string name="recommended_intro">Aceste aplicații gratuite rulează pe ecranul auto Gearslip. Fiecare buton deschide pagina aplicației pe GitHub.</string>
<string name="recommended_title">Aplicații recomandate</string>
<string name="back">Înapoi</string>
<string name="recommended_installed">Instalat</string>
<string name="recommended_maps">Hărți</string>
<string name="recommended_music">Muzică</string>
<string name="recommended_podcasts">Podcasturi</string>
<string name="recommended_other">Alte aplicații interesante</string>
<string name="recommended_about_organic">Hărți și rute off-line.</string>
<string name="recommended_about_vela">Navigare pas cu pas fără Google.</string>
<string name="recommended_about_ytm">Un player YouTube Music.</string>
<string name="recommended_about_antennapod">Podcasturi, capitole și tot ce ține de ele.</string>
<string name="recommended_about_rfsentinel">Detectează dispozitive de urmărire și camere din apropiere.</string>
<string name="settings_title">Setări</string>
<string name="settings_whats_new">Noutăți</string>
<string name="settings_whats_new_sub">Citește notele de lansare ale %1$s pe GitHub</string>
<string name="settings_support">Susține Gearslip</string>
<string name="settings_support_footer">Gearslip este gratuit, fără reclame și fără urmărire. Sprijinul tău ne ajută să continuăm.</string>
<string name="settings_usage_notes">Note privind utilizarea</string>
<string name="settings_updates">Actualizări</string>
<string name="settings_about">Despre</string>
<string name="settings_replay">Vizualizează din nou ghidul de configurare</string>
<string name="settings_replay_sub">Parcurge din nou pașii de inițializare</string>
<string name="settings_connection">Conexiune</string>
<string name="settings_use_in_car">Folosește Gearslip în mașină</string>
<string name="settings_use_in_car_on">Gearslip îți răspunde când îl conectezi la mașină</string>
<string name="settings_use_in_car_off">Oprit: Android Auto poate prelua conexiunea în locul acestuia</string>
<string name="settings_apps">Aplicații</string>
<string name="settings_certificate">Certificat</string>
<string name="cert_next_connection">Modificările vor intra în vigoare la următoarea conectare.</string>
<string name="cert_checking">Se verifică…</string>
<string name="cert_valid_until">Valabil până la %1$s</string>
<string name="cert_hide_details">Ascunde detaliile</string>
<string name="cert_show_details">Afișează detaliile</string>
<string name="cert_subject">Subiect</string>
<string name="cert_issuer">Emitent</string>
<string name="cert_downloading">Se descarcă…</string>
<string name="cert_download">Descărcare de pe aasdk</string>
<string name="cert_downloaded">Certificatul a fost descărcat.</string>
<string name="cert_download_failed">Descărcarea a eșuat: %1$s</string>
<string name="cert_download_sub">Certificatul publicat de proiectul open-source aasdk pe GitHub. Majoritatea autoturismelor îl acceptă, dar unele modele fabricate începând cu anul 2020 îl resping</string>
<string name="cert_import">Importă propriul meu fișier</string>
<string name="cert_import_sub">Un fișier .p12 împreună cu cheia sa privată. Folosește-l pe al tău atunci când mașina ta respinge certificatul aasdk</string>
<string name="cert_remove_imported">Elimină certificatul importat</string>
<string name="cert_remove_downloaded">Șterge certificatul descărcat</string>
<string name="cert_removed_imported">Am eliminat certificatul importat.</string>
<string name="cert_removed_downloaded">Am șters certificatul descărcat.</string>
<string name="cert_imported">Certificatul a fost importat.</string>
<string name="cert_import_failed">Nu s-a putut importa: parolă incorectă sau fișierul nu este de tip PKCS#12 cu cheie privată.</string>
<string name="cert_password_title">Parola certificatului</string>
<string name="cert_password">Parola</string>
<string name="cert_password_hint">Lasă câmpul gol dacă fișierul nu conține niciuna.</string>
<string name="cert_import_button">Import</string>
<string name="cancel">Renunță</string>
<string name="cert_none">Nu este încărcat nimic (semnat automat; mașinile îl resping)</string>
<string name="cert_kind_imported">Importat: %1$s</string>
<string name="cert_kind_downloaded">Descărcat: %1$s</string>
<string name="cert_kind_adb">Transmis prin adb: %1$s</string>
<string name="cert_kind_self">Semnat automat: %1$s</string>
<string name="car_group_screen">Ecran</string>
<string name="car_group_nav_bar">Bara de navigare și playerul</string>
<string name="car_group_sound">Sunet și voce</string>
<string name="car_group_warnings">Avertismente</string>
<string name="car_group_more">Mai multe</string>
<string name="choice_size_normal">Normal</string>
<string name="choice_size_large">Mare</string>
<string name="choice_size_xlarge">Foarte mare</string>
<string name="choice_theme_phone">Urmărește telefonul</string>
<string name="choice_theme_light">Luminoasă</string>
<string name="choice_theme_dark">Întunecată</string>
<string name="choice_dark_black">Neagră</string>
<string name="choice_dark_black_colour">Negru cu o nuanță de culoare</string>
<string name="choice_dark_deep_colour">Culoare intensă</string>
<string name="choice_dark_grey">Gri</string>
<string name="choice_auto">Automat</string>
<string name="choice_night_day">Zi</string>
<string name="choice_night_night">Noapte</string>
<string name="choice_units_imperial">Imperiale</string>
<string name="choice_units_metric">Metrice</string>
<string name="choice_nav_show">Afișează întotdeauna</string>
<string name="choice_nav_hide_navigating">Ascunde în timpul navigării</string>
<string name="choice_nav_hide">Ascunde până când se atinge ecranul</string>
<string name="choice_open_home">Ecranul de pornire</string>
<string name="summary_player_left">Playerul în stânga</string>
<string name="summary_player_right">Playerul în dreapta</string>
<string name="summary_usb_audio">Sunet prin Gearslip</string>
<string name="summary_bluetooth_audio">Audio Bluetooth</string>
<string name="summary_directions_on">Indicații de orientare</string>
<string name="summary_directions_off">Indicații de orientare dezactivate</string>
<string name="summary_bluetooth_on">Bluetooth activat</string>
<string name="summary_bluetooth_off">Bluetooth dezactivat</string>
<string name="summary_location_on">Localizare activată</string>
<string name="summary_location_off">Localizare dezactivată</string>
<string name="web_page">Pagina web</string>
<string name="summary_experimental_shown">Caracteristici experimentale afișate</string>
<string name="summary_experimental_hidden">Caracteristici experimentale ascunse</string>
<string name="home_car">Automobil</string>
<string name="car_group_same">Acestea sunt aceleași setări ca și cele din meniul „Setări” ale mașinii. Orice modificare efectuată aici se va reflecta și acolo.</string>
<string name="car_open_on_connect">Deschide la conectare</string>
<string name="car_size">Dimensiune</string>
<string name="car_app_theme">Tema aplicației</string>
<string name="car_dark_mode">Mod întunecat</string>
<string name="car_buttons">Butoane</string>
<string name="car_map_day_night">Harta zi/noapte</string>
<string name="car_units">Unități</string>
<string name="car_nav_bar">Bara de navigare</string>
<string name="car_player_nav_bar">Player în bara de navigare</string>
<string name="car_skip_buttons">Butoane de omitere</string>
<string name="car_song_title">Titlul melodiei</string>
<string name="car_player_beside_map">Playerul de lângă hartă</string>
<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>
<string name="car_phone_speech_note">Pe majoritatea telefoanelor, aceasta este aplicația Google, care ar putea transmite vocea ta către Google.</string>
<string name="car_warn_bluetooth">Avertizează când Bluetooth este dezactivat</string>
<string name="car_warn_location">Avertizează dacă localizarea este dezactivată</string>
<string name="car_experimental">Caracteristici experimentale</string>
<string name="car_vehicle_data">Datele automobilului</string>
<string name="car_vehicle_data_note">Adaugă o casetă cu date despre vehicul: o afișare în timp real a datelor de la senzorii mașinii. Numele câmpurilor sunt doar presupuneri până când observi o schimbare a unuia dintre ele.</string>
<string name="car_adaptive_video">Calitate video adaptivă</string>
<string name="car_adaptive_video_note">Reduce calitatea imaginii atunci când mașina sau rețeaua Wi-Fi a acesteia nu pot face față, o îmbunătățește din nou odată ce acestea revin la normal. Se aplică la următoarea conexiune.</string>
<string name="web_page_note">Afișat de aplicația Browser din mașină. Dacă este gol, se deschide DuckDuckGo. Se aplică la următoarea conexiune.</string>
<string name="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>
<string name="car_audio_usb_sub">Un sunet mai clar, fără întârziere. La fiecare pornire, Android îți cere să partajezi ecranul, ceea ce permite aplicației Gearslip să transmită sunetul</string>
<string name="car_audio_usb_footer">Execută o singură dată comanda din secțiunea „Ajutor”, iar Android nu va mai afișa mesajul respectiv.</string>
<string name="car_audio_bt_footer">În ambele cazuri, apelurile se efectuează prin Bluetooth.</string>
<string name="screen_car_size">Dimensiunea ecranului mașinii</string>
<string name="screen_use_cars">Ca în automobil</string>
<string name="screen_preview_size">Dimensiunea previzualizării</string>
<string name="screen_preview_note">Dimensiunea la care se afișează previzualizarea, de exemplu 1920x720.</string>
<string name="screen_car_screen">Ecranul mașinii</string>
<string name="screen_footer">Gearslip utilizează dimensiunea indicată de mașină, cu excepția cazului în care setezi una.</string>
<string name="screen_size">Dimensiunea ecranului</string>
<string name="screen_from_car">Din mașină</string>
<string name="screen_crop">Decupează marginile</string>
<string name="screen_crop_sub">Pentru o mașină la care se taie partea de sus și cea de jos sau părțile laterale</string>
<string name="screen_squash">Reducere la dimensiune</string>
<string name="screen_squash_sub">Pentru o mașină care comprimă întreaga imagine, astfel încât cercurile să pară ovale</string>
<string name="screen_preview_title">Dimensiunea previzualizării mașinii</string>
<string name="screen_last_car">Ultimul automobil</string>
<string name="screen_last_car_sub">Dimensiunea ultimei mașini la care te-ai conectat</string>
<string name="screen_this_screen">Acest ecran</string>
<string name="screen_this_screen_sub">Ocupă întregul ecran al acestui dispozitiv, în cazul unui sistem de bord care rulează chiar aplicația Gearslip</string>
<string name="screen_custom">Personalizat</string>
<string name="screen_enter_size">Introdu o dimensiune</string>
<string name="screen_change_custom">Modifică dimensiunea personalizată</string>
<string name="screen_width">Lățime</string>
<string name="screen_height">Înălțime</string>
<string name="save">Salvează</string>
<string name="screen_car_size_note">Folosește această opțiune dacă imaginea mașinii pare întinsă. Introdu dimensiunea reală a ecranului, de exemplu 1280x480. Setarea va intra în vigoare la următoarea conectare.</string>
<string name="screen_set_by_you">%1$s, stabilit de tine</string>
<string name="help_title">Cum se conectează</string>
<string name="help_which_cars">Ce mașini sunt potrivite</string>
<string name="help_full_list">Vezi lista completă</string>
<string name="help_first_drive">Înainte de prima călătorie</string>
<string name="help_in_car">În mașină</string>
<string name="help_usb_audio">Sunet prin Gearslip</string>
<string name="help_aa_off">Dezactivează Android Auto.</string>
<string name="help_aa_off_detail">Dezactivează sau dezinstalează aplicația Android Auto, astfel încât mașina să comunice cu Gearslip.</string>
<string name="help_cert">Încarcă un certificat.</string>
<string name="help_cert_detail">În Setări, descarcă certificatul aasdk, care este acceptat de majoritatea mașinilor. Dacă mașina îl respinge, importă-l pe al tău în locul acestuia.</string>
<string name="help_mic">Autorizează microfonul.</string>
<string name="help_mic_detail">Android nu permite unei aplicații să transmită muzică către mașină fără aceasta. Gearslip ascultă doar atunci când atingi microfonul sau apeși butonul de convorbire de pe volan.</string>
<string name="help_plug_in">Conectează-te prin cablu sau fără fir.</string>
<string name="help_plug_in_detail">Gearslip se deschide automat atunci când mașina se conectează. Pentru conectarea fără fir, asociază mai întâi telefonul cu mașina, apoi atinge opțiunea „Conectare fără fir” din ecranul principal.</string>
<string name="help_pair">Conectează-te prin Bluetooth pentru apeluri.</string>
<string name="help_pair_detail">Apelurile se redau prin sistemul audio al mașinii odată ce telefonul și mașina sunt conectate. Aplicația Gearslip îți propune să le asociezi încă de la prima conectare.</string>
<string name="help_answer">Răspunde la apeluri de pe ecranul mașinii.</string>
<string name="help_answer_detail">Setează Gearslip ca aplicație pentru identificarea apelantului și blocarea apelurilor nedorite. Fără aceasta, apelurile continuă să sune, dar numai telefonul le poate prelua.</string>
<string name="help_prompt">Elimină solicitarea de confirmare audio la fiecare călătorie.</string>
<string name="help_prompt_detail">Conectează telefonul la un computer cu opțiunea de depanare USB activată și execută această operațiune o singură dată. Dacă o omiți, va trebui să apeși pe „Start” de fiecare dată când pornești.</string>
<string name="help_readable">Păstrează notificările lizibile.</string>
<string name="help_readable_detail">Android ascunde conținutul notificărilor în timp ce redă sunet. Dacă rulezi și această aplicație, Gearslip va afișa conținutul notificărilor doar cât timp muzica este transmisă către mașină.</string>
<string name="help_which_cars_body">Majoritatea mașinilor acceptă certificatul aasdk. Începând cu anul 2020, unele mașini au introdus verificări de securitate mai noi care îl resping, iar în cazul acestor mașini, Gearslip are nevoie de un certificat propriu.</string>
<string name="update_downloaded_ask">S-a descărcat. Android te întreabă înainte de instalare.</string>
<string name="update_install">Instalează</string>
<string name="update_download_now">Descarcă-l acum. Android te întreabă înainte de instalare.</string>
<string name="update_try_again">Reîncearcă</string>
<string name="update_update">Actualizează</string>
<string name="update_checking">Se verifică…</string>
<string name="update_latest">Ai cea mai recentă versiune</string>
<string name="update_download">Desarcă</string>
<string name="update_check_now">Verifică acum</string>
<string name="update_check_auto">Verificare automată</string>
<string name="update_once_a_day">O dată pe zi</string>
<string name="update_unplug_first">Deconectează cablul de la mașină pentru a efectua instalarea. Instalarea va închide aplicația Gearslip.</string>
<string name="update_card_title">Gearslip %1$s a fost lansat</string>
<string name="update_downloading">Se descarcă… %1$d%%</string>
<string name="update_debug_manual">Versiunile de depanare se verifică numai atunci când apeși pe „Verifică acum”</string>
<string name="update_checks_daily">Gearslip efectuează verificări o dată pe zi</string>
<string name="update_version_out">A fost lansată versiunea %1$s</string>
<string name="update_version_ready">Versiunea %1$s este gata de instalare</string>
<string name="update_dialog_status">Versiunea %1$s. %2$s.</string>
<string name="about_give_regularly">Donează săptămânal sau lunar</string>
<string name="about_bmac_sub" formatted="false">Donează o singură dată sau lunar, cu cardul. Buy Me a Coffee reține 5%</string>
<string name="about_give_once">Donează o singură dată</string>
<string name="about_any_amount">Orice sumă, cu cardul</string>
<string name="about_monero_sub">Deschide portofelul sau copiază adresa</string>
<string name="about_monero_title">Suport prin Monero</string>
<string name="about_monero_body">Trimite orice sumă la această adresă din portofelul tău Monero.</string>
<string name="about_open_wallet">Deschide în portofel</string>
<string name="about_copy_address">Copiază adresa</string>
<string name="close">Închide</string>
<string name="about_matrix">Alătură-te discuției pe Matrix</string>
<string name="about_matrix_sub">Discută cu alți șoferi despre mașini, aplicații și Gearslip</string>
<string name="about_translate">Ajută la traducerea Gearslip</string>
<string name="about_translate_sub">Corectează sau adaugă limba ta pe Weblate. Unele traduceri au fost inițial realizate automat.</string>
<string name="about_source">Cod sursă</string>
<string name="about_source_sub">Citește sau modifică codul sursă Gearslip în conformitate cu licența AGPL</string>
<string name="about_voice_credits">Mulțumiri pentru voce</string>
<string name="about_no_wallet">Nu s-a găsit nicio aplicație de portofel Monero. Copiază adresa în schimb.</string>
<string name="about_monero_copied">Adresa Monero a fost copiată</string>
<string name="about_voice_credits_sub">Recunoașterea vocală realizată de sherpa-onnx (Apache 2.0), cu Kroko ASR de la Banafo (CC BY-SA 4.0) și Parakeet și Canary de la NVIDIA (CC BY 4.0)</string>
<string name="certsetup_title">Configurarea certificatului</string>
<string name="certsetup_installed">Certificatul a fost instalat.</string>
<string name="certsetup_continue">Continuă</string>
<string name="certsetup_failed">Descărcare eșuată</string>
<string name="certsetup_retry">Reîncearcă</string>
<string name="certsetup_import_sub">Un fișier .p12 împreună cu cheia sa privată. Folosește-l pe al tău atunci când mașina ta respinge certificatul aasdk.</string>
<string name="certsetup_skip">Omite configurarea conexiunii auto</string>
<string name="certsetup_loaded">Mașinile acceptă doar certificate emise de Google, iar tu ai unul încărcat.</string>
<string name="certsetup_choose">Mașinile acceptă doar certificate emise de Google. Gearslip nu include unul, așa că alege de unde provine al tău.</string>
<string name="certsetup_aasdk_sub">Proiectul open-source aasdk publică acest certificat la adresa github.com/opencardev/aasdk. Majoritatea mașinilor îl acceptă. Unele modele fabricate începând cu anul 2020 îl resping, în acest caz va trebui să-l imporți pe al tău.</string>
<string name="storage_title">Stocarea Gearslip</string>
<string name="storage_measuring">Măsurare…</string>
<string name="storage_lyrics">Versuri</string>
<string name="storage_clear">Elimină</string>
<string name="storage_voice">Modele vocale</string>
<string name="storage_remove">Elimină</string>
<string name="storage_logs">Jurnale vechi</string>
<string name="storage_other">Alte fișiere temporare</string>
<string name="storage_delete_all">Șterge toate datele Gearslip</string>
<string name="storage_total">Datele aplicației Gearslip ocupă %1$s din spațiul de stocare al telefonului.</string>
<plurals name="storage_lyrics_detail">
<item quantity="one">%1$d melodie · %2$s. Versurile șterse se descarcă din nou atunci când le deschizi.</item>
<item quantity="few">%1$d melodii · %2$s. Versurile șterse se descarcă din nou atunci când le deschizi.</item>
<item quantity="other">%1$d de melodii · %2$s. Versurile șterse se descarcă din nou atunci când le deschizi.</item>
</plurals>
<string name="storage_voice_detail">%1$s. Comenzile vocale au nevoie de ele; descarcă-le din nou în Setări.</string>
<string name="storage_logs_detail">%1$s. Jurnalele lansărilor anterioare; jurnalul acestei lansări rămâne.</string>
<string name="storage_other_detail">%1$s. Actualizările descărcate și alte fișiere pe care Gearslip le poate genera din nou.</string>
<string name="storage_delete_armed">Atinge din Șterge pentru a șterge setările, certificatele, modelele vocale și jurnalele. Gearslip se închide și, data viitoare, va porni din ecranul de configurare.</string>
<string name="storage_delete_sub">Setări, certificate, modele vocale, versuri și jurnale.</string>
<string name="storage_delete">Șterge</string>
<string name="storage_delete_ellipsis">Ștergere…</string>
<string name="donate_title">Mă bucur că călătoria a fost reușită</string>
<string name="donate_liberapay">Sprijin pe Liberapay</string>
<string name="donate_bmac">Cumpără-mi o cafea</string>
<string name="donate_card">Fă o donație unică cu cardu</string>
<string name="donate_body">Gearslip este gratuit, fără reclame și fără urmărire. Dacă te ajută să pornești la drum, sprijinul tău îi permite să continue să funcționeze.</string>
<string name="wireless_needs_permission">Pentru aceasta, Gearslip are nevoie de conexiune Bluetooth și de acces la o rețea Wi-Fi din apropiere.</string>
<string name="wireless_enable_permission">Activează permisiunea „Dispozitive din apropiere”</string>
<string name="wireless_connect">Conectare fără fir</string>
<string name="wireless_which_car">Ce mașină?</string>
<string name="wireless_none_paired">Telefonul tău nu este încă asociat cu nimic. Asociază-l mai întâi cu mașina.</string>
<string name="wireless_not_listed">Mașina nu apare în listă? Conecteaz-o mai întâi la telefon.</string>
<string name="bluetooth_settings">Setări Bluetooth</string>
<string name="voice_live_title">Voce: cuvinte în direct</string>
<string name="voice_final_title">Voce: cuvintele de încheiere</string>
<string name="voice_none">Nimic</string>
<string name="voice_none_sub">Păstrează cuvintele din textul original. Mai rapid, mai puțin precis</string>
<string name="voice_live_body">Asistentul vocal transformă vorbirea în text pe acest telefon, iar nimic din ceea ce spui nu părăsește dispozitivul. Are nevoie de două modele, care ocupă împreună aproximativ 270 MB, descărcate o singură dată. Acesta îți transcrie cuvintele pe măsură ce vorbești.</string>
<string name="voice_final_body">Când încetezi să mai vorbești, acest model citește din nou întreaga frază. El identifică numele și locurile pe care modelul în timp real nu le sesizează.</string>
<string name="voice_model_size">%1$s · %2$d MB</string>
<string name="voice_downloaded">Descărcat. %1$s</string>
<string name="voice_downloading">Se descarcă, %1$d%%</string>
<string name="home_help">Ajutor</string>
<string name="home_preview">Previzualizare</string>
<string name="home_logs">Jurnale</string>
<string name="home_disconnect">Deconectează</string>
<string name="home_bt_title">Bluetooth-ul nu este conectat</string>
<string name="home_debug_build">%1$s · versiune de depanare</string>
<string name="home_bt_body">Muzica se redă prin Bluetooth, dar telefonul tău nu este conectat la mașină. Conectează-l de aici sau din meniul Bluetooth al mașinii.</string>
<string name="home_voice_downloading">Se descarcă vocea, %1$d%%</string>
<string name="audio_bt_title">Se redă muzică din sistemul audio al mașinii prin Bluetooth?</string>
<string name="audio_never_ask">Nu întreba niciodată, folosind un computer</string>
<string name="audio_use_bt">Folosește Bluetooth</string>
<string name="audio_keep_asking">Continuă interogarea</string>
<string name="audio_mic_title">Sistemul audio al mașinii necesită permisiunea de acces la microfon</string>
<string name="audio_open_settings">Deschide setările aplicației</string>
<string name="audio_bt_body">Android tratează muzica pe care Gearslip o transmite către mașină ca pe o partajare a ecranului, așa că solicită permisiunea la fiecare călătorie, fie că se face prin cablu, fie wireless. Prin Bluetooth nu solicită niciodată permisiunea. Poți modifica această setare ulterior în Setări.</string>
<string name="audio_keep_usb">Sau poți menține redarea sunetului prin Gearslip și să rezolvi definitiv problema: conectează telefonul la un computer cu opțiunea de depanare USB activată și rulează această procedură o singură dată.</string>
<string name="audio_mic_body">Android permite unei aplicații să transmită sunetul altor aplicații către mașină numai dacă are permisiunea de acces la microfon. Această opțiune este dezactivată pentru Gearslip, așa că muzica se redă pe telefon. Activează-o din secțiunea „Permisiuni”, apoi „Microfon”.</string>
<string name="typing_close">Închide tastatura mașinii</string>
<string name="typing_label">Introducere pe ecranul mașinii</string>
<string name="typing_search">Căutare pe ecranul mașinii</string>
<string name="command_tap_copy">Atinge pentru a copia</string>
<string name="command_copied">Comanda a fost copiată</string>
<string name="whats_new_this_version">%1$s (această versiune)</string>
<string name="carset_theme">Temă</string>
<string name="carset_imperial">Imperiale (mi, ft, °F)</string>
<string name="carset_metric">Metrice (km, m, °C)</string>
<string name="carset_screen_size">Dimensiunea ecranului (următoarea conexiune)</string>
<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>
<string name="carset_vehicle">Vehicul</string>
<string name="carset_video">Video</string>
<string name="carset_version">Versiune</string>
<string name="carset_build">Asamblare</string>
<string name="carset_whats_new_after">Afișează noutățile după actualizări</string>
<string name="carset_whats_new_after_sub">Afișează lista modificărilor o singură dată, la prima conectare a mașinii după o actualizare.</string>
<string name="carset_web_default">DuckDuckGo (implicit) – atinge pentru a seta o altă pagină</string>
<string name="carset_web_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>
<string name="carset_bottom">Jos</string>
<string name="carset_save_vehicle">Salvează acest vehicul</string>
<string name="carset_no_profile">Nu s-a găsit niciun profil de vehicul care să corespundă</string>
<string name="carset_audio_note">Prin intermediul aplicației Gearslip, sunetul telefonului este transmis prin conexiunea auto, fie prin cablu, fie fără fir. Sistemul Android îți solicită să partajezi ecranul la fiecare călătorie, iar unele aplicații blochează această funcție. Prin Bluetooth, sunetul se redă așa cum ar face-o în mod normal telefonul, iar sistemul Android nu îți solicită niciodată permisiunea.</string>
<string name="carset_directions_note">Gearslip citește cu voce tare fiecare viraj prin difuzoarele mașinii, iar volumul muzicii scade pe durata citirii. Dezactivează dacă vocea aplicației tale de hărți se aude deja în mașină.</string>
<string name="carset_phone_speech_note">Utilizează serviciul de recunoaștere vocală al telefonului în locul celui al aplicației Gearslip. Pe majoritatea telefoanelor, acesta este serviciul Google, care ar putea transmite vocea ta către Google. GrapheneOS nu dispune de un astfel de serviciu, așa că Gearslip continuă să-l folosească pe al său.</string>
<string name="carset_vehicle_data_note">Adaugă o casetă cu date despre vehicul: un flux în timp real, neprelucrat, provenit de la canalul senzorului unității principale. Etichetele câmpurilor (viteză, turație, combustibil...) sunt preluate din alte proiecte de inginerie inversă și nu au fost verificate în cazul acestui autoturism — tratează-le ca pe o estimare, nu ca pe un fapt cert, până când nu observi personal o schimbare a uneia dintre ele.</string>
<string name="carset_adaptive_note">Reduce calitatea imaginii atunci când mașina sau rețeaua Wi-Fi a acesteia nu reușesc să țină pasul și o restabilește odată ce acestea revin la normal. Setarea intră în vigoare la următoarea conexiune.</string>
<string name="carset_usable_height">Înălțime utilă %1$dpx din 480</string>
<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>
<string name="media_search_hint">Introdu numele unei melodii, al unui artist, al unui album sau al unei liste de redare.</string>
<string name="media_looking_lyrics">Se caută versuri…</string>
<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 poate continua să controleze aplicația odată ce are acces la notificări pe telefon. Permite acest lucru din Setări &gt; Notificări &gt; 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>
<string name="media_refused">%1$s nu i-a permis aplicației Gearslip să o răsfoiască.</string>
<string name="media_refused_body">Unele aplicații media acceptă doar serverul propriu al Google.</string>
<string name="media_connecting">Se stabilește conexiunea cu %1$s…</string>
<string name="media_search">Caută: %1$s</string>
<string name="media_search_no_list">%1$s nu poate afișa rezultatele. Apasă pe „Căutare”, iar %1$s va alege ce să redea.</string>
<string name="media_search_unsupported">%1$s nu poate efectua căutări din mașină.</string>
<string name="media_searching">Căutare %1$s…</string>
<string name="media_search_nothing">%1$s nu a găsit nimic pentru „%2$s”.</string>
<string name="media_search_ignored">%1$s nu a redat nimic pentru „%2$s”.</string>
<string name="media_searching_for">Se caută „%2$s” în %1$s…</string>
<string name="media_nothing_playing_dot">Nu se redă nimic.</string>
<string name="media_no_lyrics">Nu s-au găsit versuri pentru această piesă.</string>
<string name="media_up">Sus</string>
<string name="media_loading">Se încarcă…</string>
<string name="media_browse_start">Pornește muzica în aplicația de pe telefon. De aici poți reda, pune pe pauză, omite melodiile și naviga prin ele.</string>
<string name="media_load_failed">Nu s-a putut încărca această listă.</string>
<string name="media_nothing_here">Nimic aici.</string>
<string name="media_nothing_queued">Nu există nimic în coadă.</string>
<string name="media_play">Redare</string>
<string name="media_queue_empty_idle">Nu se redă nimic – nu toate aplicațiile îi comunică aplicației Gearslip ce urmează.</string>
<string name="media_queue_empty_playing">Doar ceea ce se redă în prezent – nu toate aplicațiile îi comunică aplicației Gearslip ce urmează.</string>
<string name="player_previous">Anterior</string>
<string name="player_next">Înainte</string>
<string name="player_repeat">Repetare</string>
<string name="player_pause">Pauză</string>
<string name="player_unlike">Nu aprecia</string>
<string name="player_like">Apreciază</string>
<string name="player_shuffle_off">Amestecarea este oprită</string>
<string name="player_shuffle_on">Amestecare pornită</string>
<string name="home_choose_app">Alege o altă aplicație</string>
<string name="home_no_maps">Nu s-au găsit aplicații de hărți.</string>
<string name="home_no_maps_body">Instalează o aplicație de navigație compatibilă cu Android Auto și aceasta se va porni automat de aici.</string>
<string name="home_map_stopped">Harta s-a oprit</string>
<string name="home_map_stopped_body">Rămâne închisă până când o deschizi din nou.</string>
<string name="home_reopen_map">Redeschide harta</string>
<string name="home_car_apps">Aplicațiile mașinii</string>
<string name="home_back_nav">Înapoi la bara de navigare</string>
<string name="home_show_player">Afișează playerul</string>
<string name="home_hide_lyrics">Ascunde versurile</string>
<string name="home_starting">Pornește %1$s…</string>
<string name="home_starting_map">Se încarcă harta…</string>
<string name="home_failed">%1$s nu a pornit.</string>
<string name="home_failed_map">Aplicația de hărți nu s-a deschis.</string>
<string name="crumb_home">Ecranul de pornire</string>
<string name="crumb_media">Media</string>
<string name="crumb_apps">Aplicații</string>
<string name="crumb_dashboard">Tablou de bord</string>
<string name="crumb_weather">Vreme</string>
<string name="crumb_reply">Răspunde</string>
<string name="app_browser">Browser</string>
<string name="app_screen_sharing">Partajarea ecranului</string>
<string name="app_exit">Ieșire</string>
<string name="navapps_unpin">Dezlipește din bara de navigare</string>
<string name="navapps_full">Bara de navigare este plină (%1$d fixate)</string>
<string name="navapps_pin">Fixează în bara de navigare</string>
<string name="navapps_close">Închide aplicația</string>
<string name="navapps_move_left">Deplasează spre stânga</string>
<string name="navapps_move_right">Deplasează spre dreapta</string>
<string name="navapps_reset">Resetează ordinea aplicațiilor</string>
<string name="msg_dismiss">Respinge</string>
<string name="msg_read">Citește</string>
<string name="msg_stop">Oprește</string>
<string name="msg_no">Nu</string>
<string name="msg_send_now">Trimite acum</string>
<string name="msg_type">Formează</string>
<plurals name="msg_burst">
<item quantity="one">%1$s · %2$d nouă</item>
<item quantity="few">%1$s · %2$d noi</item>
<item quantity="other">%1$s · %2$d noi</item>
</plurals>
<string name="msg_reply_to">Răspunde %1$s</string>
<string name="msg_listening">Ascult</string>
<string name="msg_sending_to">Se trimite către %1$s</string>
<string name="msg_sent_to">Trimis către %1$s</string>
<string name="msg_reply_failed">Nu am putut răspunde</string>
<string name="bt_pair_unfinished">Conectarea nu s-a finalizat. Realizează conectarea din meniul Bluetooth al mașinii.</string>
<string name="bt_pair_not_started">Conectarea nu a început. Realizează conectarea din meniul Bluetooth al mașinii.</string>
<string name="bt_paired">Conectat. Apelurile se aud acum prin sistemul audio al mașinii.</string>
<string name="overlay_dont_show">Nu mai afișa</string>
<string name="overlay_ok">OK</string>
<string name="overlay_location_off">Locația este dezactivată</string>
<string name="overlay_pair_title">Asociere cu această mașină?</string>
<string name="overlay_pair">Asociere</string>
<string name="overlay_confirm_title">Confirmă pe telefonul tău</string>
<string name="overlay_confirm_body">Verifică codul de pe telefon și atinge „Asociere”. Este posibil ca și mașina să solicite acest lucru.</string>
<string name="overlay_bt_body">Muzica se redă prin Bluetooth, dar telefonul tău nu este conectat la mașină. Conectează-l din meniul Bluetooth al mașinii sau din cel al telefonului tău.</string>
<string name="overlay_location_body">Aplicațiile de hărți nu pot afișa locația ta și nici nu pot oferi indicații de orientare. Activează funcția de localizare din setările rapide ale telefonului atunci când ești parcat.</string>
<string name="overlay_pair_body_music">Apelurile și muzica se redau prin difuzoarele mașinii odată ce telefonul și mașina sunt conectate. Apoi, telefonul îți va solicita să confirmi un cod.</string>
<string name="overlay_pair_body">Apelurile se aud prin difuzoarele mașinii odată ce telefonul și mașina sunt conectate. Apoi, telefonul îți cere să confirmi un cod.</string>
<string name="weather_waiting_fix">Se așteaptă determinarea poziției.</string>
<string name="weather_allow_location">Permite accesul la locație pentru aplicația Gearslip de pe telefon, pentru a vedea prognoza meteo.</string>
<string name="weather_unreachable">Nu s-a putut stabili conexiunea cu serviciul de prognoză meteo.</string>
<string name="weather_clear">Senin</string>
<string name="weather_mostly_clear">În mare parte senin</string>
<string name="weather_partly_cloudy">Parțial înnorat</string>
<string name="weather_overcast">Cer înnorat</string>
<string name="weather_fog">Ceață</string>
<string name="weather_drizzle">Ploaie măruntă</string>
<string name="weather_rain">Ploaie</string>
<string name="weather_snow">Zăpadă</string>
<string name="weather_rain_showers">Averse de ploaie</string>
<string name="weather_snow_showers">Averse de zăpadă</string>
<string name="weather_thunderstorm">Furtună cu fulgere</string>
<string name="weather_getting">Se obțin datele meteo…</string>
<string name="weather_next_hours">Următoarele ore</string>
<string name="weather_this_week">Această săptămână</string>
<string name="weather_today">Astăzi</string>
<string name="weather_credit">Date meteo furnizate de Open-Meteo.com (CC BY 4.0)</string>
<string name="weather_feels_like">Se simte ca %1$s</string>
<string name="weather_humidity">Umiditate %1$d%%</string>
<string name="weather_wind">Vânt %1$s</string>
<string name="dash_today">Astăzi</string>
<string name="dash_getting_weather">Se obțin datele meteo…</string>
<string name="dash_coming_up">În curând</string>
<string name="dash_loading_calendar">Se încarcă calendarul…</string>
<string name="dash_nothing_week">Nu este nimic programat săptămâna viitoare.</string>
<string name="dash_all_day">Toată ziua</string>
<string name="dialer_contacts">Contacte</string>
<string name="dialer_keypad">Tastatură numerică</string>
<string name="dialer_ask">Întreabă pe telefon</string>
<string name="dialer_enter_number">Introdu un număr</string>
<string name="dialer_call">Apelează</string>
<string name="dialer_no_contacts">Nu s-au găsit contacte cu acest număr de telefon.</string>
<string name="dialer_search">Căutare contacte</string>
<string name="dialer_needs_permission">Aplicația Gearslip are nevoie de permisiunile „Telefon” și „Contacte” pentru a putea efectua apeluri de aici. Acordă-i aceste permisiuni pe telefon atunci când ești parcat.</string>
<string name="dialer_no_match">Nu există contacte care să corespundă cu „%1$s”.</string>
<string name="dialer_call_who">Suni pe %1$s?</string>
<string name="carui_exit_fullscreen">Ieșire din modul ecran complet</string>
<string name="carui_drive_safely">Condu cu prudență</string>
<string name="carui_understand">Am înțeles</string>
<string name="carui_unofficial">Gearslip este un program neoficial: nu este creat sau verificat de niciun producător auto și poate prezenta defecțiuni fără avertisment.</string>
<string name="carui_never_interact">Nu interacționa niciodată cu acest ecran în timp ce conduci. Setează traseul sau melodia înainte de a porni la drum, apoi concentrează-ți atenția asupra drumului.</string>
<string name="carui_this_app">Această aplicație</string>
<string name="carui_app_unsupported">%1$s nu funcționează încă cu Gearslip.</string>
<string name="carui_app_unsupported_body">Nu s-a pornit. Unele aplicații funcționează doar pe Android Auto de la Google.</string>
<string name="navbar_on_call">Apel</string>
<string name="navbar_end_call">Încheie apelul</string>
<string name="navbar_unmute">Pornește sunetul</string>
<string name="navbar_mute">Fără sunet</string>
<string name="navbar_close_panel">Închide panoul lateral</string>
<string name="navbar_dock_player">Mută playerul lângă hartă</string>
<string name="web_search">Caută sau introdu o adresă</string>
<string name="web_forward">Redirecționare</string>
<string name="web_reload">Reîncărcare</string>
<string name="web_start_page">Pagina de start</string>
<string name="web_fullscreen">Ecran complet</string>
<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">Notificările sunt dezactivate. Permite aplicației Gearslip accesul la notificări din Setări &gt; Notificări &gt; 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>
<string name="msg_none_recent">Nu există mesaje recente. Mesajele noi %1$s apar aici pe măsură ce sunt primite pe telefon.</string>
<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>
</resources>
@@ -374,6 +374,8 @@
<string name="wireless_which_car">Какая машина?</string> <string name="wireless_which_car">Какая машина?</string>
<string name="wireless_none_paired">Телефон пока ни с чем не связан. Сначала свяжите его с машиной.</string> <string name="wireless_none_paired">Телефон пока ни с чем не связан. Сначала свяжите его с машиной.</string>
<string name="wireless_not_listed">Машины нет в списке? Сначала свяжите её с телефоном.</string> <string name="wireless_not_listed">Машины нет в списке? Сначала свяжите её с телефоном.</string>
<string name="wireless_auto_off">Не подключается сам</string>
<string name="wireless_auto_turn_on">Разрешить подключаться самому</string>
<string name="bluetooth_settings">Настройки Bluetooth</string> <string name="bluetooth_settings">Настройки Bluetooth</string>
<!-- Voice models --> <!-- Voice models -->
@@ -845,4 +847,7 @@
<string name="places_finding">Ищу…</string> <string name="places_finding">Ищу…</string>
<string name="places_not_found">Gearslip не нашёл, где вы. Проверьте, что геолокация включена.</string> <string name="places_not_found">Gearslip не нашёл, где вы. Проверьте, что геолокация включена.</string>
<string name="places_clear">Очистить</string> <string name="places_clear">Очистить</string>
<string name="settings_language">Язык</string>
<string name="settings_language_phone">Как на телефоне</string>
<string name="player_next_chapter">Следующая глава</string>
</resources> </resources>
@@ -372,6 +372,8 @@
<string name="wireless_which_car">Vilken bil?</string> <string name="wireless_which_car">Vilken bil?</string>
<string name="wireless_none_paired">Telefonen är inte parkopplad med något än. Parkoppla den med bilen först.</string> <string name="wireless_none_paired">Telefonen är inte parkopplad med något än. Parkoppla den med bilen först.</string>
<string name="wireless_not_listed">Finns inte bilen med? Parkoppla den med telefonen först.</string> <string name="wireless_not_listed">Finns inte bilen med? Parkoppla den med telefonen först.</string>
<string name="wireless_auto_off">Ansluter inte av sig själv</string>
<string name="wireless_auto_turn_on">Låt den ansluta av sig själv</string>
<string name="bluetooth_settings">Bluetooth-inställningar</string> <string name="bluetooth_settings">Bluetooth-inställningar</string>
<!-- Voice models --> <!-- Voice models -->
@@ -829,4 +831,7 @@
<string name="places_finding">Söker…</string> <string name="places_finding">Söker…</string>
<string name="places_not_found">Gearslip hittade inte var du är. Kontrollera att platstjänster är på.</string> <string name="places_not_found">Gearslip hittade inte var du är. Kontrollera att platstjänster är på.</string>
<string name="places_clear">Rensa</string> <string name="places_clear">Rensa</string>
<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>
</resources> </resources>
@@ -372,6 +372,8 @@
<string name="wireless_which_car">Hangi araç?</string> <string name="wireless_which_car">Hangi araç?</string>
<string name="wireless_none_paired">Telefonunuz henüz hiçbir şeyle eşlenmedi. Önce araçla eşleyin.</string> <string name="wireless_none_paired">Telefonunuz henüz hiçbir şeyle eşlenmedi. Önce araçla eşleyin.</string>
<string name="wireless_not_listed">Araç listede yok mu? Önce telefonunuzla eşleyin.</string> <string name="wireless_not_listed">Araç listede yok mu? Önce telefonunuzla eşleyin.</string>
<string name="wireless_auto_off">Kendiliğinden bağlanmıyor</string>
<string name="wireless_auto_turn_on">Kendiliğinden bağlanmasına izin ver</string>
<string name="bluetooth_settings">Bluetooth ayarları</string> <string name="bluetooth_settings">Bluetooth ayarları</string>
<!-- Voice models --> <!-- Voice models -->
@@ -829,4 +831,7 @@
<string name="places_finding">Aranıyor…</string> <string name="places_finding">Aranıyor…</string>
<string name="places_not_found">Gearslip konumunuzu bulamadı. Konumun açık olduğundan emin olun.</string> <string name="places_not_found">Gearslip konumunuzu bulamadı. Konumun açık olduğundan emin olun.</string>
<string name="places_clear">Temizle</string> <string name="places_clear">Temizle</string>
<string name="settings_language">Dil</string>
<string name="settings_language_phone">Telefonla aynı</string>
<string name="player_next_chapter">Sonraki bölüm</string>
</resources> </resources>
@@ -374,6 +374,8 @@
<string name="wireless_which_car">Яке авто?</string> <string name="wireless_which_car">Яке авто?</string>
<string name="wireless_none_paired">Телефон поки ні з чим не з’єднано. Спершу з’єднайте його з авто.</string> <string name="wireless_none_paired">Телефон поки ні з чим не з’єднано. Спершу з’єднайте його з авто.</string>
<string name="wireless_not_listed">Авто немає в списку? Спершу з’єднайте його з телефоном.</string> <string name="wireless_not_listed">Авто немає в списку? Спершу з’єднайте його з телефоном.</string>
<string name="wireless_auto_off">Не підключається сам</string>
<string name="wireless_auto_turn_on">Дозволити підключатися самому</string>
<string name="bluetooth_settings">Налаштування Bluetooth</string> <string name="bluetooth_settings">Налаштування Bluetooth</string>
<!-- Voice models --> <!-- Voice models -->
@@ -845,4 +847,7 @@
<string name="places_finding">Шукаю…</string> <string name="places_finding">Шукаю…</string>
<string name="places_not_found">Gearslip не знайшов, де ви. Перевірте, чи увімкнено геолокацію.</string> <string name="places_not_found">Gearslip не знайшов, де ви. Перевірте, чи увімкнено геолокацію.</string>
<string name="places_clear">Очистити</string> <string name="places_clear">Очистити</string>
<string name="settings_language">Мова</string>
<string name="settings_language_phone">Як на телефоні</string>
<string name="player_next_chapter">Наступний розділ</string>
</resources> </resources>
@@ -371,6 +371,8 @@
<string name="wireless_which_car">哪辆车?</string> <string name="wireless_which_car">哪辆车?</string>
<string name="wireless_none_paired">您的手机还没有与任何设备配对。请先与车配对。</string> <string name="wireless_none_paired">您的手机还没有与任何设备配对。请先与车配对。</string>
<string name="wireless_not_listed">没有看到您的车?请先将它与手机配对。</string> <string name="wireless_not_listed">没有看到您的车?请先将它与手机配对。</string>
<string name="wireless_auto_off">不会自动连接</string>
<string name="wireless_auto_turn_on">允许自动连接</string>
<string name="bluetooth_settings">蓝牙设置</string> <string name="bluetooth_settings">蓝牙设置</string>
<!-- Voice models --> <!-- Voice models -->
@@ -821,4 +823,7 @@
<string name="places_finding">正在定位…</string> <string name="places_finding">正在定位…</string>
<string name="places_not_found">Gearslip 找不到你的位置。请检查定位是否已开启。</string> <string name="places_not_found">Gearslip 找不到你的位置。请检查定位是否已开启。</string>
<string name="places_clear">清除</string> <string name="places_clear">清除</string>
<string name="settings_language">语言</string>
<string name="settings_language_phone">与手机相同</string>
<string name="player_next_chapter">下一章</string>
</resources> </resources>
+31
View File
@@ -148,6 +148,9 @@
<string name="cert_kind_self">Self-signed: %1$s</string> <string name="cert_kind_self">Self-signed: %1$s</string>
<!-- Car settings on the phone --> <!-- Car settings on the phone -->
<string name="car_apps_sub">Choose which apps appear in the car</string>
<string name="car_apps_finding">Finding apps…</string>
<string name="car_apps_none">No car apps found</string>
<string name="car_group_screen">Screen</string> <string name="car_group_screen">Screen</string>
<string name="car_group_nav_bar">Nav bar and player</string> <string name="car_group_nav_bar">Nav bar and player</string>
<string name="car_group_sound">Sound and voice</string> <string name="car_group_sound">Sound and voice</string>
@@ -189,6 +192,10 @@
<string name="car_group_same">These are the same settings as Settings in the car. A change here shows there too.</string> <string name="car_group_same">These are the same settings as Settings in the car. A change here shows there too.</string>
<string name="car_open_on_connect">Open on connect</string> <string name="car_open_on_connect">Open on connect</string>
<string name="car_size">Size</string> <string name="car_size">Size</string>
<string name="car_density">Car display density</string>
<string name="car_density_note">Larger text and controls, including in apps. Try 200 DPI with Size on Normal. Reconnect the car or reopen the preview to apply it. If controls clip, choose a lower DPI or From car.</string>
<string name="choice_density_car">From car</string>
<string name="choice_density_dpi">%1$d DPI</string>
<string name="car_app_theme">App theme</string> <string name="car_app_theme">App theme</string>
<string name="car_dark_mode">Dark mode</string> <string name="car_dark_mode">Dark mode</string>
<string name="car_buttons">Buttons</string> <string name="car_buttons">Buttons</string>
@@ -269,6 +276,8 @@
<string name="help_mic_detail">Android won\'t let an app pass music to the car without it. Gearslip listens only when you tap the mic or press the talk button on your wheel.</string> <string name="help_mic_detail">Android won\'t let an app pass music to the car without it. Gearslip listens only when you tap the mic or press the talk button on your wheel.</string>
<string name="help_plug_in">Plug in, or connect wirelessly.</string> <string name="help_plug_in">Plug in, or connect wirelessly.</string>
<string name="help_plug_in_detail">Gearslip opens by itself when the car connects. For wireless, pair the phone with the car first, then tap Connect wirelessly on Home.</string> <string name="help_plug_in_detail">Gearslip opens by itself when the car connects. For wireless, pair the phone with the car first, then tap Connect wirelessly on Home.</string>
<string name="help_usb_data">Allow USB data.</string>
<string name="help_usb_data_detail">GrapheneOS can block data on the USB port. If USB-C port control is set to Charging-only, the car never sees the phone, even with a data cable. Set it to allow data.</string>
<string name="help_pair">Pair over Bluetooth for calls.</string> <string name="help_pair">Pair over Bluetooth for calls.</string>
<string name="help_pair_detail">Calls play through the car once your phone and the car are paired. Gearslip offers to pair the first time you plug in.</string> <string name="help_pair_detail">Calls play through the car once your phone and the car are paired. Gearslip offers to pair the first time you plug in.</string>
<string name="help_answer">Answer calls from the car screen.</string> <string name="help_answer">Answer calls from the car screen.</string>
@@ -381,6 +390,8 @@
<string name="wireless_which_car">Which car?</string> <string name="wireless_which_car">Which car?</string>
<string name="wireless_none_paired">Your phone isn\'t paired with anything yet. Pair it with the car first.</string> <string name="wireless_none_paired">Your phone isn\'t paired with anything yet. Pair it with the car first.</string>
<string name="wireless_not_listed">Car not listed? Pair it with your phone first.</string> <string name="wireless_not_listed">Car not listed? Pair it with your phone first.</string>
<string name="wireless_auto_off">Doesn\'t connect on its own</string>
<string name="wireless_auto_turn_on">Let it connect on its own</string>
<string name="bluetooth_settings">Bluetooth settings</string> <string name="bluetooth_settings">Bluetooth settings</string>
<!-- Voice models --> <!-- Voice models -->
@@ -464,6 +475,8 @@
<!-- --> <!-- -->
<string name="carset_audio_note">Through Gearslip sends the phone\'s sound over the car connection, cable or wireless. Android asks to share your screen each drive, and a few apps block it. Bluetooth plays as the phone normally would, and Android never asks.</string> <string name="carset_audio_note">Through Gearslip sends the phone\'s sound over the car connection, cable or wireless. Android asks to share your screen each drive, and a few apps block it. Bluetooth plays as the phone normally would, and Android never asks.</string>
<string name="car_directions_over_music">Directions over the music channel</string>
<string name="car_directions_over_music_note">For a car that drops the connection or restarts after a spoken turn. Gearslip mixes each turn into the music instead of using the car\'s directions channel.</string>
<string name="carset_directions_note">Gearslip reads each turn aloud through the car\'s speakers, and the music dips while it speaks. Turn it off if your map app\'s own voice already plays in the car.</string> <string name="carset_directions_note">Gearslip reads each turn aloud through the car\'s speakers, and the music dips while it speaks. Turn it off if your map app\'s own voice already plays in the car.</string>
<string name="carset_phone_speech_note">Listens through the phone\'s own speech service instead of Gearslip\'s. On most phones that\'s Google\'s, which may send your voice to Google. GrapheneOS has none, so Gearslip keeps using its own.</string> <string name="carset_phone_speech_note">Listens through the phone\'s own speech service instead of Gearslip\'s. On most phones that\'s Google\'s, which may send your voice to Google. GrapheneOS has none, so Gearslip keeps using its own.</string>
<string name="carset_vehicle_data_note">Adds a Vehicle data tile: a live, raw dump of the head unit\'s sensor channel. Field labels (speed, RPM, fuel...) are carried over from other reverse-engineering projects and unverified against this car - treat them as a guess, not a fact, until you\'ve watched one change for yourself.</string> <string name="carset_vehicle_data_note">Adds a Vehicle data tile: a live, raw dump of the head unit\'s sensor channel. Field labels (speed, RPM, fuel...) are carried over from other reverse-engineering projects and unverified against this car - treat them as a guess, not a fact, until you\'ve watched one change for yourself.</string>
@@ -856,6 +869,21 @@
<!-- Connection status on Home --> <!-- Connection status on Home -->
<string name="status_idle">Not connected</string> <string name="status_idle">Not connected</string>
<string name="status_idle_detail">Plug into the car\'s USB port, or connect wirelessly. Gearslip opens by itself when the car answers.</string> <string name="status_idle_detail">Plug into the car\'s USB port, or connect wirelessly. Gearslip opens by itself when the car answers.</string>
<string name="status_no_car">The phone is charging, but no car has answered. Some cables only charge; try one that carries data.</string>
<string name="status_no_car_graphene">The phone is charging, but no car has answered. Check that USB-C port control, under Security &amp; privacy, allows data and isn\'t set to Charging-only.</string>
<string name="status_stuck_cable">The car hasn\'t shown Gearslip yet. Unplug the cable, wait a few seconds, and plug it back in. If it keeps happening, try another cable or USB port, and share the log in an issue.</string>
<string name="status_stuck_wireless">The car hasn\'t shown Gearslip yet. Turn the car off and on again, or connect with a cable. If it keeps happening, share the log in an issue.</string>
<string name="settings_bt_address">Phone\'s Bluetooth address</string>
<string name="settings_bt_address_off">Not set. Only for a car that drops Bluetooth when Gearslip connects.</string>
<string name="settings_bt_address_hint">Copy it from Android\'s Settings, under About phone. Gearslip passes it to the car, so the car knows the phone on the cable is the one it\'s paired with.</string>
<string name="settings_bt_address_invalid">That isn\'t a Bluetooth address. It looks like 12:34:56:78:9A:BC.</string>
<string name="settings_use_as_launcher">Use as launcher</string>
<string name="settings_use_as_launcher_off">For a head unit or a tablet on the dash. Gearslip\'s car screen becomes this device\'s home screen.</string>
<string name="settings_use_as_launcher_on">Gearslip can be this device\'s home screen. Pick it under Default home app.</string>
<string name="app_all_apps">All apps</string>
<string name="launcher_in_car">This device is connected to a car, so Gearslip is on the car\'s screen.</string>
<string name="launcher_open_gearslip">Open Gearslip</string>
<string name="status_open_settings">Open settings</string>
<string name="status_connecting">Connecting</string> <string name="status_connecting">Connecting</string>
<string name="status_projecting">Projecting</string> <string name="status_projecting">Projecting</string>
<string name="status_projecting_detail">Streaming to the head unit.</string> <string name="status_projecting_detail">Streaming to the head unit.</string>
@@ -873,4 +901,7 @@
<string name="places_finding">Finding you…</string> <string name="places_finding">Finding you…</string>
<string name="places_not_found">Gearslip couldn’t find where you are. Check that location is on.</string> <string name="places_not_found">Gearslip couldn’t find where you are. Check that location is on.</string>
<string name="places_clear">Clear</string> <string name="places_clear">Clear</string>
<string name="settings_language">Language</string>
<string name="settings_language_phone">Same as the phone</string>
<string name="player_next_chapter">Next chapter</string>
</resources> </resources>
@@ -11,7 +11,10 @@ import javax.net.ssl.SSLEngineResult
import javax.net.ssl.SSLEngineResult.HandshakeStatus import javax.net.ssl.SSLEngineResult.HandshakeStatus
import javax.net.ssl.X509ExtendedTrustManager import javax.net.ssl.X509ExtendedTrustManager
/** Checks mutual TLS and an encrypted data round trip with an in-process head-unit peer. */ /**
* Checks mutual TLS and an encrypted data round trip with an in-process head-unit peer. Test code
* only: the peer trusts any certificate, which is the point here and must never ship in the app.
*/
object TlsSelfTest { object TlsSelfTest {
private val log = GearslipLog.tagged("TLS") private val log = GearslipLog.tagged("TLS")
@@ -0,0 +1,91 @@
package app.seb3thehacker.gearslip.car
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
class CarDensityTest {
private val context = InMemoryContext()
private fun density() = CarEnvironment.frame.value.densityDpi
@Before
fun startFromTheCar() {
CarSettings.init(context)
CarEnvironment.setDisplay(800, 480, 160)
}
@Test
fun fromCarKeepsEachCarsOwnDensity() {
assertEquals(CarSettings.DENSITY_FROM_CAR, CarSettings.carDensity.value)
for (dpi in listOf(120, 180, 213)) {
CarEnvironment.setDisplay(800, 480, dpi)
assertEquals(dpi, density())
}
}
@Test
fun aChosenDensityReachesEverythingThatReadsTheFrame() {
for (dpi in CarSettings.DENSITIES) {
CarSettings.setCarDensity(dpi)
CarEnvironment.setDisplay(800, 480, 160)
assertEquals(dpi, density())
}
}
@Test
fun aChosenDensityLeavesTheResolutionAlone() {
CarSettings.setCarDensity(240)
CarEnvironment.setDisplay(1280, 720, 160)
assertEquals(CarEnvironment.Frame(1280, 720, 240), CarEnvironment.frame.value)
}
@Test
fun aNewChoiceWaitsForTheNextSession() {
CarSettings.setCarDensity(200)
CarEnvironment.setDisplay(800, 480, 160)
CarSettings.setCarDensity(240)
assertEquals(200, density())
CarEnvironment.setDisplay(800, 480, 160)
assertEquals(240, density())
}
@Test
fun thePreviewTakesUpANewChoiceWhenItOpens() {
CarSettings.setCarDensity(200)
CarEnvironment.applyDensity()
assertEquals(200, density())
}
@Test
fun goingBackToFromCarRestoresTheCarsOwnDensity() {
CarSettings.setCarDensity(240)
CarEnvironment.setDisplay(800, 480, 180)
CarSettings.setCarDensity(CarSettings.DENSITY_FROM_CAR)
CarEnvironment.applyDensity()
assertEquals(180, density())
}
@Test
fun aPreviewSizedForThePhoneFollowsTheChoiceToo() {
assertEquals(213, CarSettings.effectiveDensity(213))
CarSettings.setCarDensity(200)
assertEquals(200, CarSettings.effectiveDensity(213))
}
@Test
fun theChoiceSurvivesARestart() {
CarSettings.setCarDensity(220)
CarSettings.init(context)
assertEquals(220, CarSettings.carDensity.value)
}
@Test
fun aSavedDensityGearslipDoesNotOfferFallsBackToTheCar() {
for (saved in listOf("201", "-1", "broken", "")) {
context.stored["car_density"] = saved
CarSettings.init(context)
assertEquals(CarSettings.DENSITY_FROM_CAR, CarSettings.carDensity.value)
}
}
}
@@ -0,0 +1,71 @@
package app.seb3thehacker.gearslip.car
import app.seb3thehacker.gearslip.notify.MessagingApp
import app.seb3thehacker.gearslip.ui.CarGroup
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
class HiddenAppsTest {
private val context = InMemoryContext()
private fun app(name: String) = Entry(name, icon = null, messaging = MessagingApp(name, name))
private fun id(name: String) = MESSAGING_PREFIX + name
private fun labels(entries: List<Entry>) = entries.map { it.label }
private val apps = listOf(app("signal"), app("telegram"), app("whatsapp"))
@Before
fun startWithNothingSaved() = CarSettings.init(context)
@Test
fun everyTileShowsUntilUnticked() {
assertEquals(emptySet<String>(), CarSettings.hiddenApps.value)
assertEquals(apps.map { it.label }, labels(launcherOrder(apps, false, emptyList(), CarSettings.hiddenApps.value)))
}
@Test
fun anUntickedTileLeavesTheDrawer() {
val shown = launcherOrder(apps, false, emptyList(), setOf(id("telegram")))
assertEquals(listOf("signal", "whatsapp"), labels(shown))
}
@Test
fun gearslipsOwnTilesStayWhenInstalledOnesAreHidden() {
val all = BuiltInApps.everyEntry + apps
val shown = launcherOrder(all, false, emptyList(), apps.mapNotNull { it.componentId }.toSet())
assertTrue(shown.isNotEmpty())
assertTrue(shown.all { it.builtIn != null })
}
@Test
fun aHiddenTileLeavesTheDriversOrderIntactForTheRest() {
val custom = listOf(id("whatsapp"), id("telegram"), id("signal"))
val shown = launcherOrder(apps, false, custom, setOf(id("telegram")))
assertEquals(listOf("whatsapp", "signal"), labels(shown))
}
@Test
fun aTileComesBackWhenTickedAgain() {
CarSettings.setAppHidden(id("signal"), true)
CarSettings.setAppHidden(id("telegram"), true)
CarSettings.setAppHidden(id("signal"), false)
assertEquals(setOf(id("telegram")), CarSettings.hiddenApps.value)
}
@Test
fun theChoicesSurviveARestart() {
CarSettings.setAppHidden(id("signal"), true)
CarSettings.setAppHidden(id("whatsapp"), true)
CarSettings.init(context)
assertEquals(setOf(id("signal"), id("whatsapp")), CarSettings.hiddenApps.value)
}
@Test
fun appsIsTheFirstCardOnThePhone() {
assertEquals(CarGroup.APPS, CarGroup.entries.first())
assertFalse(CarGroup.entries.drop(1).contains(CarGroup.APPS))
}
}
@@ -0,0 +1,32 @@
package app.seb3thehacker.gearslip.car
import android.content.ContextWrapper
import android.content.SharedPreferences
import java.lang.reflect.Proxy
/** Just enough Context for [CarSettings]: strings kept in a map, so a second init reads them back. */
class InMemoryContext : ContextWrapper(null) {
val stored = mutableMapOf<String, String>()
private val editor: SharedPreferences.Editor = proxy { self, name, args ->
if (name == "putString") stored[args[0] as String] = args[1] as String
self.takeIf { name == "putString" }
}
private val preferences: SharedPreferences = proxy { _, name, args ->
when (name) {
"edit" -> editor
"getString" -> stored[args[0] as String] ?: args[1]
else -> null
}
}
override fun getApplicationContext() = this
override fun getSharedPreferences(name: String?, mode: Int) = preferences
private inline fun <reified T> proxy(crossinline call: (Any, String, Array<out Any?>) -> Any?): T =
Proxy.newProxyInstance(T::class.java.classLoader, arrayOf(T::class.java)) { self, method, args ->
call(self, method.name, args.orEmpty())
} as T
}