Author SHA1 Message Date
Seb3thehacker 4ac389d2fd 0.3.40-alpha: version and release notes 2026-11-04 22:32:56 -08:00
Seb3thehacker 95fddf5a55 GM cars also get their Bluetooth and Wi-Fi projection channels opened
The 2021 Bolt still hangs up about 20 seconds in with pings answered and GM's channel open.
These are the two channels it offers that Gearslip had never opened. What arrives on them is
logged. Without an address typed in Settings, no PairingRequest is sent.
2026-11-04 22:27:21 -08:00
Seb3thehacker 206dd67d98 Music started again after the car left is paused again
Deezer sometimes carried on out of the phone's speaker after the car was turned off. Apps that
are buffering are paused too, and two seconds later any that started by themselves are paused
again.
2026-11-04 22:15:39 -08:00
Seb3thehacker 817b413eb1 A car that keeps its screen is asked again with the display's channel named
The 2017 Pilot (protocol 1.0) never answered the request for its screen, even once the media
channel was open. The deprecated display field is what the 2018 Uconnect needed to give its
screen back, so the retry sends it too. The retry now also runs on cars with no media channel.
2026-11-04 22:15:39 -08:00
Seb3thehacker 03f27c4dda An app's own like button lights the whole button when liked, like shuffle and repeat
Metrolist swaps ic_heart_outline for ic_heart and keeps the name, so only the heart filled
in. A plain icon that has an outline twin in the app now counts as on.
2026-11-04 22:02:38 -08:00
Seb3thehacker 42b151034a README: Waze works on the car screen 2026-11-04 20:39:38 -08:00
Seb3thehacker 7a774b4091 README covers launcher mode, phone map turns, hidden apps, density and languages 2026-11-04 20:36:24 -08:00
Seb3thehacker 2b8b221d1b A pull request that edits a translation fails, pointing to Weblate
Translated strings are Weblate's alone. A PR that changed them collided
with Weblate's copy, and Weblate locked itself until it was reset.
2026-11-04 20:28:31 -08:00
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
Seb3thehacker f6ec5c7a8e 0.3.31-alpha: version and release notes 2026-10-27 00:10:04 -07:00
Seb3thehacker 2f49559600 voice: saved places, so "navigate home" or "take me to work" works in any map app
Home, Work and three named places live in the Car card on Home, under Saved places. Type an
address or save where you're standing.
2026-10-27 00:05:15 -07:00
Seb3thehacker 57803988d4 car: Waze gets the cards taken out of its stable area too, and the areas each app is given go in the log 2026-10-26 23:44:53 -07:00
Seb3thehacker bcde51ad56 car: with the player beside the map, the map app's own buttons fold behind one, so the map controls still fit 2026-10-26 23:37:13 -07:00
Seb3thehacker cc522bb0b7 theme: the phone app follows the App theme setting too, so Light is light everywhere 2026-10-26 23:33:59 -07:00
Seb3thehacker e4c2a72fad translations: first drafts for every other Weblate language, and the README points translators to Weblate
French, Italian, Portuguese (Brazil), Dutch, Swedish, Polish, Czech, Russian, Ukrainian, Turkish,
Japanese, Korean, Chinese (Simplified) and Arabic. They are early drafts for native speakers to
fix on Weblate. The README also catches up on the audio option, now called Through Gearslip,
and on cars that need your own certificate.
2026-10-26 23:29:32 -07:00
Seb3thehacker 0627a32df7 translations: German and Spanish fixes, the Home status card reads from strings.xml, and Settings links to Weblate 2026-10-26 21:46:47 -07:00
Seb3thehacker 5df2dace77 translations: German and Spanish from Weblate, with the broken placeholders and the audio option fixed 2026-10-26 21:34:52 -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
Seb3thehacker cb30a3de69 audio: the USB option is now called Through Gearslip, since it works over wireless too 2026-10-26 21:18:46 -07:00
Seb3thehacker 4aac815b2e car: Settings, Messages, the phone screens and Vehicle data scroll right down to the nav bar 2026-10-26 21:00:05 -07:00
Seb3thehacker d915b0ce45 media: Browse and Up next run right down to the nav bar 2026-10-26 20:55:06 -07:00
Seb3thehacker 746b0b7325 media: the title and seek bar move to the top, so play, next and the buttons under them can be bigger 2026-10-26 20:39:43 -07:00
Seb3thehacker de370751a0 car: the nav bar takes the screen's own background everywhere, and the car isn't sent the same cover every second 2026-10-26 20:36:28 -07:00
Seb3thehacker e1948a823f media: the tabs fill the header again, and the nav bar goes see-through over the player so the cover's glow runs behind it 2026-10-26 20:28:47 -07:00
Seb3thehacker 738b99ff9e audio: the Bluetooth question stacks its buttons, and no longer says the music goes over USB 2026-10-26 20:28:46 -07:00
Seb3thehacker 7959b27d38 messages: the Read, Reply and other buttons sit in a row under the messages 2026-10-26 20:28:46 -07:00
Seb3thehacker 83c33eeea3 car: an experimental turn card on the instrument cluster, for cars with a cluster screen 2026-10-26 16:50:36 -07:00
Seb3thehacker b168eb7c04 host: car apps get the car's speed, odometer, fuel and range, compass and GPS 2026-10-26 16:37:04 -07:00
Seb3thehacker f4b8391fa3 car: calls show on the car's own screens, and its phone button answers 2026-10-26 16:37:04 -07:00
Seb3thehacker d1f67bb64d audio: when the car runs out of sound, Gearslip runs a little further ahead of it 2026-10-26 16:36:51 -07:00
Seb3thehacker 7d02ac8b75 help: the certificate step and Which cars work say to import your own when a car rejects the aasdk one 2026-10-26 15:17:44 -07:00
Seb3thehacker 6ff59be6ab certificate: setup and Settings say that some newer cars reject the aasdk certificate, and that you import your own then 2026-10-26 15:15:42 -07:00
Seb3thehacker dbeaa9cf5e media: small screens and big text sizes get one column with the tabs in a bar, and the controls always stay on screen
The responsive layout ideas come from M4TH1EU's pull request #46, carried onto the current
two-column design.
2026-10-26 15:06:53 -07:00
Seb3thehacker 7bfb8c8669 setup: say what the recommended-apps switch hides: only the Settings entry 2026-10-26 13:55:24 -07:00
Seb3thehacker 3fbabfa0ed car: the car screen's text and the spoken directions come from strings.xml, ready for translation; voice commands stay English 2026-10-26 13:04:29 -07:00
Seb3thehacker 0fd7568536 docs: translations happen on Weblate, with a badge in the README and how translators sign the CLA 2026-10-26 12:51:24 -07:00
Seb3thehacker 02618dbcb9 phone: the app's screens take their text from strings.xml, ready for translation, with per-app language support and pseudo-locales in debug builds 2026-10-26 12:23:20 -07:00
Seb3thehacker dc9514dcfa car: panel shadows use a real blur, so the player card's shadow matches the nav bar's instead of showing rings 2026-10-26 12:23:20 -07:00
Seb3thehacker 74f557524f car: undo the launcher's headings, the media player beside the tabs drops its card for a bigger cover with the title beside it, and the map-stopped buttons grow 2026-10-26 01:31:08 -07:00
Seb3thehacker 324112ca8b media: the player card with its cover sits beside every tab, Up next comes after Now playing, and Browse's header gets room 2026-10-26 01:25:45 -07:00
Seb3thehacker 15d6721705 phone: car settings all live under Home's car groups, with voice models, car audio and screen size moved in and a new More group 2026-10-26 01:18:35 -07:00
Seb3thehacker 1f9ef552c1 car: media app gets a big-cover Now playing and Browse with covers and sections, Apps get headings, Settings a rail of groups 2026-10-26 01:14:00 -07:00
Seb3thehacker 608d72b856 car: a faint album-colour glow on the player card, screens fade in as they open, and the phone app follows the Dark mode setting 2026-10-26 00:59:39 -07:00
Seb3thehacker 08eef32e59 car: a Dark mode setting with black, black and colour, deep colour and the old grey, black by default 2026-10-26 00:47:56 -07:00
Seb3thehacker 894712c38d car: the nav bar is a full-width strip again, and the player card loses its resize grip 2026-10-26 00:42:40 -07:00
Seb3thehacker ef1b2e1be7 home: drag the player card's edge to resize it, and the cover fills whatever the controls leave
The width is saved. The controls sit along the bottom of the card.
2026-10-26 00:34:55 -07:00
Seb3thehacker 93a1ff7485 car: the nav bar floats on every screen, and the docked player is narrower with bigger buttons 2026-10-26 00:30:50 -07:00
Seb3thehacker 8a2dae4173 home: the player floats over the map as a card, with theme shadows on the card, cover and nav bar
The map keeps its full size underneath, so docking the player only re-centres it. Tactile and Keycap cast shadows, Flat stays flat. The card slides in and a new cover fades in.
2026-10-26 00:22:28 -07:00
Seb3thehacker a1a0546b74 preview: keep the phone awake while the car preview is open, and only then 2026-10-26 00:10:28 -07:00
Seb3thehacker c616f0203a home: lyrics beside the map get a button back to the player, and the dock button opens and closes the panel on its own side 2026-10-25 21:34:39 -07:00
Seb3thehacker 08ac6fe117 home and lyrics: with the player on the left, the map's cards move to its far edge, and lyrics light up word by word
The docked player and side lyrics use the theme's surface instead of the album colour. Lyrics keep the line being sung near the middle, fade at the edges, and fill in each word for BetterLyrics songs, which now keep their word timings.
2026-10-25 19:44:19 -07:00
Seb3thehacker 46c2e92684 storage: a Manage storage screen in App info that clears lyrics, voice models and old logs one at a time 2026-10-25 15:30:47 -07:00
Seb3thehacker b2a95adb87 lyrics: a cached song looks again, once a day, for better lyrics than it has 2026-10-25 15:24:03 -07:00
Seb3thehacker 9d629c392b lyrics: debug builds show which service the lyrics came from 2026-10-25 15:21:51 -07:00
Seb3thehacker 0eea09542f lyrics: try BetterLyrics, then LRCLIB, then KuGou, as Metrolist does, with a setting for LRCLIB only 2026-10-25 15:17:35 -07:00
Seb3thehacker 5c72a18f06 settings: open faster by building the sections over a few frames, and keep scrolling smooth 2026-10-25 14:39:13 -07:00
Seb3thehacker 5cc1f518c7 phone: debug builds get a yellow home header behind the status bar, as in Glint, and the title is smaller 2026-10-25 14:20:36 -07:00
Seb3thehacker 3f393fd56f car: the size setting no longer scales the safe-area inset padding, which added margins on a car that reports insets 2026-10-25 13:19:13 -07:00
Seb3thehacker 703d8d69e8 Merge pull request #37 from jorgecanut/pr/aac-only-sinks
audio: one Start, and AAC-only sinks get their codec config first
2026-10-25 13:18:06 -07:00
jorgeandClaude Opus 5.5 89e5bbd2bb audio: send the AAC codec config to AAC-only sinks
A GalReceiver 2014 head unit, whose media sink takes only raw AAC-LC,
reset the connection on the first audio frame. Raw AAC-LC has no header
of its own, so send the AudioSpecificConfig right after Start, as video
does with SPS/PPS. PCM and ADTS sinks are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 13:26:35 +01:00
148 changed files with 21828 additions and 1627 deletions

No files matched your search

+10 -17
View File
@@ -1,20 +1,13 @@
Gearslip 0.3.12 gives the car screen a fresh look and fixes the wireless loop 0.3.02 caused on some head units. Gearslip 0.3.40 is a test build for a few cars that still don't connect.
## New If your car works on 0.3.39, you don't need this one. If you're following #18, #39 or #57, please try it and attach a log to your issue: open Gearslip, tap Logs, then Share log.
- Navigation cards sit down the left side, with a bigger turn card that shows your lane. The map keeps the right side.
- The car screen and the phone app take their colors from your phone's Material You palette.
- The car's settings sit on the phone's home screen, each group on its own screen.
- Put the home screen's player left or right of the map.
- The browser shows the page title, and it has an address bar, forward, reload, and a start page.
- If Gearslip lacks call permissions, the car's Phone screen asks for them on the phone.
## Changed
- Gearslip uses Car App Library 1.8. Map apps show the right turn arrows and route times, and Vela works fully.
- Setup ends with recommended apps, then the button picker, then usage notes.
## Fixed ## Fixed
- Some head units, such as ZLink adapters, sent their Wi-Fi details twice. Gearslip rejoined the network each time and dropped the connection. It now ignores the repeat. - A car that holds back its screen is asked for it again with the detail older cars expect. This is for the 2017 Pilot (#57) and other cars on the oldest Android Auto version.
- Gearslip no longer crashes when a wireless connection ends. - Music no longer carries on from the phone's speaker after you turn the car off. An app that started playing again by itself is paused a second time (#39).
- Opening a second music app without playing anything keeps the player on the song that's still playing. - A liked song lights up the whole like button, as shuffle and repeat do. Before, only the heart filled in for apps such as Metrolist.
- Vela's speed sign is full size again.
- RF Sentinel and other non-media car apps get their own tile in the launcher. ## Testing
- GM cars open two more channels, Bluetooth and Wi-Fi projection, and log what the car sends on them. This is to find out why the 2021 Bolt hangs up about 20 seconds in (#18). If you drive a GM car, type your phone's Bluetooth address under Settings > Connection > Phone's Bluetooth address. You'll find it in Android's Settings under About phone.
Help translate Gearslip on [Weblate](https://hosted.weblate.org/engage/gearslip/).
+18
View File
@@ -0,0 +1,18 @@
name: Translations
# Weblate is the only thing that writes translated strings: it pushes straight to experimental,
# never through a pull request. A PR that edits them collides with Weblate's own copy, and
# Weblate locks itself until someone resets it. Edit the English strings.xml only.
on:
pull_request:
paths:
- "gearslip/src/main/res/values-*/strings.xml"
jobs:
weblate-only:
runs-on: ubuntu-latest
steps:
- name: Translations go through Weblate
run: |
echo "::error::This pull request edits a translation. Please make translation changes on Weblate instead: https://hosted.weblate.org/engage/gearslip/ (it can upload a whole strings.xml under Files > Upload translation). See CONTRIBUTING.md."
exit 1
+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 |
+3
View File
@@ -88,6 +88,9 @@ We are not required to use or include any Contribution.
Most contributors sign on GitHub: the CLA Assistant bot comments on your first pull request, Most contributors sign on GitHub: the CLA Assistant bot comments on your first pull request,
and you sign there with one click. Your GitHub account then serves as your signature. and you sign there with one click. Your GitHub account then serves as your signature.
Translators sign on Weblate: it shows this agreement before your first translation, and
accepting it there serves as your signature.
To sign by hand instead, fill in the following and send it to us on Matrix. The address is To sign by hand instead, fill in the following and send it to us on Matrix. The address is
on the [Seb3thehacker GitHub profile](https://github.com/Seb3thehacker). on the [Seb3thehacker GitHub profile](https://github.com/Seb3thehacker).
+18
View File
@@ -38,6 +38,24 @@ branch on `experimental`, and open your pull request against it.
- Gearslip asks for no adb, root, or Shizuku. A feature must work with ordinary Android - Gearslip asks for no adb, root, or Shizuku. A feature must work with ordinary Android
permissions and on-device consent dialogs. permissions and on-device consent dialogs.
## Translating
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
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`
and `%1$d` in place; Gearslip fills them with names and numbers. Untranslated strings show
in English, so a partial translation is still useful.
Before your first translation, Weblate asks you to accept the
[Contributor License Agreement](CLA.md). That counts as signing it.
## Writing ## Writing
Text that users read, such as screens, release notes, and docs, follows Strunk's Text that users read, such as screens, release notes, and docs, follows Strunk's
+51 -20
View File
@@ -9,13 +9,28 @@ 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)
[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 standards that reject Most cars work. Starting around 2020, some cars added newer security checks that reject
Gearslip's certificate, and Gearslip can't run in those cars. [CARS.md](CARS.md) lists the the aasdk certificate, and in those cars Gearslip needs a certificate of your own. [CARS.md](CARS.md) lists the
cars drivers have confirmed. cars drivers have confirmed.
## What Gearslip is ## What Gearslip is
@@ -29,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
@@ -44,9 +59,10 @@ screen-sharing mode.
call goes out. When a call comes in, answer or decline it on the car screen. During a call goes out. When a call comes in, answer or decline it on the car screen. During a
call, the nav bar shows who you're talking to, and you mute or hang up from there. call, the nav bar shows who you're talking to, and you mute or hang up from there.
- **Navigation.** Gearslip hosts real Car App Library map apps on the car screen. - **Navigation.** Gearslip hosts real Car App Library map apps on the car screen.
Organic Maps, Vela, OsmAnd, Flitsmeister, MAPS.ME, and MapQuest work today. While you Organic Maps, Vela, OsmAnd, Waze, Flitsmeister, MAPS.ME, and MapQuest work today. While you
navigate, a big turn card on the left shows the arrow, the distance, and your lane, and navigate, a big turn card on the left shows the arrow, the distance, and your lane, and
the map keeps the right side. the map keeps the right side. Turns from Google Maps or Waze running on the phone show in
the nav bar, and on the instrument cluster in cars with a screen there.
- **Music.** Browse, play, and queue tracks from a car-enabled media app, with a Now - **Music.** Browse, play, and queue tracks from a car-enabled media app, with a Now
Playing view, an Up Next queue, and lyrics. Type a search, and apps that accept one Playing view, an Up Next queue, and lyrics. Type a search, and apps that accept one
play the best match. play the best match.
@@ -63,11 +79,15 @@ screen-sharing mode.
- **Notifications.** Only messages, missed calls, alarms, and reminders interrupt you. - **Notifications.** Only messages, missed calls, alarms, and reminders interrupt you.
The rest wait on the dashboard. The rest wait on the dashboard.
- **Your apps, your order.** The launcher groups apps by kind: phone, maps, music, - **Your apps, your order.** The launcher groups apps by kind: phone, maps, music,
messaging, and the rest. Press and hold an app to move it. messaging, and the rest. Press and hold an app to move it, and hide the ones you don't
use from the Apps card on the phone.
- **Car controls.** Steering wheel buttons, control knobs, and D-pads all work. - **Car controls.** Steering wheel buttons, control knobs, and D-pads all work.
- **Your phone's colors.** The car screen and the phone app take their colors from your - **Your phone's colors.** The car screen and the phone app take their colors from your
phone's Material You palette, and every card and button matches. phone's Material You palette, and every card and button matches.
- **Themes.** Choose flat buttons or skeuomorphic ones that look like real keys. - **Themes.** Choose flat buttons or skeuomorphic ones that look like real keys.
- **Small screens.** Car display density makes text and buttons bigger in car apps.
- **Your language.** Gearslip comes in 19 languages besides English, some only partly
translated. Pick one in Settings. Voice commands understand English only for now.
- **Settings on the phone.** The car's settings sit on the phone's home screen too, so you - **Settings on the phone.** The car's settings sit on the phone's home screen too, so you
can set things up before you drive. A change on either screen shows on both. can set things up before you drive. A change on either screen shows on both.
@@ -81,22 +101,28 @@ screen-sharing mode.
then one permission at a time, each with a plain reason and an Allow or Skip. A help then one permission at a time, each with a plain reason and an Allow or Skip. A help
screen inside the car answers anything setup didn't cover. screen inside the car answers anything setup didn't cover.
- **Templated apps work.** Gearslip renders the Car App Library's templates on the car - **Templated apps work.** Gearslip renders the Car App Library's templates on the car
screen, and it draws its own keyboard for search, sign-in, and text fields. It has been screen, and it draws its own keyboard for search, sign-in, and text fields. Alerts, such
tested against several real navigation and media apps. as a map app asking whether a hazard is still there, show on the car screen too. It has
been tested against several real navigation and media apps.
- **Gearslip can be a head unit's home screen.** On a head unit that runs Android, or a
spare phone or tablet on the dash, turn on Use as launcher in Settings > Connection. The
car screen fills the display, and an All apps page opens everything else.
- **Gearslip works with [LIVI](https://github.com/f-io/LIVI),** the open-source head unit - **Gearslip works with [LIVI](https://github.com/f-io/LIVI),** the open-source head unit
used in homemade car dashboards. used in homemade car dashboards.
- **Some apps refuse Gearslip.** Spotify, Waze, and Google Messages use a version of - **Some apps refuse Gearslip.** Spotify and Google Messages use a version of
Google's car library that accepts only Android Auto itself. Spotify still plays through Google's car library that accepts only Android Auto itself. Spotify still plays through
Gearslip's own player. TIDAL lets only approved apps browse its library. Gearslip's own player. TIDAL lets only approved apps browse its library.
- **Calls use the car's Bluetooth,** as they do in Android Auto. Pair the phone with the - **Calls use the car's Bluetooth,** as they do in Android Auto. Pair the phone with the
car to hear calls through its speakers. car to hear calls through its speakers.
- **Music reaches the car over Bluetooth or USB.** Setup asks which. Bluetooth is the - **Music reaches the car over Bluetooth or through Gearslip.** Setup asks which.
default: Android never asks for permission, and your map app's voice reaches the car Bluetooth is the default: Android never asks for permission, and your map app's voice
too. USB sounds a little clearer, with no delay, but each drive Android asks you to reaches the car too. Through Gearslip, the sound travels over the car connection, cable
share your screen, which lets Gearslip send the sound. Gearslip never reads the screen; capturing playback is or wireless. It sounds a little clearer, with no delay, but each drive Android asks you
the only way an ordinary app can pass another app's audio along. to share your screen, which lets Gearslip send the sound. Gearslip never reads the
screen; capturing playback is the only way an ordinary app can pass another app's audio
along.
- **Gearslip needs no adb, root, or Shizuku.** Every feature relies on an ordinary - **Gearslip needs no adb, root, or Shizuku.** Every feature relies on an ordinary
Android permission or an on-device consent dialog. For USB audio, setup offers two Android permission or an on-device consent dialog. For audio through Gearslip, setup offers two
optional adb commands: one removes the audio dialog for good, and the other keeps optional adb commands: one removes the audio dialog for good, and the other keeps
notifications visible while audio plays. notifications visible while audio plays.
- **Updates come from GitHub.** Once a day, Gearslip checks this repository's latest - **Updates come from GitHub.** Once a day, Gearslip checks this repository's latest
@@ -126,7 +152,7 @@ turn them on, in setup or in Settings.
those boxes cover: the Android version and whether the phone runs GrapheneOS, the phone those boxes cover: the Android version and whether the phone runs GrapheneOS, the phone
model, whether a car connected that day, and each car's head unit, model, year, screen model, whether a car connected that day, and each car's head unit, model, year, screen
size, and connection result, with whether that drive used the cable or wireless and size, and connection result, with whether that drive used the cable or wireless and
whether music went over USB or Bluetooth. If you tick it, how each drive went: how far whether music went through Gearslip or over Bluetooth. If you tick it, how each drive went: how far
the connection got, how long it lasted, and how it ended. Settings shows the exact note. Untick a phone detail, and the connection got, how long it lasted, and how it ended. Settings shows the exact note. Untick a phone detail, and
the server drops the copy it kept. the server drops the copy it kept.
- **What they leave out.** Your name, accounts, location, contacts, messages, and - **What they leave out.** Your name, accounts, location, contacts, messages, and
@@ -153,6 +179,11 @@ Bug reports from real cars help most, and pull requests are welcome.
[CONTRIBUTING.md](CONTRIBUTING.md) explains what to put in a report, which branch to work [CONTRIBUTING.md](CONTRIBUTING.md) explains what to put in a report, which branch to work
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
[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

+17 -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 = 12 versionCode = 15
// -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.12-alpha" versionName = project.findProperty("gearslipVersionName")?.toString() ?: "0.3.40-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.
@@ -74,6 +74,9 @@ android {
applicationIdSuffix = ".dev" applicationIdSuffix = ".dev"
signingConfigs.findByName("release")?.let { signingConfig = it } signingConfigs.findByName("release")?.let { signingConfig = it }
manifestPlaceholders["appLabel"] = "Gearslip Debug" manifestPlaceholders["appLabel"] = "Gearslip Debug"
// English (XA) and Arabic (XB) under the phone's languages: stretched, accented and
// right-to-left text, which shows what's still hardcoded and what doesn't fit.
isPseudoLocalesEnabled = true
// Shrunk with R8 like a release, so a debug install behaves like what ships (a keep // Shrunk with R8 like a release, so a debug install behaves like what ships (a keep
// rule missing for release shows up here first). -PnoR8 skips it for a quicker build. // rule missing for release shows up here first). -PnoR8 skips it for a quicker build.
if (!project.hasProperty("noR8")) { if (!project.hasProperty("noR8")) {
@@ -88,6 +91,18 @@ android {
buildConfig = true buildConfig = true
} }
// Lists each language that has a translation in the app's locale config, so Android 13+
// offers it under the system's per-app languages. English is the unqualified default
// (res/resources.properties).
androidResources {
generateLocaleConfig = true
}
lint {
// Weblate fills languages in a string at a time; the rest fall back to English.
disable += "MissingTranslation"
}
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_21 sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21 targetCompatibility = JavaVersion.VERSION_21
+34
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
@@ -126,6 +128,7 @@
android:roundIcon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher"
android:label="${appLabel}" android:label="${appLabel}"
android:supportsRtl="true" android:supportsRtl="true"
android:manageSpaceActivity="app.seb3thehacker.gearslip.ui.ManageSpaceActivity"
android:theme="@style/Theme.Gearslip"> android:theme="@style/Theme.Gearslip">
<provider <provider
@@ -157,11 +160,37 @@
</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
the head unit to Android Auto, without disabling the screen the switch is on. the head unit to Android Auto, without disabling the screen the switch is on.
--> -->
<!-- App info › Storage › Manage space: clears lyrics, voice models and logs one at a time. -->
<activity
android:name=".ui.ManageSpaceActivity"
android:exported="true"
android:label="Gearslip storage" />
<activity <activity
android:name=".PermissionActivity" android:name=".PermissionActivity"
android:exported="false" android:exported="false"
@@ -197,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,26 @@ 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(
15, "0.3.40-alpha",
listOf(
"A liked song lights up the whole like button, as shuffle and repeat do.",
"Music no longer carries on from the phone after you turn the car off.",
"Cars that hold back their screen are asked for it again in a way older cars understand.",
),
),
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(
@@ -0,0 +1,164 @@
package app.seb3thehacker.gearslip
import android.content.Context
import android.os.Handler
import android.os.Looper
import app.seb3thehacker.gearslip.car.ClusterScreen
/**
* Gearslip's picture on the car's instrument-cluster screen: a second video sink, for a car that
* offers one, showing the turn card ([ClusterScreen]). The same exchange as the main screen's,
* on its own channel, with its own encoder and its own private display:
*
* -> CHANNEL_OPEN_REQUEST <- CHANNEL_OPEN_RESPONSE
* -> Setup { H.264 } <- Config { status, max_unacked, configuration_indices }
* -> VideoFocusRequest <- VideoFocusNotification { projected }
* -> Start, CodecConfig, Data... <- Ack
*
* No touch: a cluster has none. Experimental, behind [car.CarSettings.clusterVideoEnabled], and
* untested in a car: none in the notes has a cluster sink.
*/
internal class ClusterVideo(
private val context: Context,
private val service: ServiceDiscovery.VideoService,
private val send: (messageId: Int, body: ByteArray) -> Unit,
) {
private val log = GearslipLog.tagged("CLUSTER")
private val main = Handler(Looper.getMainLooper())
val channelId get() = service.serviceId
private var configIndex = 0
@Volatile private var started = false
@Volatile private var running = true
@Volatile private var source: VideoSource? = null
private var projector: ScreenProjector? = null
private val ackLock = Object()
@Volatile private var inFlight = 0
@Volatile private var maxUnacked = 1
@Volatile private var lastAckAt = 0L
private var frames = 0
fun onMessage(messageId: Int, body: ByteArray) {
when (messageId) {
GearslipRunner.MSG_CHANNEL_OPEN_RESPONSE -> {
val status = Protobuf.readInt32Field(body, 1) ?: 0
log.i("<- ChannelOpenResponse: status=$status")
if (status != 0) return
send(GearslipRunner.MSG_MEDIA_SETUP, Protobuf.varintField(1, GearslipRunner.CODEC_H264_BP.toLong()))
log.i("-> Setup(codec=VIDEO_H264_BP)")
}
GearslipRunner.MSG_MEDIA_CONFIG -> {
val fields = Wire.fields(body)
Wire.varint(fields, 2)?.toInt()?.let { maxUnacked = maxOf(1, it) }
val offered = fields.filter { it.number == 3 && it.wireType == 0 }.map { it.varint.toInt() }
// The cluster gets the smallest picture it offers: a turn card needs no more.
configIndex = offered.minByOrNull { service.configs.getOrNull(it)?.pixelCount() ?: Int.MAX_VALUE } ?: 0
log.i("<- Config status=${Wire.varint(fields, 1)} max_unacked=$maxUnacked offered=$offered - using $configIndex")
val focus = Protobuf.varintField(2, GearslipRunner.VIDEO_FOCUS_PROJECTED.toLong()) + Protobuf.varintField(3, 1L)
send(GearslipRunner.MSG_VIDEO_FOCUS_REQUEST, focus)
log.i("-> VideoFocusRequest(mode=PROJECTED)")
}
GearslipRunner.MSG_VIDEO_FOCUS_NOTIFICATION -> {
val mode = Wire.varint(Wire.fields(body), 1)?.toInt() ?: GearslipRunner.VIDEO_FOCUS_PROJECTED
log.i("<- VideoFocusNotification: mode=$mode")
val projected = mode == GearslipRunner.VIDEO_FOCUS_PROJECTED || mode == GearslipRunner.VIDEO_FOCUS_PROJECTED_NO_INPUT
if (projected && !started) startStream()
}
GearslipRunner.MSG_MEDIA_ACK -> {
val acked = Wire.varint(Wire.fields(body), 2)?.toInt() ?: 1
synchronized(ackLock) {
inFlight = maxOf(0, inFlight - maxOf(1, acked))
lastAckAt = System.currentTimeMillis()
ackLock.notifyAll()
}
}
else -> log.i("<- unhandled message id=$messageId (${body.size} bytes)")
}
}
private fun startStream() {
val config = service.configs.getOrNull(configIndex) ?: service.configs.firstOrNull() ?: return
val (width, height) = config.pixelSize() ?: run {
log.w("unsupported cluster resolution ${config.resolutionName}")
return
}
started = true
send(
GearslipRunner.MSG_MEDIA_START,
Protobuf.varintField(1, GearslipRunner.SESSION_ID.toLong()) + Protobuf.varintField(2, configIndex.toLong()),
)
log.i("-> Start(config=$configIndex -> $config)")
lastAckAt = System.currentTimeMillis()
val video = VideoSource(
width = width,
height = height,
frameRate = FRAME_RATE,
mode = VideoSource.Mode.SURFACE,
wireless = true, // a small share of the link: the main screen comes first
onCodecConfig = { csd ->
send(GearslipRunner.MSG_MEDIA_CODEC_CONFIG, csd)
log.i("-> CodecConfig (${csd.size} bytes)")
},
onFrame = { data, pts, _ -> sendFrame(data, pts) },
)
source = video
runCatching { video.start() }.onFailure {
log.e("cluster encoder failed to start", it)
return
}
val surface = video.inputSurface ?: return
val density = config.density.takeIf { it > 0 } ?: 160
main.post {
if (!running) return@post
projector = ScreenProjector(context).also {
it.start(surface, width, height, density, name = "gearslip-cluster") {
// The margins the car crops off are kept clear, as on the main screen.
ClusterScreen(config.widthMargin, config.heightMargin)
}
}
}
}
/** As on the main screen: wait for the car's acks rather than drop a frame. */
private fun sendFrame(data: ByteArray, presentationTimeUs: Long) {
synchronized(ackLock) {
while (running && inFlight >= maxUnacked) {
val since = System.currentTimeMillis() - lastAckAt
if (since > GearslipRunner.ACK_STALL_MS) {
inFlight = 0
lastAckAt = System.currentTimeMillis()
break
}
ackLock.wait(GearslipRunner.ACK_STALL_MS - since + 1)
}
}
if (!running) return
val timestamp = ByteArray(8)
for (i in 0 until 8) timestamp[i] = ((presentationTimeUs shr ((7 - i) * 8)) and 0xFF).toByte()
synchronized(ackLock) { inFlight++ }
runCatching { send(GearslipRunner.MSG_MEDIA_DATA, timestamp + data) }.onFailure { running = false }
if (++frames <= 2) log.i("-> Data frame #$frames (${data.size} bytes)")
}
fun stop() {
running = false
synchronized(ackLock) { ackLock.notifyAll() }
main.post {
projector?.stop()
projector = null
}
source?.stop()
source = null
}
private companion object {
// A turn card changes a few times a minute; the encoder repeats the last frame between.
const val FRAME_RATE = 15
}
}
@@ -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() }
} }
/** /**
@@ -320,7 +335,10 @@ class GearslipActivity : ComponentActivity(), Projection {
} }
private fun handleIntent(intent: Intent?) { private fun handleIntent(intent: Intent?) {
// 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 (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
} }
@@ -338,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)
} }
@@ -399,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. */
@@ -421,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) {
@@ -443,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
@@ -456,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) }
} }
@@ -472,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 {
@@ -96,11 +100,15 @@ class GearslipRunner(
@Volatile private var inputKeycodes: List<Int> = emptyList() @Volatile private var inputKeycodes: List<Int> = emptyList()
@Volatile private var sensorChannelId: Int = -1 @Volatile private var sensorChannelId: Int = -1
@Volatile private var mediaStatus: CarMediaStatus? = null @Volatile private var mediaStatus: CarMediaStatus? = null
@Volatile private var phoneStatus: app.seb3thehacker.gearslip.call.CarPhoneStatus? = null
@Volatile private var clusterVideo: ClusterVideo? = null
@Volatile private var clusterNav: ClusterNav? = null @Volatile private var clusterNav: ClusterNav? = null
private var sensorTypes: List<Int> = emptyList() private var sensorTypes: List<Int> = emptyList()
@Volatile private var audioLink: AudioLink? = null @Volatile private var audioLink: AudioLink? = null
@Volatile private var guidanceLink: AudioLink? = null @Volatile private var guidanceLink: AudioLink? = null
@Volatile private var vendorChannelId = -1 @Volatile private var vendorChannelId = -1
/** The car's Wi-Fi projection channel, opened in GM cars only (see [startVendorChannel]); -1 when closed. */
@Volatile private var wifiChannelId = -1
private var audioMessagesSeen = 0 private var audioMessagesSeen = 0
private var touchWidth = 0 private var touchWidth = 0
private var touchHeight = 0 private var touchHeight = 0
@@ -126,6 +134,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}")
@@ -156,7 +165,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
@@ -184,12 +194,17 @@ class GearslipRunner(
guidanceLink?.close() guidanceLink?.close()
guidanceLink = null guidanceLink = null
CarSensors.clear() CarSensors.clear()
app.seb3thehacker.gearslip.car.CarData.clear()
CarMic.current = null CarMic.current = null
CarBluetooth.onCarDisconnected() CarBluetooth.onCarDisconnected()
BluetoothAudioCheck.onCarDisconnected() BluetoothAudioCheck.onCarDisconnected()
LocationCheck.onCarDisconnected() LocationCheck.onCarDisconnected()
mediaStatus?.stop() mediaStatus?.stop()
mediaStatus = null mediaStatus = null
phoneStatus?.stop()
phoneStatus = null
clusterVideo?.stop()
clusterVideo = null
clusterNav?.stop() clusterNav?.stop()
clusterNav = null clusterNav = null
lastSensorNight = null lastSensorNight = null
@@ -237,12 +252,18 @@ class GearslipRunner(
clusterNav?.onMessage(messageId, body) clusterNav?.onMessage(messageId, body)
} else if (frame.channel == mediaStatus?.channelId) { } else if (frame.channel == mediaStatus?.channelId) {
mediaStatus?.onMessage(messageId, body) mediaStatus?.onMessage(messageId, body)
} else if (frame.channel == phoneStatus?.channelId) {
phoneStatus?.onMessage(messageId, body)
} else if (frame.channel == clusterVideo?.channelId) {
clusterVideo?.onMessage(messageId, body)
} else if (frame.channel == guidanceLink?.channelId) { } else if (frame.channel == guidanceLink?.channelId) {
guidanceLink?.onMessage(messageId, body) guidanceLink?.onMessage(messageId, body)
} else if (frame.channel == CarBluetooth.channelId) { } else if (frame.channel == CarBluetooth.channelId) {
CarBluetooth.onMessage(messageId, body) CarBluetooth.onMessage(messageId, body)
} else if (frame.channel == vendorChannelId) { } else if (frame.channel == vendorChannelId) {
onVendorMessage(messageId, body) onVendorMessage(messageId, body)
} else if (frame.channel == wifiChannelId) {
onWifiProjectionMessage(messageId, body)
} else if (frame.channel == CarMic.current?.channelId) { } else if (frame.channel == CarMic.current?.channelId) {
CarMic.current?.onMessage(messageId, body) CarMic.current?.onMessage(messageId, body)
} else if (frame.channel == audioLink?.channelId) { } else if (frame.channel == audioLink?.channelId) {
@@ -272,6 +293,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 -> {
@@ -393,8 +415,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")
@@ -435,11 +456,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) {
@@ -458,10 +512,12 @@ 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())
SessionReport.headUnitInfo = info SessionReport.headUnitInfo = info
app.seb3thehacker.gearslip.car.CarData.onModel(info.carModel, info.carYear)
vehicleProfile = runCatching { vehicleProfileFor(info) }.getOrNull() vehicleProfile = runCatching { vehicleProfileFor(info) }.getOrNull()
val profile = vehicleProfile val profile = vehicleProfile
SessionReport.profile(profile?.name) SessionReport.profile(profile?.name)
@@ -512,6 +568,8 @@ class GearslipRunner(
startSensorChannel(serviceDiscoveryResponse) startSensorChannel(serviceDiscoveryResponse)
startMicChannel(serviceDiscoveryResponse) startMicChannel(serviceDiscoveryResponse)
startMediaStatusChannel(serviceDiscoveryResponse) startMediaStatusChannel(serviceDiscoveryResponse)
startPhoneStatusChannel(serviceDiscoveryResponse)
startClusterVideo(serviceDiscoveryResponse)
startClusterNavChannel(serviceDiscoveryResponse) startClusterNavChannel(serviceDiscoveryResponse)
startBluetooth(serviceDiscoveryResponse) startBluetooth(serviceDiscoveryResponse)
startVendorChannel(serviceDiscoveryResponse) startVendorChannel(serviceDiscoveryResponse)
@@ -563,6 +621,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)
@@ -592,7 +652,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)
@@ -628,6 +690,32 @@ class GearslipRunner(
openChannel(channel) openChannel(channel)
} }
/** The turn card on the instrument cluster, for a car with a cluster screen: experimental, off by default. */
private fun startClusterVideo(serviceDiscoveryResponse: ByteArray) {
val sink = ServiceDiscovery.findClusterVideoService(serviceDiscoveryResponse) ?: return
log.i("cluster video sink: channel=${sink.serviceId} display=${sink.displayId} type=${sink.displayType}")
if (!CarSettings.experimentalFeaturesEnabled.value || !CarSettings.clusterVideoEnabled.value) {
log.i("cluster screen is off in the car's Settings (experimental) - leaving that sink alone")
return
}
val context = app.seb3thehacker.gearslip.car.CarEnvironment.appContext ?: return
clusterVideo = ClusterVideo(context, sink) { id, body -> send(id, body, encrypted = true, channel = sink.serviceId) }
openChannel(sink.serviceId)
}
/** Calls, for the car's own screens: the cluster, the steering wheel display. */
private fun startPhoneStatusChannel(serviceDiscoveryResponse: ByteArray) {
val channel = ServiceDiscovery.findPhoneStatusChannel(serviceDiscoveryResponse) ?: run {
log.i("no phone status channel - the car's own screens won't show calls")
return
}
log.i("phone status channel: $channel")
phoneStatus = app.seb3thehacker.gearslip.call.CarPhoneStatus(channel) { id, body ->
send(id, body, encrypted = true, channel = channel)
}
openChannel(channel)
}
/** Bluetooth pairing, for calls: the channel only opens if the driver asks to pair. */ /** Bluetooth pairing, for calls: the channel only opens if the driver asks to pair. */
/** /**
* GM's own channel ("com.gm.vip"), opened in GM cars only. Issue #18: a 2021 Bolt ends every * GM's own channel ("com.gm.vip"), opened in GM cars only. Issue #18: a 2021 Bolt ends every
@@ -642,6 +730,23 @@ class GearslipRunner(
vendorChannelId = gm.serviceId vendorChannelId = gm.serviceId
log.i("-> ChannelOpenRequest on GM's channel ${gm.serviceId}") log.i("-> ChannelOpenRequest on GM's channel ${gm.serviceId}")
openChannel(gm.serviceId) openChannel(gm.serviceId)
// Opening GM's channel and pinging the car didn't stop the Bolt hanging up, so a GM car
// also gets the two channels Gearslip had never opened: Bluetooth and Wi-Fi projection.
// Only what arrives on them is logged, by message id and size, until one turns out to matter.
CarBluetooth.announce("a GM car, issue #18")
ServiceDiscovery.findWifiProjectionService(serviceDiscoveryResponse)?.let { channel ->
wifiChannelId = channel
log.i("-> ChannelOpenRequest on the Wi-Fi projection channel $channel (a GM car, issue #18)")
openChannel(channel)
}
}
private fun onWifiProjectionMessage(messageId: Int, body: ByteArray) {
if (messageId == MSG_CHANNEL_OPEN_RESPONSE) {
log.i("<- Wi-Fi projection channel open: status=${Protobuf.readInt32Field(body, 1) ?: 0}")
} else {
log.i("<- Wi-Fi projection message id=$messageId (${body.size} bytes)")
}
} }
private fun onVendorMessage(messageId: Int, body: ByteArray) { private fun onVendorMessage(messageId: Int, body: ByteArray) {
@@ -766,6 +871,7 @@ class GearslipRunner(
MSG_SENSOR_EVENT_INDICATION -> { MSG_SENSOR_EVENT_INDICATION -> {
CarSensors.onEvent(body) CarSensors.onEvent(body)
app.seb3thehacker.gearslip.car.CarData.onEvent(body)
logSensorEvent(body) logSensorEvent(body)
onNightSensor(body) onNightSensor(body)
} }
@@ -954,11 +1060,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 -> {
@@ -1111,6 +1214,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()
@@ -1234,6 +1338,49 @@ class GearslipRunner(
} }
} }
/**
* Asks the car for its screen; the answer comes to [onVideoFocus]. [withDisplay] names the
* display's channel too, as the request for the car's own screen does (see [requestNativeFocus]):
* the field is deprecated, but a car older than that may ignore a request without it.
*/
private fun requestProjectedFocus(withDisplay: Boolean = false) {
// VideoFocusRequestNotification { disp_channel_id = 1; mode = 2; reason = 3 }. Reason 1
// means "the phone's screen is off", which a 2020 Civic may have taken as a reason to
// keep its own screen (issue #43); 0 is UNKNOWN.
val display = if (withDisplay) Protobuf.varintField(1, videoChannelId.toLong()) else ByteArray(0)
val focus = display + Protobuf.varintField(2, VIDEO_FOCUS_PROJECTED.toLong()) +
Protobuf.varintField(3, 0L)
send(MSG_VIDEO_FOCUS_REQUEST, focus, encrypted = true, channel = videoChannelId)
log.i(if (withDisplay) "-> VideoFocusRequest(display=$videoChannelId, mode=PROJECTED)" else "-> 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.
* The 2017 Pilot (protocol 1.0) still kept its screen with the channel open, so the second
* request also names the display's channel, as cars of the Pilot's age may need.
*/
private fun watchForWithheldScreen() {
Thread({
Thread.sleep(WITHHELD_SCREEN_MS)
if (!running || videoStarted) return@Thread
val open = CarAudio.openSink
if (open != null) {
log.w("no screen from the car after ${WITHHELD_SCREEN_MS / 1000}s - opening the media channel and asking again")
open()
// Give the car the channel's open response before it hears the request again.
Thread.sleep(1_000)
} else {
log.w("no screen from the car after ${WITHHELD_SCREEN_MS / 1000}s - asking again")
}
if (running && !videoStarted) requestProjectedFocus(withDisplay = true)
}, "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
@@ -1266,6 +1413,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.
@@ -1359,6 +1538,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
@@ -1370,6 +1552,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.
@@ -1458,10 +1645,13 @@ class GearslipRunner(
} }
} }
private companion object { internal companion object {
/** 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
@@ -1471,6 +1661,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
@@ -1515,6 +1707,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
} }
@@ -51,8 +51,17 @@ object ServiceDiscovery {
"$resolutionName @ $frameRateName, density=$density, margin=${widthMargin}x$heightMargin" "$resolutionName @ $frameRateName, density=$density, margin=${widthMargin}x$heightMargin"
} }
/** [displayId] is MediaSinkService field 6: 0, the default, is the main screen. */ /**
class VideoService(val serviceId: Int, val codecType: Int, val configs: List<VideoConfig>, val displayId: Int = 0) { * [displayId] is MediaSinkService field 6: 0, the default, is the main screen. [displayType]
* is field 7 (0 main, 1 instrument cluster, 2 auxiliary), or null when the car leaves it out.
*/
class VideoService(
val serviceId: Int,
val codecType: Int,
val configs: List<VideoConfig>,
val displayId: Int = 0,
val displayType: Int? = null,
) {
val codecName: String get() = CODECS[codecType] ?: "unknown($codecType)" val codecName: String get() = CODECS[codecType] ?: "unknown($codecType)"
} }
@@ -116,6 +125,12 @@ object ServiceDiscovery {
*/ */
fun findMediaStatusChannel(response: ByteArray): Int? = findChannelWith(response, 9) fun findMediaStatusChannel(response: ByteArray): Int? = findChannelWith(response, 9)
/**
* The channel the car takes call status on (PhoneStatusService, Service field 10), for its
* cluster and phone screens. It carries no settings. Null when the car has none.
*/
fun findPhoneStatusChannel(response: ByteArray): Int? = findChannelWith(response, 10)
class NavStatusService(val serviceId: Int, val minimumIntervalMs: Int) class NavStatusService(val serviceId: Int, val minimumIntervalMs: Int)
/** The cluster advertises its minimum update interval in NavigationStatusService field 1. */ /** The cluster advertises its minimum update interval in NavigationStatusService field 1. */
@@ -185,6 +200,13 @@ object ServiceDiscovery {
VendorService(id, name) VendorService(id, name)
} }
/** The channel of the car's Wi-Fi projection service (Service field 14), or null. */
fun findWifiProjectionService(response: ByteArray): Int? =
Wire.allBytes(Wire.fields(response), 1).firstNotNullOfOrNull { channel ->
val service = Wire.fields(channel)
if (Wire.bytes(service, 14) == null) null else Wire.varint(service, 1)?.toInt()
}
fun findBluetoothService(response: ByteArray): BluetoothService? { fun findBluetoothService(response: ByteArray): BluetoothService? {
for (channel in Wire.allBytes(Wire.fields(response), 1)) { for (channel in Wire.allBytes(Wire.fields(response), 1)) {
val service = Wire.fields(channel) val service = Wire.fields(channel)
@@ -277,6 +299,19 @@ object ServiceDiscovery {
return sinks.firstOrNull { it.displayId == 0 } ?: sinks.firstOrNull() return sinks.firstOrNull { it.displayId == 0 } ?: sinks.firstOrNull()
} }
/**
* The instrument cluster's video sink: one marked as a cluster, or, from a car that marks
* none, a second sink beside the main screen's. Null when the car has no cluster screen.
*/
fun findClusterVideoService(response: ByteArray): VideoService? {
val sinks = videoServices(response)
val main = findVideoService(response) ?: return null
return sinks.firstOrNull { it.displayType == DISPLAY_TYPE_CLUSTER && it.serviceId != main.serviceId }
?: sinks.firstOrNull { it.displayType == null && it.serviceId != main.serviceId && it.displayId != main.displayId }
}
private const val DISPLAY_TYPE_CLUSTER = 1
private fun videoServices(response: ByteArray): List<VideoService> = buildList { private fun videoServices(response: ByteArray): List<VideoService> = buildList {
for (channel in Wire.allBytes(Wire.fields(response), 1)) { for (channel in Wire.allBytes(Wire.fields(response), 1)) {
val service = Wire.fields(channel) val service = Wire.fields(channel)
@@ -298,7 +333,7 @@ object ServiceDiscovery {
heightMargin = Wire.varint(f, 4)?.toInt() ?: 0, heightMargin = Wire.varint(f, 4)?.toInt() ?: 0,
) )
} }
add(VideoService(id, codecType, configs, Wire.varint(sinkFields, 6)?.toInt() ?: 0)) add(VideoService(id, codecType, configs, Wire.varint(sinkFields, 6)?.toInt() ?: 0, Wire.varint(sinkFields, 7)?.toInt()))
} }
} }
@@ -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
@@ -87,8 +87,17 @@ internal class AacEncoder(private val sampleRate: Int, private val channels: Int
runCatching { codec.release() } runCatching { codec.release() }
} }
private companion object { companion object {
const val INPUT_WAIT_US = 10_000L private const val INPUT_WAIT_US = 10_000L
val FREQUENCIES = listOf(96000, 88200, 64000, 48000, 44100, 32000, 24000, 22050, 16000, 12000, 11025, 8000, 7350) private val FREQUENCIES = listOf(96000, 88200, 64000, 48000, 44100, 32000, 24000, 22050, 16000, 12000, 11025, 8000, 7350)
/** The two-byte AudioSpecificConfig for AAC-LC: object type 2, rate index, channel count. */
fun audioSpecificConfig(sampleRate: Int, channels: Int): ByteArray {
val freq = FREQUENCIES.indexOf(sampleRate).let { if (it < 0) 3 else it }
return byteArrayOf(
((2 shl 3) or (freq shr 1)).toByte(),
(((freq and 1) shl 7) or (channels shl 3)).toByte(),
)
}
} }
} }
@@ -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
@@ -52,6 +57,10 @@ class AudioLink(
private var bytes = 0L private var bytes = 0L
private var dropped = 0 private var dropped = 0
@Volatile private var configTimer: Thread? = null @Volatile private var configTimer: Thread? = null
/** Underflows the car reported this drive, and the silence still to slip in ahead of the next chunk. */
private var underflows = 0
@Volatile private var padMs = 0
@Volatile private var pendingPadMs = 0
private val ackLock = Object() private val ackLock = Object()
/** Only for a sink that takes AAC and nothing else; PCM sinks get the capture as it is. */ /** Only for a sink that takes AAC and nothing else; PCM sinks get the capture as it is. */
@Volatile private var aac: AacEncoder? = null @Volatile private var aac: AacEncoder? = null
@@ -66,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
@@ -104,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) {
@@ -180,6 +207,8 @@ class AudioLink(
} }
} }
MSG_UNDERFLOW -> onUnderflow()
MSG_ACK -> { MSG_ACK -> {
val acked = Wire.varint(Wire.fields(body), 2)?.toInt() ?: 1 val acked = Wire.varint(Wire.fields(body), 2)?.toInt() ?: 1
synchronized(ackLock) { synchronized(ackLock) {
@@ -207,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()
@@ -216,11 +245,54 @@ 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 {
// A ducking request leaves the music playing; a loss means another source has the speakers. focusGranted -> {
if (had && (state == FOCUS_STATE_LOSS || state == FOCUS_STATE_LOSS_TRANSIENT)) onTakenBack() 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.
if (state == FOCUS_STATE_LOSS && (had || pausedForNow)) {
pausedForNow = false
onTakenBack()
}
}
}
} }
/**
* The car ran out of sound to play (AudioUnderflowNotification, no body): its buffer emptied
* before the next chunk arrived, which the driver hears as a gap. Capture runs in real time, so
* the car's buffer only ever holds what the window lets through. A little silence slipped in
* ahead of the next chunk puts it that far ahead, so the next slow patch is soaked up rather
* than heard. Capped, as each step adds a little delay to the music.
*/
private fun onUnderflow() {
underflows++
val step = if (started && padMs < MAX_PAD_MS) PAD_STEP_MS else 0
padMs += step
pendingPadMs += step
if (underflows <= 3 || underflows % 20 == 0) {
GearslipLog.w("$tag: <- AudioUnderflow ($underflows this drive) - running ${padMs}ms ahead now")
}
}
/** Silence for [ms], whole frames of this sink's format. */
private fun silence(ms: Int): ByteArray = ByteArray(sampleRate * ms / 1000 * channels * 2)
override fun start() { override fun start() {
if (!ready || started) return if (!ready || started) return
if (asksFocus && !focusRequested) { if (asksFocus && !focusRequested) {
@@ -251,6 +323,12 @@ class AudioLink(
MSG_START, MSG_START,
Protobuf.varintField(1, sessionId.toLong()) + Protobuf.varintField(2, configIndex.toLong()), Protobuf.varintField(1, sessionId.toLong()) + Protobuf.varintField(2, configIndex.toLong()),
) )
// Raw AAC-LC has no header of its own: the car needs the codec config before the first frame.
if (service.codecType == ServiceDiscovery.CODEC_AAC_LC) {
val asc = AacEncoder.audioSpecificConfig(sampleRate, channels)
sendOnChannel(MSG_CODEC_CONFIG, asc)
GearslipLog.hex("$tag: -> CodecConfig (AudioSpecificConfig)", asc)
}
inFlight = 0 inFlight = 0
lastAckAt = System.currentTimeMillis() lastAckAt = System.currentTimeMillis()
if (aacSink && aac == null) { if (aacSink && aac == null) {
@@ -267,25 +345,39 @@ 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.
padMs = 0
pendingPadMs = 0
} }
override fun write(pcm: ByteArray, size: Int) { override fun write(pcm: ByteArray, size: Int) {
if (!started) return if (!started) return
val pad = pendingPadMs
if (pad > 0) {
pendingPadMs = 0
val quiet = silence(pad)
writeNow(quiet, quiet.size)
}
writeNow(pcm, size)
}
private fun writeNow(pcm: ByteArray, size: Int) {
val encoder = aac val encoder = aac
if (encoder == null) { if (encoder == null) {
send(pcm.copyOf(size)) send(pcm.copyOf(size))
@@ -364,12 +456,16 @@ class AudioLink(
const val MSG_FOCUS_RESPONSE = 19 const val MSG_FOCUS_RESPONSE = 19
private const val MSG_CHANNEL_OPEN_RESPONSE = 8 private const val MSG_CHANNEL_OPEN_RESPONSE = 8
private const val MSG_DATA = 0 private const val MSG_DATA = 0
private const val MSG_CODEC_CONFIG = 1
private const val MSG_SETUP = 32768 private const val MSG_SETUP = 32768
private const val MSG_START = 32769 private const val MSG_START = 32769
private const val MSG_STOP = 32770 private const val MSG_STOP = 32770
private const val MSG_CONFIG = 32771 private const val MSG_CONFIG = 32771
private const val MSG_ACK = 32772 private const val MSG_ACK = 32772
private const val MSG_SETUP_ANSWER = 255 private const val MSG_SETUP_ANSWER = 255
private const val MSG_UNDERFLOW = 32779 // 0x800B, car to phone, no body
private const val PAD_STEP_MS = 60
private const val MAX_PAD_MS = 240
private const val STATUS_READY = 2 private const val STATUS_READY = 2
private const val FOCUS_GAIN = 1 private const val FOCUS_GAIN = 1
private const val FOCUS_GAIN_TRANSIENT_MAY_DUCK = 3 private const val FOCUS_GAIN_TRANSIENT_MAY_DUCK = 3
@@ -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,108 @@
package app.seb3thehacker.gearslip.call
import android.os.SystemClock
import android.view.KeyEvent
import app.seb3thehacker.gearslip.GearslipLog
import app.seb3thehacker.gearslip.Protobuf
import app.seb3thehacker.gearslip.Wire
import app.seb3thehacker.gearslip.car.CarKeys
/**
* Tells the car about calls, so its own screens show them: the instrument cluster, the steering
* wheel display, the car's phone page. Android Auto does the same over the car's
* PhoneStatusService (Service field 10):
*
* -> PhoneStatusUpdate { repeated PhoneCall calls = 1 }
* PhoneCall { state = 1 (1 in call, 4 incoming, 6 muted); duration_seconds = 2;
* number = 3; name = 4 }
* <- PhoneStatusInput { input = 1 { action = 1 (7 call) }; caller = 2; name = 3 }
*
* Read once a second from [CarCalls]. An update goes when the call changes, and every second
* while one is going, so the car's timer counts along; no calls goes once, as an empty list.
* The car's own call button answers through [CarKeys], like the steering wheel's.
*/
class CarPhoneStatus(
val channelId: Int,
private val sendOnChannel: (messageId: Int, body: ByteArray) -> Unit,
) {
@Volatile private var running = false
private var sent: String? = null
fun onMessage(messageId: Int, body: ByteArray) {
when (messageId) {
MSG_CHANNEL_OPEN_RESPONSE -> {
val status = Protobuf.readInt32Field(body, 1)
GearslipLog.i("phone status: <- ChannelOpenResponse: status=$status")
if (status == 0) start()
}
MSG_INPUT -> onInput(body)
else -> GearslipLog.i("phone status: <- unhandled message id=$messageId (${body.size} bytes)")
}
}
private fun start() {
if (running) return
running = true
Thread({
while (running) {
runCatching { tick() }.onFailure { GearslipLog.w("phone status: ${it.message}") }
Thread.sleep(TICK_MS)
}
}, "gearslip-phone-status").start()
}
fun stop() {
running = false
}
private fun tick() {
val call = when (val state = CarCalls.state.value) {
CallUiState.None -> null
is CallUiState.Ringing -> Call(STATE_INCOMING, 0, state.caller)
is CallUiState.Active -> Call(
if (CarCalls.muted.value) STATE_MUTED else STATE_IN_CALL,
((SystemClock.uptimeMillis() - state.since) / 1000).coerceAtLeast(0),
state.caller,
)
}
// Only a call that's going changes from one second to the next.
val key = call?.let { "${it.state}|${it.caller?.number}|${it.caller?.name}|${if (it.state == STATE_INCOMING) 0 else it.seconds}" } ?: ""
if (key == sent) return
val changed = call?.state != lastState
sent = key
lastState = call?.state
val body = call?.let { Protobuf.bytesField(1, it.encode()) } ?: ByteArray(0)
sendOnChannel(MSG_STATUS, body)
if (changed) GearslipLog.i("phone status: -> ${call?.let { STATE_NAMES[it.state] } ?: "no calls"}")
}
private var lastState: Int? = null
private class Call(val state: Int, val seconds: Long, val caller: Caller?) {
fun encode(): ByteArray {
var body = Protobuf.varintField(1, state.toLong()) + Protobuf.varintField(2, seconds)
caller?.number?.takeIf { it.isNotBlank() }?.let { body += Protobuf.stringField(3, it) }
caller?.name?.takeIf { it.isNotBlank() }?.let { body += Protobuf.stringField(4, it) }
return body
}
}
/** The car's own phone buttons. Only "call" does anything: it answers, as the wheel's does. */
private fun onInput(body: ByteArray) {
val action = Wire.bytes(Wire.fields(body), 1)?.let { Wire.varint(Wire.fields(it), 1)?.toInt() }
GearslipLog.i("phone status: <- input action=${action ?: "?"}")
if (action == ACTION_CALL) CarKeys.onKey(KeyEvent.KEYCODE_CALL, down = true, longPress = false)
}
companion object {
private const val TICK_MS = 1_000L
private const val MSG_CHANNEL_OPEN_RESPONSE = 8
private const val MSG_STATUS = 32769 // 0x8001
private const val MSG_INPUT = 32770 // 0x8002
private const val STATE_IN_CALL = 1
private const val STATE_INCOMING = 4
private const val STATE_MUTED = 6
private const val ACTION_CALL = 7
private val STATE_NAMES = mapOf(STATE_IN_CALL to "in a call", STATE_INCOMING to "ringing", STATE_MUTED to "in a call, muted")
}
}
@@ -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) })
} }
@@ -95,7 +95,12 @@ object AssistantCommands {
heard: String, heard: String,
): String? { ): String? {
// "use Organic Maps to get to X", "open OsmAnd and navigate to X": the app comes first. // "use Organic Maps to get to X", "open OsmAnd and navigate to X": the app comes first.
var text = heard // "take me home", "go home": no "to", so no trigger would catch them.
var text = if (Regex("^(?:(?:take|bring|get|drive) me|go|drive|head|get|directions|go back|head back) home$").matches(heard)) {
"navigate to home"
} else {
heard
}
var leadingApp: String? = null var leadingApp: String? = null
Regex("^(?:use|using|with|open|in|on) (.+?) (?:to |and )(.+)$").find(text)?.let { m -> Regex("^(?:use|using|with|open|in|on) (.+?) (?:to |and )(.+)$").find(text)?.let { m ->
if (after(m.groupValues[2], NAVIGATE_TRIGGERS) != null) { if (after(m.groupValues[2], NAVIGATE_TRIGGERS) != null) {
@@ -135,6 +140,20 @@ object AssistantCommands {
val (place, nearby) = plainPlace(destination) val (place, nearby) = plainPlace(destination)
destination = place destination = place
if (destination.isBlank()) return "Where to?" if (destination.isBlank()) return "Where to?"
// "home", "work", or a name from Saved places: its address is the search.
var spoken: String? = null
SavedPlaces.match(context, destination)?.let { (slot, saved) ->
val name = when (slot) {
SavedPlaces.HOME -> "home"
SavedPlaces.WORK -> "work"
else -> saved.label
}
if (saved.address.isBlank()) {
return "You haven't saved a $name address yet. Add it under Saved places in Gearslip's settings on your phone."
}
destination = saved.address
spoken = if (slot == SavedPlaces.HOME) "home" else "to $name"
}
val chosen = chosenApp ?: when { val chosen = chosenApp ?: when {
navApps.size == 1 -> navApps.first() navApps.size == 1 -> navApps.first()
@@ -144,6 +163,7 @@ object AssistantCommands {
CarServices.connectNav(template, frame, destination) CarServices.connectNav(template, frame, destination)
navigator.home() navigator.home()
spoken?.let { return "Navigating $it with ${chosen.label}." }
return if (nearby) "Finding the nearest $destination with ${chosen.label}." else "Navigating to $destination with ${chosen.label}." return if (nearby) "Finding the nearest $destination with ${chosen.label}." else "Navigating to $destination with ${chosen.label}."
} }
@@ -98,7 +98,7 @@ object BluetoothAudioCheck {
if (!armed) return if (!armed) return
val context = appContext ?: return val context = appContext ?: return
if (CarSettings.pipeAudio.value) { if (CarSettings.pipeAudio.value) {
set(false, "USB audio is on") set(false, "audio goes through Gearslip")
return return
} }
if (!CarSettings.bluetoothWarning.value) { if (!CarSettings.bluetoothWarning.value) {
@@ -1,5 +1,7 @@
package app.seb3thehacker.gearslip.car package app.seb3thehacker.gearslip.car
import androidx.compose.ui.res.stringResource
import app.seb3thehacker.gearslip.R
import app.seb3thehacker.gearslip.car.theme.* import app.seb3thehacker.gearslip.car.theme.*
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@@ -45,32 +47,28 @@ internal fun BluetoothPairOverlay() {
val locationOff by LocationCheck.off.collectAsState() val locationOff by LocationCheck.off.collectAsState()
val locationDismissed by LocationCheck.carDismissed.collectAsState() val locationDismissed by LocationCheck.carDismissed.collectAsState()
if (missing && bluetoothAudio && warn && !dismissed) PairCard( if (missing && bluetoothAudio && warn && !dismissed) PairCard(
title = "Bluetooth isn't connected", title = stringResource(R.string.home_bt_title),
body = "Music plays over Bluetooth, but your phone isn't connected to the car. " + body = stringResource(R.string.overlay_bt_body),
"Connect it from the car's Bluetooth menu or your phone's.", secondary = stringResource(R.string.overlay_dont_show) to BluetoothAudioCheck::neverAgain,
secondary = "Don't show again" to BluetoothAudioCheck::neverAgain, primary = stringResource(R.string.overlay_ok) to BluetoothAudioCheck::dismissOnCar,
primary = "OK" to BluetoothAudioCheck::dismissOnCar,
) else if (locationOff && !locationDismissed) PairCard( ) else if (locationOff && !locationDismissed) PairCard(
title = "Location is off", title = stringResource(R.string.overlay_location_off),
body = "Map apps can't show where you are or give directions. Turn on location " + body = stringResource(R.string.overlay_location_body),
"in your phone's quick settings when you're parked.", secondary = stringResource(R.string.overlay_dont_show) to LocationCheck::neverAgain,
secondary = "Don't show again" to LocationCheck::neverAgain, primary = stringResource(R.string.overlay_ok) to LocationCheck::dismissOnCar,
primary = "OK" to LocationCheck::dismissOnCar,
) )
} }
CarBluetooth.Prompt.ASK -> PairCard( CarBluetooth.Prompt.ASK -> PairCard(
title = "Pair with this car?", title = stringResource(R.string.overlay_pair_title),
body = (if (bluetoothAudio) "Calls and music play" else "Calls play") + body = stringResource(if (bluetoothAudio) R.string.overlay_pair_body_music else R.string.overlay_pair_body),
" through the car's speakers once your phone and the car are paired. Your phone " + secondary = stringResource(R.string.worked_not_now) to CarBluetooth::notNow,
"then asks you to confirm a code.", primary = stringResource(R.string.overlay_pair) to CarBluetooth::pair,
secondary = "Not now" to CarBluetooth::notNow,
primary = "Pair" to CarBluetooth::pair,
) )
CarBluetooth.Prompt.PAIRING -> PairCard( CarBluetooth.Prompt.PAIRING -> PairCard(
title = "Confirm on your phone", title = stringResource(R.string.overlay_confirm_title),
body = "Check the code on your phone and tap Pair. The car may ask too.", body = stringResource(R.string.overlay_confirm_body),
secondary = null, secondary = null,
primary = "Hide" to CarBluetooth::hide, primary = stringResource(R.string.carset_hide) to CarBluetooth::hide,
) )
} }
} }
@@ -1,5 +1,7 @@
package app.seb3thehacker.gearslip.car package app.seb3thehacker.gearslip.car
import androidx.compose.ui.res.stringResource
import app.seb3thehacker.gearslip.R
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import app.seb3thehacker.gearslip.car.theme.* import app.seb3thehacker.gearslip.car.theme.*
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -63,7 +65,7 @@ private fun IncomingCall(s: CallUiState.Ringing, onAnswer: () -> Unit, onDecline
) )
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
Text( Text(
"Incoming call", stringResource(R.string.call_incoming),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@@ -76,13 +78,13 @@ private fun IncomingCall(s: CallUiState.Ringing, onAnswer: () -> Unit, onDecline
) )
Spacer(Modifier.height(56.dp)) Spacer(Modifier.height(56.dp))
Row(horizontalArrangement = Arrangement.spacedBy(48.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(48.dp)) {
CallButton(Icons.Filled.Close, "Decline", MaterialTheme.colorScheme.error, enabled = s.canDecline, onClick = onDecline) CallButton(Icons.Filled.Close, stringResource(R.string.call_decline), MaterialTheme.colorScheme.error, enabled = s.canDecline, onClick = onDecline)
CallButton(Icons.Filled.Call, "Answer", MaterialTheme.colorScheme.primary, enabled = true, onClick = onAnswer) CallButton(Icons.Filled.Call, stringResource(R.string.call_answer), MaterialTheme.colorScheme.primary, enabled = true, onClick = onAnswer)
} }
if (!s.canDecline) { if (!s.canDecline) {
Spacer(Modifier.height(20.dp)) Spacer(Modifier.height(20.dp))
Text( Text(
"Already ringing out loud - answer here, or let it ring.", stringResource(R.string.call_ringing),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@@ -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))
}
}
}
}
}
@@ -1,5 +1,7 @@
package app.seb3thehacker.gearslip.car package app.seb3thehacker.gearslip.car
import androidx.compose.ui.res.stringResource
import app.seb3thehacker.gearslip.R
import app.seb3thehacker.gearslip.car.theme.* import app.seb3thehacker.gearslip.car.theme.*
import android.view.SurfaceHolder import android.view.SurfaceHolder
import android.view.SurfaceView import android.view.SurfaceView
@@ -11,6 +13,8 @@ import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.util.VelocityTracker import androidx.compose.ui.input.pointer.util.VelocityTracker
import kotlin.math.hypot import kotlin.math.hypot
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.width
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.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@@ -73,10 +77,28 @@ fun CarAppStage(
if (status.surfaceRequested) { if (status.surfaceRequested) {
AppSurface(connection, frame, template, Modifier.fillMaxSize()) AppSurface(connection, frame, template, Modifier.fillMaxSize())
} }
// The docked player floats over one side of the map: the chrome keeps clear of it,
// and the app hears that side is covered, so its map centres in the rest.
val panel = LocalSidePanelInset.current
val panelAtStart = LocalCardsAtEnd.current
TemplateChrome( TemplateChrome(
template, template,
Modifier.fillMaxSize().padding(top = if (embedded) 0.dp else CHROME_BAR, bottom = navBar), Modifier.fillMaxSize().padding(
top = if (embedded) 0.dp else CHROME_BAR,
bottom = navBar,
start = if (panelAtStart) panel else 0.dp,
end = if (panelAtStart) 0.dp else panel,
),
) )
if (panel > 0.dp) {
Box(
Modifier
.align(if (panelAtStart) Alignment.CenterStart else Alignment.CenterEnd)
.fillMaxHeight()
.width(panel)
.covers("side-panel", if (panelAtStart) CoverEdge.START else CoverEdge.END),
)
}
BottomCover(navBar, Modifier.align(Alignment.BottomCenter)) BottomCover(navBar, Modifier.align(Alignment.BottomCenter))
} }
if (!embedded) ChromeBar(status, onDisconnect, Modifier.align(Alignment.TopCenter)) if (!embedded) ChromeBar(status, onDisconnect, Modifier.align(Alignment.TopCenter))
@@ -106,12 +128,16 @@ private fun AppSurface(
val measured = bounds?.takeIf { width > 0 && height > 0 }?.let { cover?.visibleWithin(it, width, height) } val measured = bounds?.takeIf { width > 0 && height > 0 }?.let { cover?.visibleWithin(it, width, height) }
// The card column moves the map's centre but isn't taken out of the stable area: apps size // The card column moves the map's centre but isn't taken out of the stable area: apps size
// their own overlays from it, and Vela's speedometer shrank to fit the strip right of the cards. // their own overlays from it, and Vela's speedometer shrank to fit the strip right of the cards.
val measuredStable = bounds?.takeIf { width > 0 && height > 0 }?.let { cover?.visibleWithin(it, width, height, skip = CoverEdge.START) } val cardsAtEnd = LocalCardsAtEnd.current
LaunchedEffect(template, size, measured, measuredStable) { val cardEdge = cardEdge
val measuredStable = bounds?.takeIf { width > 0 && height > 0 }?.let { cover?.visibleWithin(it, width, height, skip = cardEdge) }
LaunchedEffect(template, size, measured, measuredStable, cardsAtEnd) {
if (width > 0 && height > 0) { if (width > 0 && height > 0) {
val visible = visibleAreaFor(template, width, height, measured) val visible = visibleAreaFor(template, width, height, measured, cardsAtEnd)
val stable = if (!isContentTemplate(template)) { // Waze seems to centre its car in the stable area rather than the visible one, so it
visibleAreaFor(template, width, height, measuredStable) // gets the cards taken out of that too. Test: see whether its car moves out from under them.
val stable = if (!isContentTemplate(template) && connection.appPackage != "com.waze") {
visibleAreaFor(template, width, height, measuredStable, cardsAtEnd)
} else { } else {
visible visible
} }
@@ -227,14 +253,14 @@ private fun ChromeBar(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text( Text(
status.app ?: "Car app", status.app ?: stringResource(R.string.stage_car_app),
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
GsIconButton( GsIconButton(
androidx.compose.material.icons.Icons.Filled.Close, androidx.compose.material.icons.Icons.Filled.Close,
"Close ${status.app ?: "the app"}", status.app?.let { stringResource(R.string.stage_close, it) } ?: stringResource(R.string.stage_close_app),
onDisconnect, onDisconnect,
size = 36.dp, size = 36.dp,
iconSize = 20.dp, iconSize = 20.dp,
@@ -1,6 +1,9 @@
package app.seb3thehacker.gearslip.car package app.seb3thehacker.gearslip.car
import androidx.annotation.StringRes
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
@@ -13,7 +16,9 @@ import app.seb3thehacker.gearslip.ProjectedContent
/** One tile in the launcher and the screen it opens. */ /** One tile in the launcher and the screen it opens. */
class CarApp( class CarApp(
val id: String, val id: String,
/** English, for matching what a driver says; [labelRes] is what the screen shows. */
val label: String, val label: String,
@param:StringRes val labelRes: Int,
val icon: ImageVector, val icon: ImageVector,
val content: @Composable () -> Unit, val content: @Composable () -> Unit,
) )
@@ -22,19 +27,28 @@ class CarApp(
object CarApps { object CarApps {
val all: List<CarApp> = listOf( val all: List<CarApp> = listOf(
CarApp("web", "Browser", Icons.Filled.Search) { CarApp("web", "Browser", R.string.app_browser, Icons.Filled.Search) {
WebApp(content = ProjectedContent.resolve(LocalContext.current)) WebApp(content = ProjectedContent.resolve(LocalContext.current))
}, },
CarApp("phone", "Screen sharing", Icons.Filled.Share) { CarApp("phone", "Screen sharing", R.string.app_screen_sharing, Icons.Filled.Share) {
PhoneAppsScreen() PhoneAppsScreen()
}, },
CarApp("dialer", "Phone", Icons.Filled.Call) { CarApp("dialer", "Phone", R.string.carset_phone, Icons.Filled.Call) {
PhoneDialerScreen() PhoneDialerScreen()
}, },
CarApp("settings", "Settings", 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 }
}
} }
@@ -1,5 +1,7 @@
package app.seb3thehacker.gearslip.car package app.seb3thehacker.gearslip.car
import androidx.annotation.StringRes
import app.seb3thehacker.gearslip.R
import android.Manifest import android.Manifest
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter import android.bluetooth.BluetoothAdapter
@@ -38,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 {
@@ -61,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
@@ -81,18 +90,39 @@ 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
} }
} }
/**
* Opens the car's Bluetooth channel though there's no pairing to do, as a real phone does on
* every connection. Issue #18: a 2021 Bolt hangs up about 20 seconds in, and this is one of
* the two channels it offers that Gearslip never opened. Without an address the driver typed,
* it only opens the channel: a made-up address could set the car pairing with the wrong phone.
*/
fun announce(why: String) {
val c = car ?: return
if (pairing || announcing) return
announcing = true
log.i("-> ChannelOpenRequest on channel ${c.service.serviceId} ($why)")
c.open()
}
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
@@ -111,7 +141,7 @@ object CarBluetooth {
// If the car never answers on its channel, pair from the phone anyway. // If the car never answers on its channel, pair from the phone anyway.
main.postDelayed({ startBond("the car didn't answer the pairing request") }, CAR_REPLY_WAIT_MS) main.postDelayed({ startBond("the car didn't answer the pairing request") }, CAR_REPLY_WAIT_MS)
main.postDelayed({ main.postDelayed({
if (pairing) finish("Pairing didn't finish. Pair from the car's Bluetooth menu instead.") if (pairing) finish(R.string.bt_pair_unfinished)
}, GIVE_UP_MS) }, GIVE_UP_MS)
} }
@@ -137,7 +167,12 @@ 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
}
if (announcing && typedAddress() == null) {
log.i("Bluetooth channel open; no address typed in Settings, so no PairingRequest")
return return
} }
val method = c.service.pairingMethods.firstOrNull { it == METHOD_NUMERIC_COMPARISON } val method = c.service.pairingMethods.firstOrNull { it == METHOD_NUMERIC_COMPARISON }
@@ -151,6 +186,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)
} }
@@ -168,7 +204,7 @@ object CarBluetooth {
bondStarted = true bondStarted = true
val started = runCatching { device.createBond() }.getOrDefault(false) val started = runCatching { device.createBond() }.getOrDefault(false)
log.i("pairing from the phone ($why): ${if (started) "started" else "refused"}") log.i("pairing from the phone ($why): ${if (started) "started" else "refused"}")
if (!started) finish("Pairing didn't start. Pair from the car's Bluetooth menu instead.") if (!started) finish(R.string.bt_pair_not_started)
} }
private fun watchBonding(device: BluetoothDevice) { private fun watchBonding(device: BluetoothDevice) {
@@ -184,8 +220,8 @@ object CarBluetooth {
log.i("bond state $previous -> $state") log.i("bond state $previous -> $state")
when { when {
state == BluetoothDevice.BOND_BONDING -> bondStarted = true state == BluetoothDevice.BOND_BONDING -> bondStarted = true
state == BluetoothDevice.BOND_BONDED -> finish("Paired. Calls now play through the car.") state == BluetoothDevice.BOND_BONDED -> finish(R.string.bt_paired)
previous == BluetoothDevice.BOND_BONDING -> finish("Pairing didn't finish. Pair from the car's Bluetooth menu instead.") previous == BluetoothDevice.BOND_BONDING -> finish(R.string.bt_pair_unfinished)
} }
} }
} }
@@ -193,12 +229,12 @@ object CarBluetooth {
receiver = r receiver = r
} }
private fun finish(message: String) { private fun finish(@StringRes message: Int) {
pairing = false pairing = false
stopWatching() stopWatching()
main.removeCallbacksAndMessages(null) main.removeCallbacksAndMessages(null)
_prompt.value = Prompt.NONE _prompt.value = Prompt.NONE
CarToasts.show(this, message, CarToast.LENGTH_LONG) CarToasts.show(this, appContext?.getString(message) ?: return, CarToast.LENGTH_LONG)
} }
private fun stopWatching() { private fun stopWatching() {
@@ -227,12 +263,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(",")
@@ -244,6 +298,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
@@ -1,5 +1,7 @@
package app.seb3thehacker.gearslip.car package app.seb3thehacker.gearslip.car
import androidx.compose.ui.res.stringResource
import app.seb3thehacker.gearslip.R
import android.Manifest import android.Manifest
import android.content.Context import android.content.Context
import android.content.pm.PackageManager import android.content.pm.PackageManager
@@ -60,7 +62,7 @@ fun CarDashboardScreen() {
.padding(horizontal = 24.dp, vertical = 16.dp), .padding(horizontal = 24.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(24.dp), verticalArrangement = Arrangement.spacedBy(24.dp),
) { ) {
ScreenHeader("Today") ScreenHeader(stringResource(R.string.dash_today))
WeatherCard(context) WeatherCard(context)
AgendaCard(context) AgendaCard(context)
NotificationsSection() NotificationsSection()
@@ -84,9 +86,9 @@ private fun WeatherCard(context: Context) {
horizontalArrangement = Arrangement.spacedBy(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
when (val s = state) { when (val s = state) {
WeatherState.Loading -> Text("Getting weather…", style = MaterialTheme.typography.bodyLarge) WeatherState.Loading -> Text(stringResource(R.string.dash_getting_weather), style = MaterialTheme.typography.bodyLarge)
is WeatherState.Unavailable -> Text( is WeatherState.Unavailable -> Text(
s.reason, style = MaterialTheme.typography.bodyLarge, stringResource(s.reason), style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
is WeatherState.Ready -> { is WeatherState.Ready -> {
@@ -94,7 +96,7 @@ private fun WeatherCard(context: Context) {
WeatherIcon(w.code, w.isDay, size = 44.dp) WeatherIcon(w.code, w.isDay, size = 44.dp)
Column { Column {
Text( Text(
"${w.temp}${w.tempUnit} · ${Weather.describe(w.code)}", "${w.temp}${w.tempUnit} · ${Weather.condition(w.code)}",
style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold,
) )
Text(w.place, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) Text(w.place, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
@@ -112,12 +114,12 @@ private fun AgendaCard(context: Context) {
} }
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("Coming up", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) Text(stringResource(R.string.dash_coming_up), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
val list = events val list = events
when { when {
list == null -> Text("Loading calendar…", style = MaterialTheme.typography.bodyMedium) list == null -> Text(stringResource(R.string.dash_loading_calendar), style = MaterialTheme.typography.bodyMedium)
list.isEmpty() -> Text( list.isEmpty() -> Text(
"Nothing in the next week.", stringResource(R.string.dash_nothing_week),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@@ -135,7 +137,7 @@ private fun EventRow(event: AgendaEvent) {
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text( Text(
eventWhen(event), eventWhen(event, stringResource(R.string.dash_all_day)),
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
) )
@@ -149,14 +151,14 @@ private fun EventRow(event: AgendaEvent) {
} }
} }
/** "Today" events show just a time; later days get a weekday so a week's list stays readable. */ /** stringResource(R.string.dash_today) events show just a time; later days get a weekday so a week's list stays readable. */
private fun eventWhen(event: AgendaEvent): String { private fun eventWhen(event: AgendaEvent, allDay: String): String {
val zone = if (event.allDay) TimeZone.getTimeZone("UTC") else TimeZone.getDefault() val zone = if (event.allDay) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
val start = Calendar.getInstance(zone).apply { timeInMillis = event.startMillis } val start = Calendar.getInstance(zone).apply { timeInMillis = event.startMillis }
val today = Calendar.getInstance() val today = Calendar.getInstance()
val sameDay = start.get(Calendar.YEAR) == today.get(Calendar.YEAR) && val sameDay = start.get(Calendar.YEAR) == today.get(Calendar.YEAR) &&
start.get(Calendar.DAY_OF_YEAR) == today.get(Calendar.DAY_OF_YEAR) start.get(Calendar.DAY_OF_YEAR) == today.get(Calendar.DAY_OF_YEAR)
val time = if (event.allDay) "All day" else DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(event.startMillis)) val time = if (event.allDay) allDay else DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(event.startMillis))
if (sameDay || (event.allDay && start.before(today))) return time if (sameDay || (event.allDay && start.before(today))) return time
return SimpleDateFormat("EEE", Locale.getDefault()).apply { timeZone = zone }.format(Date(event.startMillis)) + " " + time return SimpleDateFormat("EEE", Locale.getDefault()).apply { timeZone = zone }.format(Date(event.startMillis)) + " " + time
} }
@@ -0,0 +1,110 @@
package app.seb3thehacker.gearslip.car
import app.seb3thehacker.gearslip.Wire
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* The car's sensors, read into real values for the car apps that ask (a map's speed, an EV app's
* range). [CarSensors] keeps the raw readings for the Vehicle data screen; this keeps the few with
* a known meaning, decoded the way Android Auto's SensorBatch scales them:
*
* gps_location = 1 { latitude_e7 = 2; longitude_e7 = 3; accuracy_e3 = 4; altitude_e2 = 5;
* speed_e3 = 6; bearing_e6 = 7 }
* compass = 2 { bearing_e6 = 1 } speed = 3 { speed_e3 = 1 } (metres per second)
* odometer = 5 { kms_e1 = 1 } fuel = 6 { level = 1 (percent); range = 2 (km); low = 3 }
*
* Each value is null until the car first sends it, and the whole lot clears when it disconnects.
*/
object CarData {
class Location(
val latitude: Double,
val longitude: Double,
val accuracyMeters: Float?,
val altitudeMeters: Double?,
val speedMps: Float?,
val bearingDegrees: Float?,
)
data class Snapshot(
val speedMps: Float? = null,
val odometerKm: Float? = null,
val fuelPercent: Int? = null,
val rangeKm: Int? = null,
val fuelLow: Boolean? = null,
val compassDegrees: Float? = null,
val location: Location? = null,
/** When each kind last changed, by its SensorBatch field number. */
val updatedAt: Map<Int, Long> = emptyMap(),
)
/** What the car says it is, from its service discovery: model and year. It names no maker. */
class Model(val name: String, val year: Int?)
private val flow = MutableStateFlow(Snapshot())
val state: StateFlow<Snapshot> = flow
@Volatile var model: Model? = null
private set
fun onModel(name: String, year: String) {
model = name.takeIf { it.isNotBlank() }?.let { Model(it, year.toIntOrNull()?.takeIf { y -> y > 0 }) }
}
fun onEvent(body: ByteArray) {
val now = System.currentTimeMillis()
val batch = Wire.fields(body)
var s = flow.value
val touched = HashSet<Int>()
// Each kind may come several times in one batch: the last is the newest.
Wire.allBytes(batch, LOCATION).lastOrNull()?.let { bytes ->
val f = Wire.fields(bytes)
val lat = Wire.varint(f, 2)?.toInt()
val lon = Wire.varint(f, 3)?.toInt()
if (lat != null && lon != null) {
s = s.copy(
location = Location(
lat / 1e7, lon / 1e7,
Wire.varint(f, 4)?.let { it / 1e3f },
Wire.varint(f, 5)?.let { it.toInt() / 1e2 },
Wire.varint(f, 6)?.let { it.toInt() / 1e3f },
Wire.varint(f, 7)?.let { it.toInt() / 1e6f },
),
)
touched += LOCATION
}
}
Wire.allBytes(batch, COMPASS).lastOrNull()?.let { bytes ->
Wire.varint(Wire.fields(bytes), 1)?.let { s = s.copy(compassDegrees = it.toInt() / 1e6f); touched += COMPASS }
}
Wire.allBytes(batch, SPEED).lastOrNull()?.let { bytes ->
Wire.varint(Wire.fields(bytes), 1)?.let { s = s.copy(speedMps = it.toInt() / 1e3f); touched += SPEED }
}
Wire.allBytes(batch, ODOMETER).lastOrNull()?.let { bytes ->
Wire.varint(Wire.fields(bytes), 1)?.let { s = s.copy(odometerKm = it.toInt() / 10f); touched += ODOMETER }
}
Wire.allBytes(batch, FUEL).lastOrNull()?.let { bytes ->
val f = Wire.fields(bytes)
s = s.copy(
fuelPercent = Wire.varint(f, 1)?.toInt() ?: s.fuelPercent,
rangeKm = Wire.varint(f, 2)?.toInt() ?: s.rangeKm,
fuelLow = Wire.varint(f, 3)?.let { it != 0L } ?: s.fuelLow,
)
touched += FUEL
}
if (touched.isEmpty()) return
flow.value = s.copy(updatedAt = s.updatedAt + touched.associateWith { now })
}
fun clear() {
flow.value = Snapshot()
model = null
}
const val LOCATION = 1
const val COMPASS = 2
const val SPEED = 3
const val ODOMETER = 5
const val FUEL = 6
}
@@ -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
@@ -64,7 +67,9 @@ object CarEnvironment {
} }
@Volatile private var sensorNight: Boolean? = null @Volatile private var sensorNight: Boolean? = null
private var appContext: Context? = null /** Set once the car UI starts; the cluster screen draws with it too. */
var appContext: Context? = null
private set
private var running = false private var running = false
private val handler = Handler(Looper.getMainLooper()) private val handler = Handler(Looper.getMainLooper())
@@ -93,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) {
@@ -1,5 +1,6 @@
package app.seb3thehacker.gearslip.car package app.seb3thehacker.gearslip.car
import androidx.compose.ui.res.stringResource
import app.seb3thehacker.gearslip.car.theme.* import app.seb3thehacker.gearslip.car.theme.*
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -12,6 +13,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import app.seb3thehacker.gearslip.car.theme.gsPanelShadow
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
@@ -64,7 +67,9 @@ import app.seb3thehacker.gearslip.media.NowPlaying
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
/** /**
* The home screen: the map on the left, the docked player on the right when asked for. * The home screen: the map, with the docked player floating over one side of it when asked for.
* With the player on the left, the map app's cards move to the map's far edge, so the player, the
* map and the cards each keep their own space.
* *
* The map is whatever navigation app was used last, brought back on its own. The player sits in * The map is whatever navigation app was used last, brought back on its own. The player sits in
* the nav bar's pill until its dock button moves it here; the column closes itself back to a * the nav bar's pill until its dock button moves it here; the column closes itself back to a
@@ -94,26 +99,56 @@ fun CarHome() {
} }
val splitScreen = phase == CarMedia.Phase.READY && now.isActive && panel != SidePanel.NONE val splitScreen = phase == CarMedia.Phase.READY && now.isActive && panel != SidePanel.NONE
val playerLeft by CarSettings.playerLeft.collectAsStateWithLifecycle() val playerLeft by CarSettings.playerLeft.collectAsStateWithLifecycle()
// What the card showed last, so it still has something to show while it slides away.
val shown = remember { androidx.compose.runtime.mutableStateOf(SidePanel.CONTROLS) }
if (panel != SidePanel.NONE) shown.value = panel
// No padding anywhere, split screen or not: the player sits flush against the map. // The map runs edge to edge underneath; the player floats over one side of it as a card, with
Row(Modifier.fillMaxSize()) { // a gap all round. The map's surface keeps its size when the card comes and goes, so the app
val side = Modifier.weight(1f).fillMaxHeight().padding(bottom = LocalNavBarInset.current) // only re-centres its map instead of drawing onto a new surface.
if (splitScreen && playerLeft) { BoxWithConstraints(Modifier.fillMaxSize()) {
if (panel == SidePanel.LYRICS) SideLyrics(now, side) else DockedPlayer(now, side) val column = (maxWidth / PLAYER_SHARE).coerceIn(PLAYER_MIN_WIDTH, PLAYER_MAX_WIDTH)
} androidx.compose.runtime.CompositionLocalProvider(
Box( LocalCardsAtEnd provides (splitScreen && playerLeft),
Modifier LocalSidePanelInset provides if (splitScreen) column else 0.dp,
.weight(if (splitScreen) 2.1f else 1f)
.fillMaxHeight(),
) { ) {
MapPane(navStatus, frame) MapPane(navStatus, frame)
} }
if (splitScreen && !playerLeft) { androidx.compose.animation.AnimatedVisibility(
if (panel == SidePanel.LYRICS) SideLyrics(now, side) else DockedPlayer(now, side) splitScreen,
Modifier.align(if (playerLeft) Alignment.CenterStart else Alignment.CenterEnd),
enter = androidx.compose.animation.slideInHorizontally(tween(220)) { if (playerLeft) -it / 3 else it / 3 } +
androidx.compose.animation.fadeIn(tween(180)),
exit = androidx.compose.animation.slideOutHorizontally(tween(180)) { if (playerLeft) -it / 3 else it / 3 } +
androidx.compose.animation.fadeOut(tween(140)),
) {
val card = Modifier
.width(column)
.fillMaxHeight()
.padding(
start = if (playerLeft) CARD_GAP else 0.dp,
end = if (playerLeft) 0.dp else CARD_GAP,
top = CARD_GAP,
bottom = LocalNavBarInset.current + CARD_GAP,
)
if (shown.value == SidePanel.LYRICS) SideLyrics(now, card) else DockedPlayer(now, card)
} }
} }
} }
/** The player takes one part in this many of the screen's width. */
private const val PLAYER_SHARE = 3.6f
/** Narrow enough to leave the map most of a small screen; on a wide one the map gets the extra room. */
private val PLAYER_MIN_WIDTH = 240.dp
private val PLAYER_MAX_WIDTH = 340.dp
/** Between the player card and the screen's edges, and the map's own cards beside it. */
private val CARD_GAP = 12.dp
/** The card's corners: rounder than a button's, as the map app's own cards are. */
private val CARD_SHAPE = RoundedCornerShape(24.dp)
@Composable @Composable
private fun MapPane(status: CarAppConnection.Status, frame: CarEnvironment.Frame) { private fun MapPane(status: CarAppConnection.Status, frame: CarEnvironment.Frame) {
val navigator = LocalCarNavigator.current val navigator = LocalCarNavigator.current
@@ -123,8 +158,11 @@ private fun MapPane(status: CarAppConnection.Status, frame: CarEnvironment.Frame
} }
Column( Column(
Modifier.fillMaxSize().padding(24.dp), Modifier.fillMaxSize().padding(
verticalArrangement = Arrangement.Center, start = if (LocalCardsAtEnd.current) LocalSidePanelInset.current else 0.dp,
end = if (LocalCardsAtEnd.current) 0.dp else LocalSidePanelInset.current,
).padding(24.dp),
verticalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterVertically),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
val hasLast = CarSettings.lastNav.value != null val hasLast = CarSettings.lastNav.value != null
@@ -132,18 +170,19 @@ private fun MapPane(status: CarAppConnection.Status, frame: CarEnvironment.Frame
val scope = androidx.compose.runtime.rememberCoroutineScope() val scope = androidx.compose.runtime.rememberCoroutineScope()
when (status.phase) { when (status.phase) {
CarAppConnection.Phase.BINDING, CarAppConnection.Phase.HANDSHAKE -> CarAppConnection.Phase.BINDING, CarAppConnection.Phase.HANDSHAKE ->
Text("Starting ${status.app ?: "the map"}…", style = MaterialTheme.typography.titleMedium) Text(status.app?.let { stringResource(R.string.home_starting, it) } ?: stringResource(R.string.home_starting_map), style = MaterialTheme.typography.titleMedium)
CarAppConnection.Phase.REJECTED, CarAppConnection.Phase.FAILED -> { CarAppConnection.Phase.REJECTED, CarAppConnection.Phase.FAILED -> {
Text("${status.app ?: "The map app"} would not start.", style = MaterialTheme.typography.titleMedium) Text(status.app?.let { stringResource(R.string.home_failed, it) } ?: stringResource(R.string.home_failed_map), style = MaterialTheme.typography.titleMedium)
status.detail?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) } status.detail?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) }
GsButton(onClick = navigator::apps, tone = GsTone.Tonal) { Text("Choose another app") } Spacer(Modifier.height(8.dp))
StatusButton(stringResource(R.string.home_choose_app), GsTone.Primary, navigator::apps)
} }
else -> if (!hasNavApps) { else -> if (!hasNavApps) {
// Nothing to auto-select and nothing to pick from Car apps either - this is the // Nothing to auto-select and nothing to pick from Car apps either - this is the
// only case worth a distinct screen, since every other IDLE case resolves itself. // only case worth a distinct screen, since every other IDLE case resolves itself.
Text("No mapping apps found.", style = MaterialTheme.typography.titleMedium) Text(stringResource(R.string.home_no_maps), style = MaterialTheme.typography.titleMedium)
Text( Text(
"Install a navigation app that supports Android Auto and it will start here on its own.", stringResource(R.string.home_no_maps_body),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@@ -151,9 +190,18 @@ private fun MapPane(status: CarAppConnection.Status, frame: CarEnvironment.Frame
// IDLE with a last app on record means the driver stopped it themselves - it will // IDLE with a last app on record means the driver stopped it themselves - it will
// not come back on its own, so the screen must offer a way to bring it back rather // not come back on its own, so the screen must offer a way to bring it back rather
// than sit on a "starting" message that never resolves. // than sit on a "starting" message that never resolves.
Text("Map stopped.", style = MaterialTheme.typography.titleMedium) Text(stringResource(R.string.home_map_stopped), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
GsButton(onClick = { scope.launch { CarServices.reconnectNav(frame) } }, tone = GsTone.Tonal) { Text("Reopen") } Text(
GsButton(onClick = { navigator.apps() }, tone = GsTone.Tonal) { Text("Car apps") } stringResource(R.string.home_map_stopped_body),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(8.dp))
// Side by side, the same size, the likely one lit: one clear choice, not a stack.
Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) {
StatusButton(stringResource(R.string.home_reopen_map), GsTone.Primary) { scope.launch { CarServices.reconnectNav(frame) } }
StatusButton(stringResource(R.string.home_car_apps), GsTone.Tonal) { navigator.apps() }
}
} else { } else {
// Autostart is still picking one - a navigation app is installed, so this resolves // Autostart is still picking one - a navigation app is installed, so this resolves
// to a "Starting..." state itself, in the next frame or two. // to a "Starting..." state itself, in the next frame or two.
@@ -162,9 +210,17 @@ private fun MapPane(status: CarAppConnection.Status, frame: CarEnvironment.Frame
} }
} }
/** A big, even button for the map pane's few choices. */
@Composable
private fun StatusButton(label: String, tone: GsTone, onClick: () -> Unit) {
GsButton(onClick = onClick, tone = tone, modifier = Modifier.width(220.dp).height(64.dp)) {
Text(label, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
}
}
/** /**
* The player, docked beside the map: art, title, progress, transport, then like, shuffle, repeat * The player, docked beside the map: the art as large as it fits, then title, progress, transport,
* and lyrics. While it's here the nav bar's pill steps aside on Home, so the controls never show * and like, shuffle, repeat and lyrics along the bottom. While it's here the nav bar's pill steps aside on Home, so the controls never show
* twice. The X sends it back to the nav bar. * twice. The X sends it back to the nav bar.
*/ */
@Composable @Composable
@@ -175,28 +231,31 @@ private fun DockedPlayer(now: NowPlaying, modifier: Modifier) {
val scheme = MaterialTheme.colorScheme val scheme = MaterialTheme.colorScheme
val art by produceState(now.art, now.art, now.artUri) { value = now.art ?: MediaArt.load(context, now.artUri) } val art by produceState(now.art, now.art, now.artUri) { value = now.art ?: MediaArt.load(context, now.artUri) }
Surface(modifier = modifier, color = rememberArtColor(art, MaterialTheme.colorScheme.surfaceVariant)) { // The theme's surface, with a faint wash of the cover's colours behind the top of it. A solid
BoxWithConstraints(Modifier.fillMaxSize().padding(12.dp)) { // slab of album colour beside the map fought it for attention; this only hints at the song.
// Whatever height the controls below leave over, so the art grows on a taller Surface(modifier = modifier.gsPanelShadow(CARD_SHAPE), shape = CARD_SHAPE, color = scheme.surfaceContainer) {
// screen and shrinks rather than pushing the buttons off a short one. AlbumGlow(art, Modifier.fillMaxSize())
val artSize = minOf(maxWidth - 44.dp, maxHeight - 250.dp).coerceIn(56.dp, 200.dp) Box(Modifier.fillMaxSize().padding(12.dp)) {
Column(Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally) { Column(Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally) {
Box(Modifier.fillMaxWidth()) { // The cover gets everything the controls leave, as big a square as fits, sitting
// right on top of the title; the controls stay pinned to the bottom.
BoxWithConstraints(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.BottomCenter) {
val artSize = minOf(maxWidth, maxHeight).coerceAtLeast(0.dp)
// Art opens the full media screen, same as on the pill. // Art opens the full media screen, same as on the pill.
val coverShape = RoundedCornerShape(16.dp)
Box( Box(
Modifier Modifier
.align(Alignment.TopCenter)
.size(artSize) .size(artSize)
.clip(RoundedCornerShape(12.dp)) .gsPanelShadow(coverShape)
.clip(coverShape)
.background(scheme.surface) .background(scheme.surface)
.clickable { navigator.media() }, .clickable { navigator.media() },
) { ) {
art?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) } // A new song's cover fades in over the old one instead of cutting to it.
androidx.compose.animation.Crossfade(art, animationSpec = tween(350), label = "cover") { shown ->
shown?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) }
}
} }
GsIconButton(
Icons.Filled.Close, "Back to the nav bar", navigator::closeSidePanel,
Modifier.align(Alignment.TopEnd), size = 36.dp, iconSize = 20.dp,
)
} }
Spacer(Modifier.height(10.dp)) Spacer(Modifier.height(10.dp))
Text( Text(
@@ -212,46 +271,53 @@ private fun DockedPlayer(now: NowPlaying, modifier: Modifier) {
} else { } else {
Spacer(Modifier.height(10.dp)) Spacer(Modifier.height(10.dp))
} }
TransportRow(now, media, skip = 48.dp, modifier = Modifier.fillMaxWidth()) TransportRow(now, media, skip = 56.dp, modifier = Modifier.fillMaxWidth())
Spacer(Modifier.height(10.dp)) Spacer(Modifier.height(10.dp))
PlayModeRow(now, media, size = 42.dp, modifier = Modifier.fillMaxWidth()) { PlayModeRow(now, media, size = 50.dp, modifier = Modifier.fillMaxWidth()) {
GsIconButton( GsIconButton(
ImageVector.vectorResource(R.drawable.lyrics_24), "Lyrics", navigator::showSideLyrics, ImageVector.vectorResource(R.drawable.lyrics_24), stringResource(R.string.storage_lyrics), navigator::showSideLyrics,
size = 42.dp, iconSize = 22.dp, size = 50.dp, iconSize = 26.dp,
) )
} }
} }
GsIconButton(
Icons.Filled.Close, stringResource(R.string.home_back_nav), navigator::closeSidePanel,
Modifier.align(Alignment.TopEnd), size = 36.dp, iconSize = 20.dp,
)
} }
} }
} }
/** /**
* Lyrics alone beside the map, big enough to read at a glance. No art: that space goes to a * Lyrics alone beside the map, big enough to read at a glance. The header is slim, since the
* larger title and artist instead. The player goes back to the nav bar meanwhile, whose dock * player goes back to the nav bar meanwhile and names the song there. Its note button swaps the
* button swaps these back out for the controls. * lyrics back for the player; the X closes the panel.
*/ */
@Composable @Composable
private fun SideLyrics(now: NowPlaying, modifier: Modifier) { private fun SideLyrics(now: NowPlaying, modifier: Modifier) {
val navigator = LocalCarNavigator.current val navigator = LocalCarNavigator.current
val context = LocalContext.current val context = LocalContext.current
val scheme = MaterialTheme.colorScheme
val art by produceState(now.art, now.art, now.artUri) { value = now.art ?: MediaArt.load(context, now.artUri) } val art by produceState(now.art, now.art, now.artUri) { value = now.art ?: MediaArt.load(context, now.artUri) }
Surface(modifier = modifier, color = rememberArtColor(art, MaterialTheme.colorScheme.surfaceVariant)) { Surface(modifier = modifier.gsPanelShadow(CARD_SHAPE), shape = CARD_SHAPE, color = scheme.surfaceContainer) {
Column(Modifier.fillMaxSize().padding(16.dp)) { Column(Modifier.fillMaxSize().padding(start = 18.dp, end = 12.dp, top = 12.dp, bottom = 8.dp)) {
Row(verticalAlignment = Alignment.Top) { Row(verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) { Box(Modifier.size(36.dp).clip(RoundedCornerShape(8.dp)).background(scheme.surface)) {
Text( art?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) }
now.title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold,
maxLines = 2, overflow = TextOverflow.Ellipsis,
)
Text(
now.artist, style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
} }
GsIconButton(Icons.Filled.Close, "Hide lyrics", navigator::closeSidePanel, size = 36.dp, iconSize = 20.dp) Spacer(Modifier.size(10.dp))
Text(
listOf(now.title, now.artist).filter { it.isNotEmpty() }.joinToString(" · "),
Modifier.weight(1f),
style = MaterialTheme.typography.bodyMedium,
color = scheme.onSurfaceVariant,
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
// The way back to the player, as the player's own Lyrics button is the way here.
GsIconButton(MediaIcons.MusicNote, stringResource(R.string.home_show_player), navigator::showSidePlayer, size = 36.dp, iconSize = 20.dp)
Spacer(Modifier.size(6.dp))
GsIconButton(Icons.Filled.Close, stringResource(R.string.home_hide_lyrics), navigator::closeSidePanel, size = 36.dp, iconSize = 20.dp)
} }
Spacer(Modifier.height(12.dp))
LyricsPanel(now, Modifier.weight(1f).fillMaxWidth(), lineStyle = MaterialTheme.typography.headlineSmall) LyricsPanel(now, Modifier.weight(1f).fillMaxWidth(), lineStyle = MaterialTheme.typography.headlineSmall)
} }
} }
@@ -1,5 +1,7 @@
package app.seb3thehacker.gearslip.car package app.seb3thehacker.gearslip.car
import androidx.compose.ui.res.stringResource
import app.seb3thehacker.gearslip.R
import app.seb3thehacker.gearslip.car.theme.* import app.seb3thehacker.gearslip.car.theme.*
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -153,7 +155,7 @@ fun CarKeyboard(
horizontalArrangement = Arrangement.spacedBy(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp),
) { ) {
Key(if (symbols) "abc" else "?123", Modifier.weight(1.4f)) { symbols = !symbols } Key(if (symbols) "abc" else "?123", Modifier.weight(1.4f)) { symbols = !symbols }
Key("space", Modifier.weight(if (onDismiss != null) 3.2f else 4f)) { onTextChange("$text ") } Key(stringResource(R.string.keyboard_space), Modifier.weight(if (onDismiss != null) 3.2f else 4f)) { onTextChange("$text ") }
if (onDismiss != null) { if (onDismiss != null) {
DismissKey(onDismiss, Modifier.weight(1.4f)) DismissKey(onDismiss, Modifier.weight(1.4f))
} }
@@ -200,7 +202,7 @@ private fun DismissKey(onClick: () -> Unit, modifier: Modifier = Modifier) {
horizontalArrangement = Arrangement.Center, horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Icon(Icons.Filled.Close, contentDescription = "Hide keyboard") Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.keyboard_hide))
} }
} }
} }
@@ -220,7 +222,7 @@ private fun SubmitKey(modifier: Modifier, search: Boolean, image: ImageVector?,
) { ) {
Icon( Icon(
image ?: if (search) Icons.Filled.Search else Icons.Filled.ArrowBack, image ?: if (search) Icons.Filled.Search else Icons.Filled.ArrowBack,
contentDescription = "Submit", contentDescription = stringResource(R.string.keyboard_submit),
) )
} }
} }
@@ -1,5 +1,7 @@
package app.seb3thehacker.gearslip.car package app.seb3thehacker.gearslip.car
import androidx.compose.ui.res.stringResource
import app.seb3thehacker.gearslip.R
import app.seb3thehacker.gearslip.car.theme.* import app.seb3thehacker.gearslip.car.theme.*
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
@@ -127,7 +129,7 @@ internal object LauncherCache {
// notifications - rather than a filter per kind that has to know about the others. // notifications - rather than a filter per kind that has to know about the others.
val tiles = (carApps.keys + mediaApps.keys + messagingApps.keys).flatMap { pkg -> val tiles = (carApps.keys + mediaApps.keys + messagingApps.keys).flatMap { pkg ->
val icon by lazy { iconOf(pkg) } val icon by lazy { iconOf(pkg) }
tilesFor(carApps[pkg], mediaApps[pkg], messagingApps[pkg]).map { tile -> tilesFor(carApps[pkg], mediaApps[pkg], messagingApps[pkg], context.getString(R.string.media_browse)).map { tile ->
when (tile) { when (tile) {
is AppTile.Screen -> Entry(tile.label, icon, template = tile.app) is AppTile.Screen -> Entry(tile.label, icon, template = tile.app)
is AppTile.Player -> Entry(tile.app.label, icon, media = tile.app) is AppTile.Player -> Entry(tile.app.label, icon, media = tile.app)
@@ -163,6 +165,7 @@ private fun tilesFor(
car: TemplateApp?, car: TemplateApp?,
media: app.seb3thehacker.gearslip.media.MediaApp?, media: app.seb3thehacker.gearslip.media.MediaApp?,
messaging: app.seb3thehacker.gearslip.notify.MessagingApp?, messaging: app.seb3thehacker.gearslip.notify.MessagingApp?,
browse: String,
): List<AppTile> { ): List<AppTile> {
if (car != null && !car.isMediaScreen && car.kind != "messaging") return listOf(AppTile.Screen(car, car.label)) if (car != null && !car.isMediaScreen && car.kind != "messaging") return listOf(AppTile.Screen(car, car.label))
val pkg = car?.component?.packageName ?: media?.component?.packageName ?: messaging?.packageName ?: return emptyList() val pkg = car?.component?.packageName ?: media?.component?.packageName ?: messaging?.packageName ?: return emptyList()
@@ -170,7 +173,7 @@ private fun tilesFor(
val screenRefused = KnownApps.screenBroken(pkg) && KnownApps.playerWorks(pkg) val screenRefused = KnownApps.screenBroken(pkg) && KnownApps.playerWorks(pkg)
val messengerScreen = messaging != null && (car?.kind == "messaging" || car?.kind == "app") val messengerScreen = messaging != null && (car?.kind == "messaging" || car?.kind == "app")
if (car != null && !messengerScreen && !(media != null && screenRefused)) { if (car != null && !messengerScreen && !(media != null && screenRefused)) {
add(AppTile.Screen(car, if (media != null) "${car.label} · Browse" else car.label)) add(AppTile.Screen(car, if (media != null) "${car.label} · $browse" else car.label))
} }
if (media != null) add(AppTile.Player(media)) if (media != null) add(AppTile.Player(media))
else if (messaging != null) add(AppTile.Messages(messaging)) else if (messaging != null) add(AppTile.Messages(messaging))
@@ -182,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. */
@@ -221,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() }),
@@ -237,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) }
@@ -280,14 +287,15 @@ fun CarLauncher() {
val pkg = entry.packageName?.takeIf { entry.messaging == null } val pkg = entry.packageName?.takeIf { entry.messaging == null }
val id = entry.componentId val id = entry.componentId
return Tile( return Tile(
entry.label, entry.icon, entry.builtIn?.glyph, entry.builtIn?.let { context.getString(it.labelRes) } ?: entry.label, entry.icon, entry.builtIn?.glyph,
broken = pkg != null && (KnownApps.isBroken(context, pkg) || (entry.template != null && KnownApps.screenBroken(pkg))), broken = pkg != null && (KnownApps.isBroken(context, pkg) || (entry.template != null && KnownApps.screenBroken(pkg))),
onLongClick = id?.let { { navigator.showAppMenu(it) } }, onLongClick = id?.let { { navigator.showAppMenu(it) } },
) { openApp(entry, id?.let { running[it] }, navigator, frame) } ) { openApp(entry, id?.let { running[it] }, navigator, frame) }
} }
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),
@@ -307,7 +315,7 @@ private fun BrokenBadge(size: androidx.compose.ui.unit.Dp, modifier: Modifier =
modifier.size(size).background(Color(0xFFD32F2F), CircleShape), modifier.size(size).background(Color(0xFFD32F2F), CircleShape),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Icon(Icons.Filled.Close, contentDescription = "Doesn't work with Gearslip", tint = Color.White, modifier = Modifier.size(size * 0.66f)) Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.launcher_broken), tint = Color.White, modifier = Modifier.size(size * 0.66f))
} }
} }
@@ -1,5 +1,6 @@
package app.seb3thehacker.gearslip.car package app.seb3thehacker.gearslip.car
import androidx.compose.ui.res.stringResource
import androidx.compose.material.icons.filled.Call import androidx.compose.material.icons.filled.Call
import app.seb3thehacker.gearslip.call.CarCalls import app.seb3thehacker.gearslip.call.CarCalls
import app.seb3thehacker.gearslip.call.CallUiState import app.seb3thehacker.gearslip.call.CallUiState
@@ -74,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
@@ -115,12 +117,12 @@ private fun BreadcrumbBar(navigator: CarNavigator) {
Modifier.fillMaxWidth().height(40.dp).padding(start = 2.dp, end = 10.dp), Modifier.fillMaxWidth().height(40.dp).padding(start = 2.dp, end = 10.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
GsIconButton(Icons.AutoMirrored.Filled.ArrowBack, "Back", navigator::back, size = 32.dp, iconSize = 20.dp) GsIconButton(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back), navigator::back, size = 32.dp, iconSize = 20.dp)
Spacer(Modifier.width(2.dp)) Spacer(Modifier.width(2.dp))
trail.forEachIndexed { index, crumb -> trail.forEachIndexed { index, crumb ->
val isLast = index == trail.lastIndex val isLast = index == trail.lastIndex
Text( Text(
crumb.label, crumb.shown(),
style = MaterialTheme.typography.labelLarge, style = MaterialTheme.typography.labelLarge,
fontWeight = if (isLast) FontWeight.SemiBold else FontWeight.Normal, fontWeight = if (isLast) FontWeight.SemiBold else FontWeight.Normal,
color = if (isLast) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant, color = if (isLast) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
@@ -164,7 +166,11 @@ internal fun CarNavBar(navigator: CarNavigator) {
val frame by CarEnvironment.frame.collectAsState() val frame by CarEnvironment.frame.collectAsState()
val running = rememberRunningApps() val running = rememberRunningApps()
Surface(color = MaterialTheme.colorScheme.surfaceContainer) { // A full-width strip along the bottom in the screens' own background, so it reads as part of
// the screen above rather than a panel on it. Over the player it's see-through, so the
// cover's glow carries on behind it.
val overPlayer = navigator.current == CarScreen.Media
Surface(color = if (overPlayer) androidx.compose.ui.graphics.Color.Transparent else MaterialTheme.colorScheme.background) {
BoxWithConstraints(Modifier.fillMaxWidth().height(NAV_BAR_HEIGHT).padding(horizontal = 10.dp)) { BoxWithConstraints(Modifier.fillMaxWidth().height(NAV_BAR_HEIGHT).padding(horizontal = 10.dp)) {
Row(Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) { Row(Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) {
// Home and Apps are two different places now, not a toggle on one button - the // Home and Apps are two different places now, not a toggle on one button - the
@@ -192,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) {
@@ -280,7 +287,7 @@ private fun NavHomeButton(navigator: CarNavigator, entries: List<Entry>, running
} }
} else { } else {
Icon( Icon(
ImageVector.vectorResource(R.drawable.navigation_24), contentDescription = "Home", ImageVector.vectorResource(R.drawable.navigation_24), contentDescription = stringResource(R.string.crumb_home),
modifier = Modifier.padding(NAV_ITEM_PADDING).size(28.dp), modifier = Modifier.padding(NAV_ITEM_PADDING).size(28.dp),
) )
} }
@@ -341,7 +348,7 @@ private fun NavAppIcon(
onLongClick = onLongClick, onLongClick = onLongClick,
) { ) {
entry.icon?.let { Image(it, entry.label, Modifier.size(30.dp).clip(RoundedCornerShape(8.dp))) } entry.icon?.let { Image(it, entry.label, Modifier.size(30.dp).clip(RoundedCornerShape(8.dp))) }
entry.builtIn?.let { Icon(it.glyph, entry.label, Modifier.size(26.dp)) } entry.builtIn?.let { Icon(it.glyph, entry.shownLabel(), Modifier.size(26.dp)) }
// Only running apps get the line along the bottom edge, the usual taskbar mark for it. // Only running apps get the line along the bottom edge, the usual taskbar mark for it.
if (open) { if (open) {
Box( Box(
@@ -411,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
@@ -424,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(),
@@ -435,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) {
@@ -492,7 +508,7 @@ private fun NavCallPill(call: CallUiState.Active, modifier: Modifier = Modifier)
) { ) {
Icon(Icons.Filled.Call, contentDescription = null, modifier = Modifier.size(20.dp)) Icon(Icons.Filled.Call, contentDescription = null, modifier = Modifier.size(20.dp))
Text( Text(
call.caller?.label ?: "On a call", call.caller?.label ?: stringResource(R.string.navbar_on_call),
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
maxLines = 1, maxLines = 1,
@@ -507,12 +523,12 @@ private fun NavCallPill(call: CallUiState.Active, modifier: Modifier = Modifier)
Spacer(Modifier.width(2.dp)) Spacer(Modifier.width(2.dp))
GsIconButton( GsIconButton(
if (muted) MediaIcons.MicOff else MediaIcons.Mic, if (muted) MediaIcons.MicOff else MediaIcons.Mic,
if (muted) "Unmute" else "Mute", stringResource(if (muted) R.string.navbar_unmute else R.string.navbar_mute),
CarCalls::toggleMute, CarCalls::toggleMute,
tone = if (muted) GsTone.Primary else GsTone.Neutral, size = 36.dp, iconSize = 22.dp, tone = if (muted) GsTone.Primary else GsTone.Neutral, size = 36.dp, iconSize = 22.dp,
) )
GsIconButton( GsIconButton(
MediaIcons.CallEnd, "End call", { CarCalls.end(context) }, MediaIcons.CallEnd, stringResource(R.string.navbar_end_call), { CarCalls.end(context) },
colors = GsColors(scheme.error, scheme.onError), size = 36.dp, iconSize = 22.dp, colors = GsColors(scheme.error, scheme.onError), size = 36.dp, iconSize = 22.dp,
) )
} }
@@ -520,15 +536,16 @@ private fun NavCallPill(call: CallUiState.Active, modifier: Modifier = Modifier)
} }
/** "Title - Artist" on one line. The text itself cuts it off, and only where the pill runs out of room. */ /** "Title - Artist" on one line. The text itself cuts it off, and only where the pill runs out of room. */
@Composable
private fun nowPlayingLabel(title: String, artist: String): String = private fun nowPlayingLabel(title: String, artist: String): String =
listOf(title.trim(), artist.trim()).filter { it.isNotEmpty() }.joinToString(" - ") listOf(title.trim(), artist.trim()).filter { it.isNotEmpty() }.joinToString(" - ")
.ifEmpty { "Nothing playing" } .ifEmpty { stringResource(R.string.media_nothing_playing) }
/** /**
* The player in the nav bar, on every screen. Art and title open the full media screen; * The player in the nav bar, on every screen. Art and title open the full media screen;
* play/pause and skip work right here. The dock button moves the player into the column beside * play/pause and skip work right here. The dock button moves the player into the column beside
* the map on Home (see [CarHome]), and the pill steps aside there while it's docked - unless the * the map on Home (see [CarHome]), and the pill steps aside there while it's docked - unless the
* column is showing lyrics instead, when the pill stays and its dock button swaps them back. * column is showing lyrics instead, when the pill stays and its dock button closes them.
*/ */
@Composable @Composable
private fun NavNowPlaying(navigator: CarNavigator, selected: Boolean, modifier: Modifier = Modifier) { private fun NavNowPlaying(navigator: CarNavigator, selected: Boolean, modifier: Modifier = Modifier) {
@@ -604,24 +621,34 @@ private fun NavNowPlaying(navigator: CarNavigator, selected: Boolean, modifier:
} }
if (!showTitle) { if (!showTitle) {
GsIconButton( GsIconButton(
MediaIcons.Previous, "Previous", media::previous, MediaIcons.Previous, stringResource(R.string.player_previous), media::previous,
enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_PREVIOUS), size = 36.dp, iconSize = 22.dp, enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_PREVIOUS), size = 36.dp, iconSize = 22.dp,
) )
} }
GsIconButton( GsIconButton(
if (now.playing) MediaIcons.Pause else Icons.Filled.PlayArrow, if (now.playing) MediaIcons.Pause else Icons.Filled.PlayArrow,
if (now.playing) "Pause" else "Play", stringResource(if (now.playing) R.string.player_pause else R.string.media_play),
media::togglePlay, media::togglePlay,
tone = GsTone.Primary, size = 36.dp, iconSize = 22.dp, tone = GsTone.Primary, size = 36.dp, iconSize = 22.dp,
) )
if (!showTitle) { if (!showTitle) {
GsIconButton( GsIconButton(
MediaIcons.Next, "Next", media::next, MediaIcons.Next, stringResource(R.string.player_next), media::next,
enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_NEXT), size = 36.dp, iconSize = 22.dp, enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_NEXT), size = 36.dp, iconSize = 22.dp,
) )
} }
// Drawn on the side the panel opens on, and filled in while it's open.
val playerLeft by CarSettings.playerLeft.collectAsState()
val docked = navigator.current == CarScreen.Home && navigator.sidePanel != SidePanel.NONE
GsIconButton( GsIconButton(
MediaIcons.DockRight, "Move the player beside the map", navigator::togglePlayerDock, when {
playerLeft && docked -> MediaIcons.DockLeftOpen
playerLeft -> MediaIcons.DockLeft
docked -> MediaIcons.DockRightOpen
else -> MediaIcons.DockRight
},
stringResource(if (docked) R.string.navbar_close_panel else R.string.navbar_dock_player),
navigator::togglePlayerDock,
size = 36.dp, iconSize = 22.dp, size = 36.dp, iconSize = 22.dp,
) )
} }
@@ -1,5 +1,9 @@
package app.seb3thehacker.gearslip.car package app.seb3thehacker.gearslip.car
import androidx.compose.runtime.Composable
import androidx.annotation.StringRes
import androidx.compose.ui.res.stringResource
import app.seb3thehacker.gearslip.R
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -41,7 +45,11 @@ sealed interface CarScreen {
enum class SidePanel { NONE, CONTROLS, LYRICS } enum class SidePanel { NONE, CONTROLS, LYRICS }
/** One step on the breadcrumb trail: the screen it shows and the word for it in the trail. */ /** One step on the breadcrumb trail: the screen it shows and the word for it in the trail. */
data class Crumb(val screen: CarScreen, val label: String) data class Crumb(val screen: CarScreen, val label: String, @param:StringRes val labelRes: Int = 0) {
/** [labelRes] when the step is one of Gearslip's own, translated; [label] for an app's own name. */
@Composable
fun shown(): String = if (labelRes != 0) stringResource(labelRes) else label
}
/** /**
* Where the car UI is, and how it got there. A car touchscreen has no hardware back button, so * Where the car UI is, and how it got there. A car touchscreen has no hardware back button, so
@@ -57,6 +65,7 @@ class CarNavigator(startId: String) {
Crumb( Crumb(
if (CarApps.find(startId) != null) CarScreen.App(startId) else CarScreen.Home, if (CarApps.find(startId) != null) CarScreen.App(startId) else CarScreen.Home,
"Home", "Home",
R.string.crumb_home,
), ),
) )
@@ -69,15 +78,15 @@ class CarNavigator(startId: String) {
/** Pushes a new step, unless it's the same screen already on top - tapping the button for /** Pushes a new step, unless it's the same screen already on top - tapping the button for
* where you already are shouldn't grow the trail. */ * where you already are shouldn't grow the trail. */
private fun push(screen: CarScreen, label: String) { private fun push(screen: CarScreen, label: String, @StringRes labelRes: Int = 0) {
if (stack.last().screen == screen) return if (stack.last().screen == screen) return
stack.add(Crumb(screen, label)) stack.add(Crumb(screen, label, labelRes))
} }
/** The one way back to a clean slate: resets the whole trail to just Home. */ /** The one way back to a clean slate: resets the whole trail to just Home. */
fun home() { fun home() {
stack.clear() stack.clear()
stack.add(Crumb(CarScreen.Home, "Home")) stack.add(Crumb(CarScreen.Home, "Home", R.string.crumb_home))
} }
/** /**
@@ -90,8 +99,9 @@ class CarNavigator(startId: String) {
private set private set
/** /**
* The pill's dock button: moves the player beside the map, going Home first if need be. With * The pill's dock button, an open/close switch for the panel: moves the player beside the map,
* lyrics already there it swaps them for the controls; with the controls there it closes. * going Home first if need be, or closes the panel when something is already there. Switching
* between the player and lyrics is done inside the panel itself.
*/ */
fun togglePlayerDock() { fun togglePlayerDock() {
if (current != CarScreen.Home) { if (current != CarScreen.Home) {
@@ -99,9 +109,11 @@ class CarNavigator(startId: String) {
home() home()
return return
} }
sidePanel = if (sidePanel == SidePanel.CONTROLS) SidePanel.NONE else SidePanel.CONTROLS sidePanel = if (sidePanel == SidePanel.NONE) SidePanel.CONTROLS else SidePanel.NONE
} }
fun showSidePlayer() { sidePanel = SidePanel.CONTROLS }
fun showSideLyrics() { sidePanel = SidePanel.LYRICS } fun showSideLyrics() { sidePanel = SidePanel.LYRICS }
fun closeSidePanel() { sidePanel = SidePanel.NONE } fun closeSidePanel() { sidePanel = SidePanel.NONE }
@@ -114,17 +126,17 @@ class CarNavigator(startId: String) {
fun dismissAppMenu() { appMenu = null } fun dismissAppMenu() { appMenu = null }
fun media() { push(CarScreen.Media, "Media") } fun media() { push(CarScreen.Media, "Media", R.string.crumb_media) }
fun browse(label: String) { push(CarScreen.Browse, label) } fun browse(label: String) { push(CarScreen.Browse, label) }
fun apps() { push(CarScreen.Apps, "Apps") } fun apps() { push(CarScreen.Apps, "Apps", R.string.crumb_apps) }
fun settings() { push(CarScreen.Settings, "Settings") } fun settings() { push(CarScreen.Settings, "Settings", R.string.settings_title) }
fun dashboard() { push(CarScreen.Dashboard, "Dashboard") } fun dashboard() { push(CarScreen.Dashboard, "Dashboard", R.string.crumb_dashboard) }
fun weather() { push(CarScreen.Weather, "Weather") } fun weather() { push(CarScreen.Weather, "Weather", R.string.crumb_weather) }
fun vehicleData() { push(CarScreen.VehicleData, "Vehicle data") } fun vehicleData() { push(CarScreen.VehicleData, "Vehicle data") }
@@ -132,10 +144,10 @@ class CarNavigator(startId: String) {
/** The list lives on the dashboard; only a reply gets a screen of its own. */ /** The list lives on the dashboard; only a reply gets a screen of its own. */
fun notifications(replyTo: String? = null) { fun notifications(replyTo: String? = null) {
if (replyTo == null) dashboard() else push(CarScreen.Notifications(replyTo), "Reply") if (replyTo == null) dashboard() else push(CarScreen.Notifications(replyTo), "Reply", R.string.crumb_reply)
} }
fun open(id: String) { push(CarScreen.App(id), CarApps.find(id)?.label ?: id) } fun open(id: String) { push(CarScreen.App(id), CarApps.find(id)?.label ?: id, CarApps.find(id)?.labelRes ?: 0) }
/** Jumps straight to a step already on the trail, dropping everything after it - what /** Jumps straight to a step already on the trail, dropping everything after it - what
* tapping a breadcrumb does. Index 0 (Home) is always on the trail, so this always succeeds. */ * tapping a breadcrumb does. Index 0 (Home) is always on the trail, so this always succeeds. */
@@ -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
@@ -30,6 +31,11 @@ object CarServices {
private var context: Context? = null private var context: Context? = null
/** Buffering counts as playing: an app between songs is about to make sound again. */
private val PLAYING_STATES = setOf(PlaybackState.STATE_PLAYING, PlaybackState.STATE_BUFFERING)
/** How long after pausing for the car leaving to look again for an app that started by itself. */
private const val REPAUSE_MS = 2_000L
val nav: CarAppConnection by lazy { CarAppConnection(requireNotNull(context)) } val nav: CarAppConnection by lazy { CarAppConnection(requireNotNull(context)) }
val media: CarMedia by lazy { CarMedia(requireNotNull(context)) } val media: CarMedia by lazy { CarMedia(requireNotNull(context)) }
@@ -252,25 +258,76 @@ object CarServices {
* the phone's speaker. Unplugging a USB accessory isn't "audio becoming noisy" the way * the phone's speaker. Unplugging a USB accessory isn't "audio becoming noisy" the way
* headphones are, so the apps won't pause themselves. Every playing session is paused, not * headphones are, so the apps won't pause themselves. Every playing session is paused, not
* 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.
* An app can start again on its own once the speaker is given back a moment later, which a
* Deezer driver heard after turning the car off (issue #39), so the ones it paused are
* looked at once more and paused again if they're playing.
*/ */
fun pausePhoneMedia(why: String = "the car left") { fun pausePhoneMedia(why: String = "the car left") {
val ctx = context ?: return pausedForNow = null
pausedOwnForNow = false
val paused = pause(why)
if (paused.isEmpty()) return
android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
val again = paused.filter { it.playbackState?.state in PLAYING_STATES }
again.forEach { runCatching { it.transportControls.pause() } }
if (again.isNotEmpty()) {
app.seb3thehacker.gearslip.GearslipLog.i("media: paused ${again.joinToString { it.packageName }} again: it started by itself after $why")
}
}, REPAUSE_MS)
}
/** 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 {
manager.getActiveSessions(listener).filter { it.playbackState?.state == PlaybackState.STATE_PLAYING } manager.getActiveSessions(listener).filter { it.playbackState?.state in PLAYING_STATES }
} catch (e: SecurityException) { } catch (e: SecurityException) {
null null
} }
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. */
@@ -1,5 +1,6 @@
package app.seb3thehacker.gearslip.car package app.seb3thehacker.gearslip.car
import app.seb3thehacker.gearslip.car.theme.DarkStyle
import android.content.Context import android.content.Context
import app.seb3thehacker.gearslip.AppSettings import app.seb3thehacker.gearslip.AppSettings
import app.seb3thehacker.gearslip.audio.CarAudio import app.seb3thehacker.gearslip.audio.CarAudio
@@ -52,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"
@@ -64,14 +67,18 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
private const val KEY_AUTOPLAY = "car_autoplay" private const val KEY_AUTOPLAY = "car_autoplay"
private const val KEY_PLAYER_TITLE = "car_player_title" private const val KEY_PLAYER_TITLE = "car_player_title"
private const val KEY_PLAYER_LEFT = "car_player_left" private const val KEY_PLAYER_LEFT = "car_player_left"
private const val KEY_MORE_LYRICS = "car_more_lyrics"
private const val KEY_THEME = "car_app_theme" private const val KEY_THEME = "car_app_theme"
private const val KEY_UI_THEME = "car_ui_theme" private const val KEY_UI_THEME = "car_ui_theme"
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"
private const val KEY_ADAPTIVE_VIDEO = "adaptive_video_enabled" private const val KEY_ADAPTIVE_VIDEO = "adaptive_video_enabled"
private const val KEY_CLUSTER_VIDEO = "cluster_video_enabled"
private const val KEY_DEBUG_BADGE = "debug_badge" private const val KEY_DEBUG_BADGE = "debug_badge"
private const val KEY_SEEN_SAFETY_WARNING = "car_seen_safety_warning" private const val KEY_SEEN_SAFETY_WARNING = "car_seen_safety_warning"
private const val KEY_UNITS = "car_units" private const val KEY_UNITS = "car_units"
@@ -93,10 +100,12 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
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)
@@ -104,18 +113,25 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
private val autoplayFlow = MutableStateFlow(false) private val autoplayFlow = MutableStateFlow(false)
private val playerTitleFlow = MutableStateFlow(false) private val playerTitleFlow = MutableStateFlow(false)
private val playerLeftFlow = MutableStateFlow(false) private val playerLeftFlow = MutableStateFlow(false)
private val moreLyricsFlow = MutableStateFlow(true)
private val themeFlow = MutableStateFlow(AppTheme.PHONE) private val themeFlow = MutableStateFlow(AppTheme.PHONE)
val appTheme: StateFlow<AppTheme> = themeFlow val appTheme: StateFlow<AppTheme> = themeFlow
private val darkStyleFlow = MutableStateFlow(DarkStyle.BLACK)
/** How dark the car's dark mode goes; black until the driver picks another. */
val darkStyle: StateFlow<DarkStyle> = darkStyleFlow
private val uiThemeFlow = MutableStateFlow(GsThemes.default.id) private val uiThemeFlow = MutableStateFlow(GsThemes.default.id)
/** Id of the [GsTheme] controls are drawn in; an unknown id falls back to the default. */ /** Id of the [GsTheme] controls are drawn in; an unknown id falls back to the default. */
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)
private val adaptiveVideoFlow = MutableStateFlow(false) private val adaptiveVideoFlow = MutableStateFlow(false)
private val clusterVideoFlow = MutableStateFlow(false)
private val debugBadgeFlow = MutableStateFlow(true) private val debugBadgeFlow = MutableStateFlow(true)
private val seenSafetyWarningFlow = MutableStateFlow(false) private val seenSafetyWarningFlow = MutableStateFlow(false)
private val unitsFlow = MutableStateFlow(Units.AUTO) private val unitsFlow = MutableStateFlow(Units.AUTO)
@@ -208,6 +224,9 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
/** 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
@@ -224,7 +243,20 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
/** The home screen's player sits left of the map instead of right of it. */ /** The home screen's player sits left of the map instead of right of it. */
val playerLeft: StateFlow<Boolean> = playerLeftFlow val playerLeft: StateFlow<Boolean> = playerLeftFlow
/** Lyrics from BetterLyrics and KuGou as well as LRCLIB; off sends song details to LRCLIB only. */
val moreLyrics: StateFlow<Boolean> = moreLyricsFlow
val scale: StateFlow<Float> = scaleFlow 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
@@ -240,6 +272,9 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
/** 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
@@ -282,6 +317,13 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
*/ */
val adaptiveVideoEnabled: StateFlow<Boolean> = adaptiveVideoFlow val adaptiveVideoEnabled: StateFlow<Boolean> = adaptiveVideoFlow
/**
* Gearslip's turn card on a car's instrument-cluster screen, for a car that offers one as a
* second video sink. Applies only while [experimentalFeaturesEnabled] is on too, and from the
* next connection; untested in a car so far.
*/
val clusterVideoEnabled: StateFlow<Boolean> = clusterVideoFlow
/** Debug builds only: the car screen's DEBUG BUILD badge, which screenshots for the README leave out. */ /** Debug builds only: the car screen's DEBUG BUILD badge, which screenshots for the README leave out. */
val debugBadge: StateFlow<Boolean> = debugBadgeFlow val debugBadge: StateFlow<Boolean> = debugBadgeFlow
@@ -306,20 +348,26 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
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 }
themeFlow.value = runCatching { themeFlow.value = runCatching {
AppTheme.valueOf(AppSettings.getString(app, KEY_THEME, AppTheme.PHONE.name)) AppTheme.valueOf(AppSettings.getString(app, KEY_THEME, AppTheme.PHONE.name))
}.getOrDefault(AppTheme.PHONE) }.getOrDefault(AppTheme.PHONE)
darkStyleFlow.value = runCatching {
DarkStyle.valueOf(AppSettings.getString(app, KEY_DARK_STYLE, DarkStyle.BLACK.name))
}.getOrDefault(DarkStyle.BLACK)
uiThemeFlow.value = AppSettings.getString(app, KEY_UI_THEME, GsThemes.default.id) uiThemeFlow.value = AppSettings.getString(app, KEY_UI_THEME, GsThemes.default.id)
autoplayFlow.value = AppSettings.getString(app, KEY_AUTOPLAY, "false") == "true" autoplayFlow.value = AppSettings.getString(app, KEY_AUTOPLAY, "false") == "true"
playerTitleFlow.value = AppSettings.getString(app, KEY_PLAYER_TITLE, "false") == "true" playerTitleFlow.value = AppSettings.getString(app, KEY_PLAYER_TITLE, "false") == "true"
playerLeftFlow.value = AppSettings.getString(app, KEY_PLAYER_LEFT, "false") == "true" playerLeftFlow.value = AppSettings.getString(app, KEY_PLAYER_LEFT, "false") == "true"
moreLyricsFlow.value = AppSettings.getString(app, KEY_MORE_LYRICS, "true") == "true"
pipeAudioFlow.value = AppSettings.getString(app, KEY_PIPE_AUDIO, "true") == "true" pipeAudioFlow.value = AppSettings.getString(app, KEY_PIPE_AUDIO, "true") == "true"
// Until the driver sets it, on for USB audio and off for Bluetooth, where the map app's // 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 {
@@ -329,10 +377,13 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
.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"
adaptiveVideoFlow.value = AppSettings.getString(app, KEY_ADAPTIVE_VIDEO, "false") == "true" adaptiveVideoFlow.value = AppSettings.getString(app, KEY_ADAPTIVE_VIDEO, "false") == "true"
clusterVideoFlow.value = AppSettings.getString(app, KEY_CLUSTER_VIDEO, "false") == "true"
debugBadgeFlow.value = AppSettings.getString(app, KEY_DEBUG_BADGE, "true") == "true" debugBadgeFlow.value = AppSettings.getString(app, KEY_DEBUG_BADGE, "true") == "true"
seenSafetyWarningFlow.value = AppSettings.getString(app, KEY_SEEN_SAFETY_WARNING, "false") == "true" seenSafetyWarningFlow.value = AppSettings.getString(app, KEY_SEEN_SAFETY_WARNING, "false") == "true"
unitsFlow.value = runCatching { unitsFlow.value = runCatching {
@@ -358,6 +409,11 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
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)
@@ -378,6 +434,11 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
AppSettings.putString(app, KEY_THEME, theme.name) AppSettings.putString(app, KEY_THEME, theme.name)
} }
fun setDarkStyle(style: DarkStyle) {
darkStyleFlow.value = style
AppSettings.putString(app, KEY_DARK_STYLE, style.name)
}
fun setUiTheme(id: String) { fun setUiTheme(id: String) {
uiThemeFlow.value = id uiThemeFlow.value = id
AppSettings.putString(app, KEY_UI_THEME, id) AppSettings.putString(app, KEY_UI_THEME, id)
@@ -393,6 +454,11 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
AppSettings.putString(app, KEY_PLAYER_LEFT, on.toString()) AppSettings.putString(app, KEY_PLAYER_LEFT, on.toString())
} }
fun setMoreLyrics(on: Boolean) {
moreLyricsFlow.value = on
AppSettings.putString(app, KEY_MORE_LYRICS, on.toString())
}
fun setAutoplay(on: Boolean) { fun setAutoplay(on: Boolean) {
autoplayFlow.value = on autoplayFlow.value = on
AppSettings.putString(app, KEY_AUTOPLAY, on.toString()) AppSettings.putString(app, KEY_AUTOPLAY, on.toString())
@@ -416,6 +482,11 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
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())
@@ -441,6 +512,11 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
AppSettings.putString(app, KEY_ADAPTIVE_VIDEO, on.toString()) AppSettings.putString(app, KEY_ADAPTIVE_VIDEO, on.toString())
} }
fun setClusterVideoEnabled(on: Boolean) {
clusterVideoFlow.value = on
AppSettings.putString(app, KEY_CLUSTER_VIDEO, on.toString())
}
fun setCarSensorsEnabled(on: Boolean) { fun setCarSensorsEnabled(on: Boolean) {
carSensorsFlow.value = on carSensorsFlow.value = on
AppSettings.putString(app, KEY_CAR_SENSORS, on.toString()) AppSettings.putString(app, KEY_CAR_SENSORS, on.toString())
@@ -465,6 +541,12 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
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(","))
@@ -1,6 +1,9 @@
package app.seb3thehacker.gearslip.car package app.seb3thehacker.gearslip.car
import androidx.compose.ui.res.stringResource
import app.seb3thehacker.gearslip.R
import app.seb3thehacker.gearslip.car.theme.DarkStyle
import app.seb3thehacker.gearslip.car.theme.* import app.seb3thehacker.gearslip.car.theme.*
import android.os.Build import android.os.Build
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -8,6 +11,9 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.graphics.Color
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
@@ -52,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()
@@ -60,16 +67,20 @@ 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()
val playerTitle by CarSettings.playerTitle.collectAsState() val playerTitle by CarSettings.playerTitle.collectAsState()
val playerLeft by CarSettings.playerLeft.collectAsState() val playerLeft by CarSettings.playerLeft.collectAsState()
val moreLyrics by CarSettings.moreLyrics.collectAsState()
val theme by CarSettings.appTheme.collectAsState() val theme by CarSettings.appTheme.collectAsState()
val uiTheme by CarSettings.uiTheme.collectAsState() val uiTheme by CarSettings.uiTheme.collectAsState()
val darkStyle by CarSettings.darkStyle.collectAsState()
val experimentalFeatures by CarSettings.experimentalFeaturesEnabled.collectAsState() val experimentalFeatures by CarSettings.experimentalFeaturesEnabled.collectAsState()
val carSensors by CarSettings.carSensorsEnabled.collectAsState() val carSensors by CarSettings.carSensorsEnabled.collectAsState()
val adaptiveVideo by CarSettings.adaptiveVideoEnabled.collectAsState() val adaptiveVideo by CarSettings.adaptiveVideoEnabled.collectAsState()
val clusterVideo by CarSettings.clusterVideoEnabled.collectAsState()
val systemSpeech by CarSettings.systemSpeech.collectAsState() val systemSpeech by CarSettings.systemSpeech.collectAsState()
val display by CarEnvironment.display.collectAsState() val display by CarEnvironment.display.collectAsState()
val vehicle by CarEnvironment.vehicle.collectAsState() val vehicle by CarEnvironment.vehicle.collectAsState()
@@ -78,232 +89,275 @@ fun CarSettingsScreen() {
value = withContext(Dispatchers.Default) { CertSummary.read(context) } value = withContext(Dispatchers.Default) { CertSummary.read(context) }
} }
LazyColumn( // A rail of the groups down the left, and the chosen group's settings beside it: five short
Modifier.fillMaxSize(), // lists instead of one long scroll.
// Built as it scrolls into view rather than all at once: every row here is a set of var chosen by androidx.compose.runtime.saveable.rememberSaveable { androidx.compose.runtime.mutableIntStateOf(0) }
// buttons, and building the whole screen made opening Settings visibly slow. val listState = androidx.compose.foundation.lazy.rememberLazyListState()
contentPadding = PaddingValues(horizontal = 24.dp, vertical = 16.dp), androidx.compose.runtime.LaunchedEffect(chosen) { listState.scrollToItem(0) }
verticalArrangement = Arrangement.spacedBy(20.dp), Row(Modifier.fillMaxSize().padding(start = 16.dp, top = 12.dp)) {
) { SettingsRail(listOf(stringResource(R.string.car_group_screen), stringResource(R.string.car_group_nav_bar), stringResource(R.string.car_group_sound), stringResource(R.string.car_group_warnings), stringResource(R.string.car_group_more)), chosen) { chosen = it }
item { LazyColumn(
ScreenHeader("Settings") Modifier.weight(1f).fillMaxHeight(),
} state = listState,
// Built as it scrolls into view rather than all at once: every row here is a set of
// buttons, and building the whole screen made opening Settings visibly slow.
contentPadding = PaddingValues(horizontal = 24.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
var section: Int
section = 0
if (section == chosen) item { Group(stringResource(R.string.car_group_screen)) }
item { Group("Screen") } if (section == chosen) item {
ChoiceRow(
item { stringResource(R.string.car_open_on_connect),
ChoiceRow( listOf(CarSettings.HOME to stringResource(R.string.choice_open_home)) + CarApps.shown(launcher, LauncherMode.canCall(context)).map { it.id to stringResource(it.labelRes) },
"Open on connect", open,
listOf(CarSettings.HOME to "Home") + CarApps.all.filter { it.id != "settings" }.map { it.id to it.label }, ) { CarSettings.setOpenOnConnect(it) }
open,
) { CarSettings.setOpenOnConnect(it) }
}
item {
ChoiceRow(
"Size",
listOf(1f to "Normal", 1.25f to "Large", 1.5f to "Extra large"),
scale,
) { CarSettings.setScale(it) }
}
item {
ChoiceRow(
"App theme",
listOf(AppTheme.PHONE to "Follow phone", AppTheme.LIGHT to "Light", AppTheme.DARK to "Dark"),
theme,
) { CarSettings.setAppTheme(it) }
}
item {
ChoiceRow(
"Theme",
GsThemes.builtIn.map { it.id to it.name },
uiTheme,
) { CarSettings.setUiTheme(it) }
}
item {
ChoiceRow(
"Map day/night",
listOf(NightMode.AUTO to "Auto", NightMode.DAY to "Day", NightMode.NIGHT to "Night"),
night,
) { CarSettings.setNightMode(it) }
}
item {
ChoiceRow(
"Units",
listOf(Units.AUTO to "Auto", Units.IMPERIAL to "Imperial (mi, ft, °F)", Units.METRIC to "Metric (km, m, °C)"),
units,
) { CarSettings.setUnits(it) }
}
// For a head unit that reports the wrong size and stretches the picture (issue #12).
item {
ChoiceRow(
"Screen size (next connection)",
listOf<ScreenSize?>(null).plus(SCREEN_PRESETS).plus(screenSize).distinct()
.map { it to (it?.toString() ?: "From the car") },
screenSize,
) { CarSettings.setScreenSize(it) }
}
if (screenSize != null) item {
ChoiceRow(
"Fit to the screen",
listOf(ScreenFit.CROP to "Crop the edges", ScreenFit.SQUASH to "Squash to fit"),
screenFit,
) { CarSettings.setScreenFit(it) }
}
item { Group("Nav bar and player") }
item {
ChoiceRow(
"Nav bar",
listOf(
NavBarMode.SHOW to "Always show",
NavBarMode.HIDE_NAVIGATING to "Hide while navigating",
NavBarMode.HIDE to "Hide until tapped",
),
navBar,
) { CarSettings.setNavBarMode(it) }
}
item {
ChoiceRow(
"Player in the nav bar",
listOf(false to "Skip buttons", true to "Song title"),
playerTitle,
) { CarSettings.setPlayerTitle(it) }
}
item {
ChoiceRow(
"Player beside the map",
listOf(false to "Right", true to "Left"),
playerLeft,
) { CarSettings.setPlayerLeft(it) }
}
item {
ChoiceRow(
"Autoplay on connect",
listOf(false to "Off", true to "On"),
autoplay,
) { CarSettings.setAutoplay(it) }
}
item { Group("Sound and voice") }
item {
ChoiceRow(
"Car audio",
listOf(false to "Bluetooth", true to "USB"),
pipeAudio,
) { CarSettings.setPipeAudio(it) }
}
item {
Text(
"USB sends the phone's sound through Gearslip. 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.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
item { Group("Warnings") }
if (!pipeAudio) item {
ChoiceRow(
"Warn when Bluetooth isn't connected",
listOf(false to "Off", true to "On"),
bluetoothWarning,
) { CarSettings.setBluetoothWarning(it) }
}
item {
ChoiceRow(
"Warn when location is off",
listOf(false to "Off", true to "On"),
locationWarning,
) { CarSettings.setLocationWarning(it) }
}
item {
ChoiceRow(
"Spoken directions",
listOf(false to "Off", true to "On"),
speakDirections,
) { CarSettings.setSpeakDirections(it) }
}
item {
Text(
"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.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
item {
ExperimentalFeatureRow(
title = "Use the phone's speech recognition",
description = "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.",
checked = systemSpeech,
) { CarSettings.setSystemSpeech(it) }
}
item { Group("More") }
item {
WebPageSetting()
}
item {
ScreenFit(vehicle)
}
item {
ChoiceRow(
"Experimental features",
listOf(false to "Hide", true to "Show"),
experimentalFeatures,
) { CarSettings.setExperimentalFeaturesEnabled(it) }
}
if (experimentalFeatures) item {
ExperimentalFeaturesList(carSensors, adaptiveVideo)
}
item {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Connection", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
Info("Phone", "${Build.MANUFACTURER} ${Build.MODEL}")
Info("Vehicle", vehicle.ifEmpty { "-" })
Info("Video", display.ifEmpty { "-" })
Info("Certificate", cert?.headline ?: "Checking…")
} }
}
item { if (section == chosen) item {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { ChoiceRow(
Text("About", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) stringResource(R.string.car_size),
Info("Version", app.seb3thehacker.gearslip.BuildConfig.VERSION_NAME) 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)),
Info("Build", app.seb3thehacker.gearslip.BuildConfig.VERSION_CODE.toString()) scale,
) { CarSettings.setScale(it) }
}
if (section == chosen) item {
ChoiceRow(
stringResource(R.string.car_app_theme),
listOf(AppTheme.PHONE to stringResource(R.string.choice_theme_phone), AppTheme.LIGHT to stringResource(R.string.choice_theme_light), AppTheme.DARK to stringResource(R.string.choice_theme_dark)),
theme,
) { CarSettings.setAppTheme(it) }
}
if (section == chosen && theme != AppTheme.LIGHT) item {
ChoiceRow(
stringResource(R.string.car_dark_mode),
listOf(
DarkStyle.BLACK to stringResource(R.string.choice_dark_black),
DarkStyle.BLACK_COLOUR to stringResource(R.string.choice_dark_black_colour),
DarkStyle.DEEP_COLOUR to stringResource(R.string.choice_dark_deep_colour),
DarkStyle.GREY to stringResource(R.string.choice_dark_grey),
),
darkStyle,
) { CarSettings.setDarkStyle(it) }
}
if (section == chosen) item {
ChoiceRow(
stringResource(R.string.carset_theme),
GsThemes.builtIn.map { it.id to it.name },
uiTheme,
) { CarSettings.setUiTheme(it) }
}
if (section == chosen) item {
ChoiceRow(
stringResource(R.string.car_map_day_night),
listOf(NightMode.AUTO to stringResource(R.string.choice_auto), NightMode.DAY to stringResource(R.string.choice_night_day), NightMode.NIGHT to stringResource(R.string.choice_night_night)),
night,
) { CarSettings.setNightMode(it) }
}
if (section == chosen) item {
ChoiceRow(
stringResource(R.string.car_units),
listOf(Units.AUTO to stringResource(R.string.choice_auto), Units.IMPERIAL to stringResource(R.string.carset_imperial), Units.METRIC to stringResource(R.string.carset_metric)),
units,
) { CarSettings.setUnits(it) }
}
// For a head unit that reports the wrong size and stretches the picture (issue #12).
if (section == chosen) item {
ChoiceRow(
stringResource(R.string.carset_screen_size),
listOf<ScreenSize?>(null).plus(SCREEN_PRESETS).plus(screenSize).distinct()
.map { it to (it?.toString() ?: stringResource(R.string.screen_from_car)) },
screenSize,
) { CarSettings.setScreenSize(it) }
}
if (section == chosen && screenSize != null) item {
ChoiceRow(
stringResource(R.string.carset_fit),
listOf(ScreenFit.CROP to stringResource(R.string.screen_crop), ScreenFit.SQUASH to stringResource(R.string.screen_squash)),
screenFit,
) { CarSettings.setScreenFit(it) }
}
section = 1
if (section == chosen) item { Group(stringResource(R.string.car_group_nav_bar)) }
if (section == chosen) item {
ChoiceRow(
stringResource(R.string.car_nav_bar),
listOf(
NavBarMode.SHOW to stringResource(R.string.choice_nav_show),
NavBarMode.HIDE_NAVIGATING to stringResource(R.string.choice_nav_hide_navigating),
NavBarMode.HIDE to stringResource(R.string.choice_nav_hide),
),
navBar,
) { CarSettings.setNavBarMode(it) }
}
if (section == chosen) item {
ChoiceRow(
stringResource(R.string.car_player_nav_bar),
listOf(false to stringResource(R.string.car_skip_buttons), true to stringResource(R.string.car_song_title)),
playerTitle,
) { CarSettings.setPlayerTitle(it) }
}
if (section == chosen) item {
ChoiceRow(
stringResource(R.string.car_player_beside_map),
listOf(false to stringResource(R.string.car_right), true to stringResource(R.string.car_left)),
playerLeft,
) { CarSettings.setPlayerLeft(it) }
}
if (section == chosen) item {
ChoiceRow(
stringResource(R.string.car_autoplay),
listOf(false to stringResource(R.string.off), true to stringResource(R.string.on)),
autoplay,
) { CarSettings.setAutoplay(it) }
}
if (section == chosen) item {
ChoiceRow(
stringResource(R.string.carset_lyrics_from),
listOf(false to "LRCLIB", true to stringResource(R.string.carset_more_sources)),
moreLyrics,
) { CarSettings.setMoreLyrics(it) }
}
section = 2
if (section == chosen) item { Group(stringResource(R.string.car_group_sound)) }
if (section == chosen) item {
ChoiceRow(
stringResource(R.string.car_audio),
listOf(false to stringResource(R.string.guide_audio_bluetooth), true to stringResource(R.string.guide_audio_usb)),
pipeAudio,
) { CarSettings.setPipeAudio(it) }
}
if (section == chosen) item {
Text(
stringResource(R.string.carset_audio_note),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
section = 3
if (section == chosen) item { Group(stringResource(R.string.car_group_warnings)) }
if (section == chosen && !pipeAudio) item {
ChoiceRow(
stringResource(R.string.car_warn_bluetooth),
listOf(false to stringResource(R.string.off), true to stringResource(R.string.on)),
bluetoothWarning,
) { CarSettings.setBluetoothWarning(it) }
}
if (section == chosen) item {
ChoiceRow(
stringResource(R.string.car_warn_location),
listOf(false to stringResource(R.string.off), true to stringResource(R.string.on)),
locationWarning,
) { CarSettings.setLocationWarning(it) }
}
if (section == chosen) item {
ChoiceRow(
stringResource(R.string.car_spoken_directions),
listOf(false to stringResource(R.string.off), true to stringResource(R.string.on)),
speakDirections,
) { CarSettings.setSpeakDirections(it) }
}
if (section == chosen) item {
Text(
stringResource(R.string.carset_directions_note),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (section == chosen && speakDirections) item {
ChoiceRow(
stringResource(R.string.car_directions_over_music),
listOf(false to stringResource(R.string.off), true to stringResource(R.string.on)),
directionsOverMusic,
) { CarSettings.setDirectionsOverMusic(it) }
}
if (section == chosen && speakDirections) item {
Text(
stringResource(R.string.car_directions_over_music_note),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (section == chosen) item {
ExperimentalFeatureRow( ExperimentalFeatureRow(
"What's new after updates", title = stringResource(R.string.car_phone_speech),
"Show the list of changes once, the first time the car connects after an update.", description = stringResource(R.string.carset_phone_speech_note),
whatsNew, checked = systemSpeech,
) { CarSettings.setWhatsNewAfterUpdates(it) } ) { CarSettings.setSystemSpeech(it) }
GsButton(onClick = CarSettings::openWhatsNew, tone = GsTone.Tonal, modifier = Modifier.height(56.dp)) { }
Text("What's new", style = MaterialTheme.typography.titleMedium)
section = 4
if (section == chosen) item { Group(stringResource(R.string.car_group_more)) }
if (section == chosen) item {
WebPageSetting()
}
if (section == chosen) item {
ScreenFit(vehicle)
}
if (section == chosen) item {
ChoiceRow(
stringResource(R.string.car_experimental),
listOf(false to stringResource(R.string.carset_hide), true to stringResource(R.string.carset_show)),
experimentalFeatures,
) { CarSettings.setExperimentalFeaturesEnabled(it) }
}
if (section == chosen && experimentalFeatures) item {
ExperimentalFeaturesList(carSensors, adaptiveVideo, clusterVideo)
}
if (section == chosen) item {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(stringResource(R.string.settings_connection), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
Info(stringResource(R.string.carset_phone), "${Build.MANUFACTURER} ${Build.MODEL}")
Info(stringResource(R.string.carset_vehicle), vehicle.ifEmpty { "-" })
Info(stringResource(R.string.carset_video), display.ifEmpty { "-" })
Info(stringResource(R.string.settings_certificate), cert?.headline ?: stringResource(R.string.cert_checking))
}
}
if (section == chosen) item {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(stringResource(R.string.settings_about), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
Info(stringResource(R.string.carset_version), app.seb3thehacker.gearslip.BuildConfig.VERSION_NAME)
Info(stringResource(R.string.carset_build), app.seb3thehacker.gearslip.BuildConfig.VERSION_CODE.toString())
ExperimentalFeatureRow(
stringResource(R.string.carset_whats_new_after),
stringResource(R.string.carset_whats_new_after_sub),
whatsNew,
) { CarSettings.setWhatsNewAfterUpdates(it) }
GsButton(onClick = CarSettings::openWhatsNew, tone = GsTone.Tonal, modifier = Modifier.height(56.dp)) {
Text(stringResource(R.string.settings_whats_new), style = MaterialTheme.typography.titleMedium)
}
} }
} }
} }
@@ -328,6 +382,47 @@ private fun <T> ChoiceRow(title: String, options: List<Pair<T, String>>, selecte
} }
} }
/** The groups, with the chosen one lit: a card down the left of Settings. */
@Composable
private fun SettingsRail(groups: List<String>, chosen: Int, onChoose: (Int) -> Unit) {
val scheme = MaterialTheme.colorScheme
val shape = RoundedCornerShape(24.dp)
Surface(
Modifier.width(250.dp).padding(bottom = 12.dp).fillMaxHeight().gsPanelShadow(shape),
shape = shape,
color = scheme.surfaceContainer,
) {
Column(
Modifier.padding(10.dp).verticalScroll(androidx.compose.foundation.rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
stringResource(R.string.settings_title),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(start = 14.dp, top = 6.dp, bottom = 10.dp),
)
groups.forEachIndexed { i, name ->
val on = i == chosen
GsIconBox(
onClick = { onChoose(i) },
modifier = Modifier.fillMaxWidth().height(56.dp),
colors = if (on) GsColors(scheme.primaryContainer, scheme.onPrimaryContainer)
else GsColors(Color.Transparent, scheme.onSurface),
latched = on,
) {
Text(
name,
style = MaterialTheme.typography.titleMedium,
fontWeight = if (on) FontWeight.Bold else FontWeight.Normal,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
)
}
}
}
}
}
/** A group of settings: a heading, so a long list reads as a few short ones. */ /** A group of settings: a heading, so a long list reads as a few short ones. */
@Composable @Composable
private fun Group(title: String) { private fun Group(title: String) {
@@ -369,25 +464,26 @@ private fun <T> ChoiceButtons(options: List<Pair<T, String>>, selected: T, onSel
* so the list reads fine whether there's one entry or several. * so the list reads fine whether there's one entry or several.
*/ */
@Composable @Composable
private fun ExperimentalFeaturesList(carSensors: Boolean, adaptiveVideo: Boolean) { private fun ExperimentalFeaturesList(carSensors: Boolean, adaptiveVideo: Boolean, clusterVideo: Boolean) {
Column( Column(
Modifier.fillMaxWidth(), Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(10.dp), verticalArrangement = Arrangement.spacedBy(10.dp),
) { ) {
ExperimentalFeatureRow( ExperimentalFeatureRow(
title = "Vehicle data", title = stringResource(R.string.car_vehicle_data),
description = "Adds a Vehicle data tile: a live, raw dump of the head unit's sensor " + description = stringResource(R.string.carset_vehicle_data_note),
"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.",
checked = carSensors, checked = carSensors,
) { CarSettings.setCarSensorsEnabled(it) } ) { CarSettings.setCarSensorsEnabled(it) }
ExperimentalFeatureRow( ExperimentalFeatureRow(
title = "Adaptive video quality", title = stringResource(R.string.car_adaptive_video),
description = "Lowers the picture's quality when the car, or its Wi-Fi, can't keep " + description = stringResource(R.string.carset_adaptive_note),
"up, and raises it again once it can. Takes effect on the next connection.",
checked = adaptiveVideo, checked = adaptiveVideo,
) { CarSettings.setAdaptiveVideoEnabled(it) } ) { CarSettings.setAdaptiveVideoEnabled(it) }
ExperimentalFeatureRow(
title = stringResource(R.string.car_cluster_video),
description = stringResource(R.string.car_cluster_video_note),
checked = clusterVideo,
) { CarSettings.setClusterVideoEnabled(it) }
} }
} }
@@ -433,7 +529,7 @@ private fun WebPageSetting() {
var url by remember { mutableStateOf(AppSettings.startupUrl(context)) } var url by remember { mutableStateOf(AppSettings.startupUrl(context)) }
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Web page", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) Text(stringResource(R.string.web_page), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
Surface( Surface(
onClick = { editing = !editing }, onClick = { editing = !editing },
shape = MaterialTheme.shapes.large, shape = MaterialTheme.shapes.large,
@@ -441,7 +537,7 @@ private fun WebPageSetting() {
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
Text( Text(
url.ifEmpty { "DuckDuckGo (default) - tap to set a different page" }, url.ifEmpty { stringResource(R.string.carset_web_default) },
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = if (url.isEmpty()) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface, color = if (url.isEmpty()) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface,
maxLines = 1, maxLines = 1,
@@ -450,7 +546,7 @@ private fun WebPageSetting() {
) )
} }
Text( Text(
"Shown by the Browser app on the launcher. Applies on the next connection.", stringResource(R.string.carset_web_note),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@@ -493,22 +589,22 @@ private fun ScreenFit(vehicle: String) {
} }
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Screen fit", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) Text(stringResource(R.string.carset_screen_fit), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
Stepper("Top", insets.top) { change(insets.copy(top = (insets.top + it).coerceAtLeast(0))) } Stepper(stringResource(R.string.carset_top), insets.top) { change(insets.copy(top = (insets.top + it).coerceAtLeast(0))) }
Stepper("Bottom", insets.bottom) { change(insets.copy(bottom = (insets.bottom + it).coerceAtLeast(0))) } Stepper(stringResource(R.string.carset_bottom), insets.bottom) { change(insets.copy(bottom = (insets.bottom + it).coerceAtLeast(0))) }
Text( Text(
"Usable height ${480 - insets.top - insets.bottom}px of 480", stringResource(R.string.carset_usable_height, 480 - insets.top - insets.bottom),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
GsButton( GsButton(
onClick = { onClick = {
val ok = app.seb3thehacker.gearslip.VehicleProfiles.saveInsets(context, vehicle, insets) val ok = app.seb3thehacker.gearslip.VehicleProfiles.saveInsets(context, vehicle, insets)
saved = if (ok) "Saved to $vehicle" else "Could not save" saved = if (ok) context.getString(R.string.carset_saved_to, vehicle) else context.getString(R.string.carset_save_failed)
}, },
enabled = known, enabled = known,
modifier = Modifier.fillMaxWidth().height(56.dp), modifier = Modifier.fillMaxWidth().height(56.dp),
) { Text(if (known) "Save for this vehicle" else "No vehicle profile matched") } ) { Text(if (known) stringResource(R.string.carset_save_vehicle) else stringResource(R.string.carset_no_profile)) }
saved?.let { Text(it, style = MaterialTheme.typography.bodyMedium) } saved?.let { Text(it, style = MaterialTheme.typography.bodyMedium) }
} }
} }
@@ -520,7 +616,7 @@ private fun Stepper(label: String, value: Int, onStep: (Int) -> Unit) {
StepButton("−5", Modifier.weight(1f)) { onStep(-5) } StepButton("−5", Modifier.weight(1f)) { onStep(-5) }
StepButton("−1", Modifier.weight(1f)) { onStep(-1) } StepButton("−1", Modifier.weight(1f)) { onStep(-1) }
Text( Text(
"${value}px", stringResource(R.string.carset_px, value),
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
modifier = Modifier.weight(1.3f), modifier = Modifier.weight(1.3f),
textAlign = androidx.compose.ui.text.style.TextAlign.Center, textAlign = androidx.compose.ui.text.style.TextAlign.Center,
@@ -1,5 +1,6 @@
package app.seb3thehacker.gearslip.car package app.seb3thehacker.gearslip.car
import androidx.compose.ui.res.stringResource
import androidx.compose.material.icons.filled.Call import androidx.compose.material.icons.filled.Call
import app.seb3thehacker.gearslip.call.CarCalls import app.seb3thehacker.gearslip.call.CarCalls
import app.seb3thehacker.gearslip.call.CallUiState import app.seb3thehacker.gearslip.call.CallUiState
@@ -62,6 +63,8 @@ import androidx.compose.runtime.setValue
import androidx.compose.animation.animateContentSize import androidx.compose.animation.animateContentSize
import android.media.session.PlaybackState import android.media.session.PlaybackState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@@ -110,6 +113,7 @@ fun CarUi() {
val phoneDark by CarEnvironment.phoneDark.collectAsState() val phoneDark by CarEnvironment.phoneDark.collectAsState()
val appTheme by CarSettings.appTheme.collectAsState() val appTheme by CarSettings.appTheme.collectAsState()
val uiThemeId by CarSettings.uiTheme.collectAsState() val uiThemeId by CarSettings.uiTheme.collectAsState()
val darkStyle by CarSettings.darkStyle.collectAsState()
val mediaApp by CarServices.mediaApp.collectAsState() val mediaApp by CarServices.mediaApp.collectAsState()
val darkOutside by CarEnvironment.darkOutside.collectAsState() val darkOutside by CarEnvironment.darkOutside.collectAsState()
val appContext = LocalContext.current.applicationContext val appContext = LocalContext.current.applicationContext
@@ -118,10 +122,15 @@ fun CarUi() {
AppTheme.LIGHT -> false AppTheme.LIGHT -> false
AppTheme.DARK -> true AppTheme.DARK -> true
} }
val colors = (if (dark) dynamicDarkColorScheme(appContext) else dynamicLightColorScheme(appContext)).withTintedSurfaces(dark) 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) {
@@ -222,8 +231,10 @@ fun CarUi() {
}, },
color = MaterialTheme.colorScheme.background, color = MaterialTheme.colorScheme.background,
) { ) {
// Insets are in video-frame pixels, so convert with the un-scaled density. // Insets are video-frame pixels. Resolve them with the live (size-scaled) density,
val pad = with(base) { // which padding() also uses, so the safe-area margin stays one fixed physical size
// at every size setting instead of growing with it and eating the screen's sides.
val pad = with(LocalDensity.current) {
PaddingValues( PaddingValues(
start = insets.left.toDp(), top = insets.top.toDp(), start = insets.left.toDp(), top = insets.top.toDp(),
end = insets.right.toDp(), bottom = insets.bottom.toDp(), end = insets.right.toDp(), bottom = insets.bottom.toDp(),
@@ -232,12 +243,11 @@ fun CarUi() {
val screen = navigator.current val screen = navigator.current
val safetyWarningSeen by CarSettings.hasSeenSafetyWarning.collectAsState() val safetyWarningSeen by CarSettings.hasSeenSafetyWarning.collectAsState()
val keyboardVisible by CarKeyboardVisibility.state val keyboardVisible by CarKeyboardVisibility.state
// Off "Always show", the bar floats over the map rather than taking a row of its // The bar always lies over the map and every other screen rather than taking a row
// own: hiding it then leaves the map's surface the size it was. Resizing it would // of its own: hiding it then leaves the map's surface the size it was.
// hand the map app a new surface each time, and some go black on that. // Resizing it would hand the map app a new surface each time, and some go black on that.
val navBarMode by CarSettings.navBarMode.collectAsState() val navBarMode by CarSettings.navBarMode.collectAsState()
val appTemplate by CarServices.nav.template.collectAsState() val appTemplate by CarServices.nav.template.collectAsState()
val floatingBar = navBarMode != NavBarMode.SHOW
val barMayHide = screen == CarScreen.Home && when (navBarMode) { val barMayHide = screen == CarScreen.Home && when (navBarMode) {
NavBarMode.SHOW -> false NavBarMode.SHOW -> false
NavBarMode.HIDE_NAVIGATING -> appTemplate is androidx.car.app.navigation.model.NavigationTemplate NavBarMode.HIDE_NAVIGATING -> appTemplate is androidx.car.app.navigation.model.NavigationTemplate
@@ -248,7 +258,8 @@ fun CarUi() {
// A call keeps the bar up whatever the setting: End and Mute live on it. // A call keeps the bar up whatever the setting: End and Mute live on it.
val onCall = CarCalls.state.collectAsState().value is CallUiState.Active val onCall = CarCalls.state.collectAsState().value is CallUiState.Active
val barShown = !keyboardVisible && (!barMayHide || controlsWake.navBarAwake || onCall) val barShown = !keyboardVisible && (!barMayHide || controlsWake.navBarAwake || onCall)
val homeInset = if (floatingBar && barShown) NAV_BAR_HEIGHT else 0.dp // Up to the bar's top edge, so the map's cards and the player end above it.
val homeInset = if (barShown) NAV_BAR_HEIGHT else 0.dp
// Only the screen that asked: going Home or anywhere else ends it. // Only the screen that asked: going Home or anywhere else ends it.
val fullscreen = CarFullscreen.active && screen != CarScreen.Home val fullscreen = CarFullscreen.active && screen != CarScreen.Home
Box(Modifier.fillMaxSize().padding(pad)) { Box(Modifier.fillMaxSize().padding(pad)) {
@@ -268,43 +279,58 @@ fun CarUi() {
// The keyboard takes the nav bar's row instead of squeezing in above it - // The keyboard takes the nav bar's row instead of squeezing in above it -
// nothing on the nav bar is useful mid-type, and the screen is too small to // nothing on the nav bar is useful mid-type, and the screen is too small to
// show both. // show both.
if (!floatingBar && !keyboardVisible) CarNavBar(navigator)
} }
// Every other screen, and everything that pops up, in a layer over Home rather // Every other screen, and everything that pops up, in a layer over Home rather
// than inside its area: full screen grows this layer over the nav bar's row while // than inside its area: full screen grows this layer over the nav bar's row while
// Home, and the map's surface in it, keeps its size. // Home, and the map's surface in it, keeps its size.
// Each new screen fades in while rising a little, once, over a fifth of a second.
// Coming from Home the backdrop fades in with it, over the map; between two other
// screens it stays put, so the map never flashes through.
val lastScreen = remember { arrayOf<CarScreen>(CarScreen.Home) }
val fromHome = remember(screen) { lastScreen[0] == CarScreen.Home }
SideEffect { lastScreen[0] = screen }
val enter = remember(screen) { androidx.compose.animation.core.Animatable(0f) }
LaunchedEffect(screen) { enter.animateTo(1f, androidx.compose.animation.core.tween(180)) }
if (safetyWarningSeen) { if (safetyWarningSeen) {
Box( Box(Modifier.fillMaxSize()) {
Modifier
.fillMaxSize()
.padding(bottom = if (!floatingBar && !keyboardVisible && !fullscreen) NAV_BAR_HEIGHT else 0.dp),
) {
if (screen != CarScreen.Home) { if (screen != CarScreen.Home) {
Box( Box(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.graphicsLayer { if (fromHome) alpha = enter.value }
.background(MaterialTheme.colorScheme.background) .background(MaterialTheme.colorScheme.background)
// The floating bar never hides here, so keep the screen clear of it. // The bar never hides here, so keep the screen clear of it. The
.padding(bottom = if (floatingBar && !keyboardVisible && !fullscreen) NAV_BAR_HEIGHT else 0.dp) // player runs on under the see-through bar and keeps clear itself.
.padding(bottom = if (!keyboardVisible && !fullscreen && screen != CarScreen.Media) NAV_BAR_HEIGHT else 0.dp)
// Keeps touches that miss this screen's controls off the map. // Keeps touches that miss this screen's controls off the map.
.pointerInput(Unit) { detectTapGestures { } }, .pointerInput(Unit) { detectTapGestures { } },
) { ) {
when (screen) { Box(
CarScreen.Home -> Unit Modifier.fillMaxSize().graphicsLayer {
CarScreen.Apps -> CarLauncher() if (!fromHome) alpha = enter.value
CarScreen.Media -> mediaApp?.let { MediaScreen(it) { navigator.back() } } translationY = (1f - enter.value) * 16.dp.toPx()
CarScreen.Browse -> BrowseAppScreen(navigator) },
CarScreen.Settings -> CarSettingsScreen() ) {
CarScreen.Dashboard -> CarDashboardScreen() when (screen) {
CarScreen.Weather -> WeatherScreen() CarScreen.Home -> Unit
CarScreen.VehicleData -> VehicleDataScreen() CarScreen.Apps -> CarLauncher()
is CarScreen.Notifications -> NotificationsScreen(screen.replyTo) CarScreen.Media -> CompositionLocalProvider(
is CarScreen.Messages -> MessagesScreen(screen.packageName) LocalNavBarInset provides if (!keyboardVisible && !fullscreen) NAV_BAR_HEIGHT else 0.dp,
is CarScreen.App -> CarApps.find(screen.id)?.content?.invoke() ) { mediaApp?.let { MediaScreen(it) { navigator.back() } } }
CarScreen.Browse -> BrowseAppScreen(navigator)
CarScreen.Settings -> CarSettingsScreen()
CarScreen.Dashboard -> CarDashboardScreen()
CarScreen.Weather -> WeatherScreen()
CarScreen.VehicleData -> VehicleDataScreen()
is CarScreen.Notifications -> NotificationsScreen(screen.replyTo)
is CarScreen.Messages -> MessagesScreen(screen.packageName)
is CarScreen.App -> CarApps.find(screen.id)?.content?.invoke()
}
} }
} }
} }
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()
@@ -325,7 +351,7 @@ fun CarUi() {
// whatever the screen drew there. // whatever the screen drew there.
DebugBuildBadge(Modifier.align(Alignment.BottomEnd).padding(bottom = homeInset)) DebugBuildBadge(Modifier.align(Alignment.BottomEnd).padding(bottom = homeInset))
} }
if (floatingBar || fullscreen) { run {
androidx.compose.animation.AnimatedVisibility( androidx.compose.animation.AnimatedVisibility(
if (fullscreen) CarFullscreen.revealed || onCall else barShown, if (fullscreen) CarFullscreen.revealed || onCall else barShown,
Modifier.align(Alignment.BottomCenter), Modifier.align(Alignment.BottomCenter),
@@ -343,7 +369,7 @@ fun CarUi() {
GsButton(onClick = CarFullscreen::exit, modifier = Modifier.height(48.dp)) { GsButton(onClick = CarFullscreen::exit, modifier = Modifier.height(48.dp)) {
Icon(MediaIcons.FullscreenExit, contentDescription = null, modifier = Modifier.size(22.dp)) Icon(MediaIcons.FullscreenExit, contentDescription = null, modifier = Modifier.size(22.dp))
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
Text("Exit full screen", style = MaterialTheme.typography.titleMedium) Text(stringResource(R.string.carui_exit_fullscreen), style = MaterialTheme.typography.titleMedium)
} }
} }
} }
@@ -388,22 +414,20 @@ private fun CarSafetyWizard(onDone: () -> Unit) {
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
Column(Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { Column(Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("Drive safely", style = ChromeType.headline, fontWeight = FontWeight.Bold) Text(stringResource(R.string.carui_drive_safely), style = ChromeType.headline, fontWeight = FontWeight.Bold)
Text( Text(
"Gearslip is unofficial software: it is not made or reviewed by any " + stringResource(R.string.carui_unofficial),
"carmaker, and it can fail without warning.",
style = ChromeType.body, style = ChromeType.body,
) )
Text( Text(
"Never interact with this screen while driving. Set the route or the " + stringResource(R.string.carui_never_interact),
"song before you go, then keep your attention on the road.",
style = ChromeType.body, style = ChromeType.body,
) )
} }
} }
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(24.dp))
GsButton(onClick = onDone, modifier = Modifier.fillMaxWidth().height(56.dp)) { GsButton(onClick = onDone, modifier = Modifier.fillMaxWidth().height(56.dp)) {
Text("I understand", style = ChromeType.body) Text(stringResource(R.string.carui_understand), style = ChromeType.body)
} }
} }
} }
@@ -434,7 +458,7 @@ private fun BrowseAppScreen(navigator: CarNavigator) {
} }
val failed = status.phase == CarAppConnection.Phase.REJECTED || status.phase == CarAppConnection.Phase.FAILED val failed = status.phase == CarAppConnection.Phase.REJECTED || status.phase == CarAppConnection.Phase.FAILED
if (failed || timedOut) { if (failed || timedOut) {
AppWontStart(status.app ?: "This app", leave) AppWontStart(status.app ?: stringResource(R.string.carui_this_app), leave)
return return
} }
CarAppStage(CarServices.browse, status, frame, onDisconnect = leave) CarAppStage(CarServices.browse, status, frame, onDisconnect = leave)
@@ -450,13 +474,13 @@ private fun AppWontStart(app: String, onBack: () -> Unit) {
verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterVertically), verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterVertically),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
Text("$app doesn't work with Gearslip yet.", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold) Text(stringResource(R.string.carui_app_unsupported, app), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
Text( Text(
"It didn't start. Some apps only run on Google's Android Auto.", stringResource(R.string.carui_app_unsupported_body),
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
GsButton(onClick = onBack, tone = GsTone.Tonal) { Text("Back") } GsButton(onClick = onBack, tone = GsTone.Tonal) { Text(stringResource(R.string.back)) }
} }
} }
@@ -1,5 +1,7 @@
package app.seb3thehacker.gearslip.car package app.seb3thehacker.gearslip.car
import androidx.compose.ui.res.stringResource
import app.seb3thehacker.gearslip.R
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
@@ -76,7 +78,7 @@ fun CarWhatsNew() {
) { ) {
Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) { Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
Text( Text(
"What's new in ${release.versionName}", stringResource(R.string.whatsnew_title, release.versionName),
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
) )
@@ -99,12 +101,12 @@ fun CarWhatsNew() {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp)) {
GsSwitch(afterUpdates, CarSettings::setWhatsNewAfterUpdates) GsSwitch(afterUpdates, CarSettings::setWhatsNewAfterUpdates)
Text( Text(
"Show after updates", stringResource(R.string.whatsnew_show_after),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
GsButton(onClick = close, modifier = Modifier.height(56.dp)) { GsButton(onClick = close, modifier = Modifier.height(56.dp)) {
Text("Got it", style = MaterialTheme.typography.titleMedium) Text(stringResource(R.string.whatsnew_got_it), style = MaterialTheme.typography.titleMedium)
} }
} }
} }
@@ -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()
@@ -0,0 +1,140 @@
package app.seb3thehacker.gearslip.car
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import app.seb3thehacker.gearslip.host.clockTime
import app.seb3thehacker.gearslip.host.display
import app.seb3thehacker.gearslip.host.remainingTime
import kotlinx.coroutines.delay
/**
* The instrument-cluster screen: the next turn, big enough to read in a glance past the wheel.
* The arrow and the distance to it, what to do there, and the time, distance and arrival left
* along the bottom. With no route it shows what's playing, quietly, or nothing.
*
* Always dark, like the dials around it. [marginX] and [marginY] are the pixels the car crops off
* the frame, kept clear.
*/
@Composable
internal fun ClusterScreen(marginX: Int, marginY: Int) {
// The map app's turn changes a few times a minute: read it once a second.
val turn by produceState<ClusterTurn?>(null) {
while (true) {
value = ClusterTurn.now()
delay(1_000)
}
}
val density = LocalDensity.current
MaterialTheme(colorScheme = darkColorScheme()) {
CompositionLocalProvider(LocalContentColor provides Color.White) {
BoxWithConstraints(
Modifier.fillMaxSize().background(Color.Black).padding(
horizontal = with(density) { (marginX / 2).toDp() } + 24.dp,
vertical = with(density) { (marginY / 2).toDp() } + 16.dp,
),
contentAlignment = Alignment.CenterStart,
) {
// Sized off the height: clusters run from short strips to near squares.
val unit = maxHeight / 12
val t = turn
if (t == null) {
NowPlayingLine(Modifier.align(Alignment.Center))
return@BoxWithConstraints
}
Column(verticalArrangement = Arrangement.spacedBy(unit * 0.4f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
CarGlyph(t.icon, Modifier.size(unit * 3.4f))
Spacer(Modifier.width(unit))
Text(
t.distance,
fontSize = with(density) { (unit * 2.2f).toSp() },
fontWeight = FontWeight.Bold,
maxLines = 1,
)
}
if (t.what.isNotEmpty()) {
Text(
t.what,
fontSize = with(density) { (unit * 1.1f).toSp() },
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
if (t.trip.isNotEmpty()) {
Text(
t.trip,
fontSize = with(density) { (unit * 0.85f).toSp() },
color = Color.White.copy(alpha = 0.7f),
maxLines = 1,
)
}
}
}
}
}
}
@Composable
private fun NowPlayingLine(modifier: Modifier) {
val now = CarServices.mediaOrNull()?.now?.value
if (now == null || now.title.isEmpty()) return
Text(
listOf(now.title, now.artist).filter { it.isNotEmpty() }.joinToString(" · "),
modifier,
style = MaterialTheme.typography.titleLarge,
color = Color.White.copy(alpha = 0.7f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
/** What the cluster shows of a route, read from [NavTurn] and the trip's estimate to the destination. */
private class ClusterTurn(
val icon: androidx.car.app.model.CarIcon?,
val distance: String,
val what: String,
val trip: String,
) {
companion object {
fun now(): ClusterTurn? {
val turn = NavTurn.current() ?: return null
if (turn.rerouting) return null
val step = turn.step
val cue = step.cue?.toString().orEmpty()
val what = cue.ifEmpty { step.road?.toString().orEmpty() }
val estimate = CarServices.navOrNull()?.trip?.value?.destinationTravelEstimates?.firstOrNull()
val trip = estimate?.let { e ->
listOf(
e.remainingTimeSeconds.takeIf { it >= 0 }?.let { remainingTime(it) }.orEmpty(),
e.remainingDistance.display(),
e.arrivalTimeAtDestination.clockTime(),
).filter { it.isNotEmpty() }.joinToString(" · ")
}.orEmpty()
return ClusterTurn(step.maneuver?.icon, turn.distance.display(), what, trip)
}
}
}
@@ -1,7 +1,11 @@
package app.seb3thehacker.gearslip.car package app.seb3thehacker.gearslip.car
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
@@ -10,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
@@ -39,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
@@ -55,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() }
@@ -65,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
@@ -121,7 +154,7 @@ object DirectionsVoice {
say(instruction) say(instruction)
stage = Stage.DONE stage = Stage.DONE
} else { } else {
say("In ${spoken(turn.distance)}, $instruction") say(ahead(turn.distance, instruction))
stage = if (meters != null && meters > PREPARE_FROM_METERS) Stage.FAR else Stage.PREPARED stage = if (meters != null && meters > PREPARE_FROM_METERS) Stage.FAR else Stage.PREPARED
} }
return return
@@ -132,7 +165,7 @@ object DirectionsVoice {
stage = Stage.DONE stage = Stage.DONE
} }
stage == Stage.FAR && meters != null && meters <= PREPARE_AT_METERS -> { stage == Stage.FAR && meters != null && meters <= PREPARE_AT_METERS -> {
say("In ${spoken(turn.distance)}, $instruction") say(ahead(turn.distance, instruction))
stage = Stage.PREPARED stage = Stage.PREPARED
} }
} }
@@ -140,67 +173,79 @@ object DirectionsVoice {
/** What to do, in words: built from the manoeuvre when the app names one, else the app's own text. */ /** What to do, in words: built from the manoeuvre when the app names one, else the app's own text. */
private fun instruction(step: Step): String? { private fun instruction(step: Step): String? {
val res = appContext?.resources ?: return null
val road = step.road.text() val road = step.road.text()
val onto = if (road.isBlank()) "" else " onto $road" // Each manoeuvre is a whole sentence, with and without the road, so a translation can
val toward = if (road.isBlank()) "" else " toward $road" // put the road wherever its language wants it.
fun say(plain: Int, withRoad: Int) =
if (road.isBlank() || withRoad == 0) res.getString(plain) else res.getString(withRoad, road)
val maneuver = step.maneuver val maneuver = step.maneuver
val built = when (maneuver?.type) { val built = when (maneuver?.type) {
Maneuver.TYPE_TURN_SLIGHT_LEFT -> "Turn slightly left$onto" Maneuver.TYPE_TURN_SLIGHT_LEFT -> say(R.string.dir_slight_left, R.string.dir_slight_left_onto)
Maneuver.TYPE_TURN_SLIGHT_RIGHT -> "Turn slightly right$onto" Maneuver.TYPE_TURN_SLIGHT_RIGHT -> say(R.string.dir_slight_right, R.string.dir_slight_right_onto)
Maneuver.TYPE_TURN_NORMAL_LEFT -> "Turn left$onto" Maneuver.TYPE_TURN_NORMAL_LEFT -> say(R.string.dir_left, R.string.dir_left_onto)
Maneuver.TYPE_TURN_NORMAL_RIGHT -> "Turn right$onto" Maneuver.TYPE_TURN_NORMAL_RIGHT -> say(R.string.dir_right, R.string.dir_right_onto)
Maneuver.TYPE_TURN_SHARP_LEFT -> "Turn sharply left$onto" Maneuver.TYPE_TURN_SHARP_LEFT -> say(R.string.dir_sharp_left, R.string.dir_sharp_left_onto)
Maneuver.TYPE_TURN_SHARP_RIGHT -> "Turn sharply right$onto" Maneuver.TYPE_TURN_SHARP_RIGHT -> say(R.string.dir_sharp_right, R.string.dir_sharp_right_onto)
Maneuver.TYPE_U_TURN_LEFT, Maneuver.TYPE_U_TURN_RIGHT -> "Make a U-turn" Maneuver.TYPE_U_TURN_LEFT, Maneuver.TYPE_U_TURN_RIGHT -> say(R.string.dir_u_turn, 0)
Maneuver.TYPE_KEEP_LEFT -> "Keep left$toward" Maneuver.TYPE_KEEP_LEFT -> say(R.string.dir_keep_left, R.string.dir_keep_left_toward)
Maneuver.TYPE_KEEP_RIGHT -> "Keep right$toward" Maneuver.TYPE_KEEP_RIGHT -> say(R.string.dir_keep_right, R.string.dir_keep_right_toward)
Maneuver.TYPE_FORK_LEFT -> "Keep left at the fork$toward" Maneuver.TYPE_FORK_LEFT -> say(R.string.dir_fork_left, R.string.dir_fork_left_toward)
Maneuver.TYPE_FORK_RIGHT -> "Keep right at the fork$toward" Maneuver.TYPE_FORK_RIGHT -> say(R.string.dir_fork_right, R.string.dir_fork_right_toward)
Maneuver.TYPE_ON_RAMP_SLIGHT_LEFT, Maneuver.TYPE_ON_RAMP_NORMAL_LEFT, Maneuver.TYPE_ON_RAMP_SHARP_LEFT, Maneuver.TYPE_ON_RAMP_SLIGHT_LEFT, Maneuver.TYPE_ON_RAMP_NORMAL_LEFT, Maneuver.TYPE_ON_RAMP_SHARP_LEFT,
Maneuver.TYPE_ON_RAMP_U_TURN_LEFT -> "Take the ramp on the left$onto" Maneuver.TYPE_ON_RAMP_U_TURN_LEFT -> say(R.string.dir_ramp_left, R.string.dir_ramp_left_onto)
Maneuver.TYPE_ON_RAMP_SLIGHT_RIGHT, Maneuver.TYPE_ON_RAMP_NORMAL_RIGHT, Maneuver.TYPE_ON_RAMP_SHARP_RIGHT, Maneuver.TYPE_ON_RAMP_SLIGHT_RIGHT, Maneuver.TYPE_ON_RAMP_NORMAL_RIGHT, Maneuver.TYPE_ON_RAMP_SHARP_RIGHT,
Maneuver.TYPE_ON_RAMP_U_TURN_RIGHT -> "Take the ramp on the right$onto" Maneuver.TYPE_ON_RAMP_U_TURN_RIGHT -> say(R.string.dir_ramp_right, R.string.dir_ramp_right_onto)
Maneuver.TYPE_OFF_RAMP_SLIGHT_LEFT, Maneuver.TYPE_OFF_RAMP_NORMAL_LEFT -> "Take the exit on the left$toward" Maneuver.TYPE_OFF_RAMP_SLIGHT_LEFT, Maneuver.TYPE_OFF_RAMP_NORMAL_LEFT -> say(R.string.dir_exit_left, R.string.dir_exit_left_toward)
Maneuver.TYPE_OFF_RAMP_SLIGHT_RIGHT, Maneuver.TYPE_OFF_RAMP_NORMAL_RIGHT -> "Take the exit on the right$toward" Maneuver.TYPE_OFF_RAMP_SLIGHT_RIGHT, Maneuver.TYPE_OFF_RAMP_NORMAL_RIGHT -> say(R.string.dir_exit_right, R.string.dir_exit_right_toward)
Maneuver.TYPE_MERGE_LEFT -> "Merge left$onto" Maneuver.TYPE_MERGE_LEFT -> say(R.string.dir_merge_left, R.string.dir_merge_left_onto)
Maneuver.TYPE_MERGE_RIGHT -> "Merge right$onto" Maneuver.TYPE_MERGE_RIGHT -> say(R.string.dir_merge_right, R.string.dir_merge_right_onto)
Maneuver.TYPE_MERGE_SIDE_UNSPECIFIED -> "Merge$onto" Maneuver.TYPE_MERGE_SIDE_UNSPECIFIED -> say(R.string.dir_merge, R.string.dir_merge_onto)
Maneuver.TYPE_ROUNDABOUT_ENTER_AND_EXIT_CW, Maneuver.TYPE_ROUNDABOUT_ENTER_AND_EXIT_CW_WITH_ANGLE, Maneuver.TYPE_ROUNDABOUT_ENTER_AND_EXIT_CW, Maneuver.TYPE_ROUNDABOUT_ENTER_AND_EXIT_CW_WITH_ANGLE,
Maneuver.TYPE_ROUNDABOUT_ENTER_AND_EXIT_CCW, Maneuver.TYPE_ROUNDABOUT_ENTER_AND_EXIT_CCW_WITH_ANGLE -> Maneuver.TYPE_ROUNDABOUT_ENTER_AND_EXIT_CCW, Maneuver.TYPE_ROUNDABOUT_ENTER_AND_EXIT_CCW_WITH_ANGLE ->
maneuver.roundaboutExitNumber.takeIf { it in 1..ORDINALS.size } maneuver.roundaboutExitNumber.takeIf { it in 1..ORDINALS.size }
?.let { "At the roundabout, take the ${ORDINALS[it - 1]} exit$onto" } ?.let { res.getString(ORDINALS[it - 1]) }
?: "Go through the roundabout$onto" ?.let { exit ->
Maneuver.TYPE_ROUNDABOUT_ENTER_CW, Maneuver.TYPE_ROUNDABOUT_ENTER_CCW -> "Enter the roundabout" if (road.isBlank()) res.getString(R.string.dir_roundabout_take, exit)
Maneuver.TYPE_ROUNDABOUT_EXIT_CW, Maneuver.TYPE_ROUNDABOUT_EXIT_CCW -> "Exit the roundabout$onto" else res.getString(R.string.dir_roundabout_take_onto, exit, road)
Maneuver.TYPE_STRAIGHT -> "Continue straight$onto" }
Maneuver.TYPE_NAME_CHANGE -> "Continue$onto" ?: say(R.string.dir_roundabout_through, R.string.dir_roundabout_through_onto)
Maneuver.TYPE_FERRY_BOAT, Maneuver.TYPE_FERRY_BOAT_LEFT, Maneuver.TYPE_FERRY_BOAT_RIGHT -> "Take the ferry" Maneuver.TYPE_ROUNDABOUT_ENTER_CW, Maneuver.TYPE_ROUNDABOUT_ENTER_CCW -> say(R.string.dir_roundabout_enter, 0)
Maneuver.TYPE_FERRY_TRAIN, Maneuver.TYPE_FERRY_TRAIN_LEFT, Maneuver.TYPE_FERRY_TRAIN_RIGHT -> "Take the train" Maneuver.TYPE_ROUNDABOUT_EXIT_CW, Maneuver.TYPE_ROUNDABOUT_EXIT_CCW -> say(R.string.dir_roundabout_exit, R.string.dir_roundabout_exit_onto)
Maneuver.TYPE_DESTINATION, Maneuver.TYPE_DESTINATION_STRAIGHT -> "Your destination is ahead" Maneuver.TYPE_STRAIGHT -> say(R.string.dir_straight, R.string.dir_straight_onto)
Maneuver.TYPE_DESTINATION_LEFT -> "Your destination is on the left" Maneuver.TYPE_NAME_CHANGE -> say(R.string.dir_continue, R.string.dir_continue_onto)
Maneuver.TYPE_DESTINATION_RIGHT -> "Your destination is on the right" Maneuver.TYPE_FERRY_BOAT, Maneuver.TYPE_FERRY_BOAT_LEFT, Maneuver.TYPE_FERRY_BOAT_RIGHT -> say(R.string.dir_ferry, 0)
Maneuver.TYPE_FERRY_TRAIN, Maneuver.TYPE_FERRY_TRAIN_LEFT, Maneuver.TYPE_FERRY_TRAIN_RIGHT -> say(R.string.dir_train, 0)
Maneuver.TYPE_DESTINATION, Maneuver.TYPE_DESTINATION_STRAIGHT -> say(R.string.dir_destination, 0)
Maneuver.TYPE_DESTINATION_LEFT -> say(R.string.dir_destination_left, 0)
Maneuver.TYPE_DESTINATION_RIGHT -> say(R.string.dir_destination_right, 0)
else -> null else -> null
} }
return built ?: step.cue.text().ifBlank { null } return built ?: step.cue.text().ifBlank { null }
} }
/** "In 300 meters, turn left": the distance ahead of a turn that isn't close yet. */
private fun ahead(d: Distance, instruction: String): String =
appContext?.getString(R.string.dir_in_distance, spoken(d), instruction) ?: instruction
/** The app's own rounded distance, read the way a person would say it. */ /** The app's own rounded distance, read the way a person would say it. */
private fun spoken(d: Distance): String { private fun spoken(d: Distance): String {
val res = appContext?.resources ?: return ""
val n = d.displayDistance val n = d.displayDistance
val number = if (n % 1.0 == 0.0) n.toInt().toString() else "%.1f".format(n) val whole = n % 1.0 == 0.0
val one = n == 1.0 val number = if (whole) n.toInt().toString() else "%.1f".format(n)
return when (d.displayUnit) { return when (d.displayUnit) {
Distance.UNIT_KILOMETERS, Distance.UNIT_KILOMETERS_P1 -> if (one) "1 kilometer" else "$number kilometers" Distance.UNIT_KILOMETERS, Distance.UNIT_KILOMETERS_P1 ->
if (whole) res.getQuantityString(R.plurals.dir_km, n.toInt(), number) else res.getString(R.string.dir_km_decimal, number)
Distance.UNIT_MILES, Distance.UNIT_MILES_P1 -> when (n) { Distance.UNIT_MILES, Distance.UNIT_MILES_P1 -> when (n) {
0.25 -> "a quarter mile" 0.25 -> res.getString(R.string.dir_quarter_mile)
0.5 -> "half a mile" 0.5 -> res.getString(R.string.dir_half_mile)
0.75 -> "three quarters of a mile" 0.75 -> res.getString(R.string.dir_three_quarter_mile)
1.0 -> "1 mile" else -> if (whole) res.getQuantityString(R.plurals.dir_miles, n.toInt(), number) else res.getString(R.string.dir_miles_decimal, number)
else -> "$number miles"
} }
Distance.UNIT_FEET -> "$number feet" Distance.UNIT_FEET -> res.getQuantityString(R.plurals.dir_feet, n.toInt(), number)
Distance.UNIT_YARDS -> "$number yards" Distance.UNIT_YARDS -> res.getQuantityString(R.plurals.dir_yards, n.toInt(), number)
else -> "${n.roundToInt()} meters" else -> res.getQuantityString(R.plurals.dir_meters, n.roundToInt(), n.roundToInt())
} }
} }
@@ -219,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")
@@ -234,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.
@@ -251,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
@@ -273,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()
} }
} }
@@ -347,5 +434,9 @@ 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 val ORDINALS = listOf("first", "second", "third", "fourth", "fifth", "sixth", "seventh", "eighth") private const val RESTART_MS = 30_000L
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_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})") }
}
}
@@ -1,5 +1,7 @@
package app.seb3thehacker.gearslip.car package app.seb3thehacker.gearslip.car
import androidx.compose.ui.res.stringResource
import app.seb3thehacker.gearslip.R
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import app.seb3thehacker.gearslip.car.theme.* import app.seb3thehacker.gearslip.car.theme.*
@@ -11,6 +13,7 @@ import androidx.compose.foundation.layout.Arrangement
import android.provider.Settings import android.provider.Settings
import android.content.Intent import android.content.Intent
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -83,34 +86,138 @@ internal fun BrowsePanel(browse: app.seb3thehacker.gearslip.media.BrowseState, m
// row could say that the app's name in the top-left corner hasn't already - showing it // row could say that the app's name in the top-left corner hasn't already - showing it
// again here (as "Browse", or as the app's own name) was just the same label twice. // again here (as "Browse", or as the app's own name) was just the same label twice.
if (media.canGoUp || browse.trail.isNotEmpty()) { if (media.canGoUp || browse.trail.isNotEmpty()) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(Modifier.padding(bottom = 12.dp), verticalAlignment = Alignment.CenterVertically) {
if (media.canGoUp) { if (media.canGoUp) {
GsIconButton(Icons.Filled.ArrowBack, "Up", media::up) GsIconButton(Icons.Filled.ArrowBack, stringResource(R.string.media_up), media::up, size = 44.dp, shape = CircleShape)
Spacer(Modifier.width(14.dp))
} }
browse.trail.lastOrNull()?.let { browse.trail.lastOrNull()?.let {
Text( Text(
it, it,
style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold,
maxLines = 1, overflow = TextOverflow.Ellipsis, maxLines = 1, overflow = TextOverflow.Ellipsis,
) )
} }
} }
} }
when { when {
browse.loading -> Notice("Loading…", null) browse.loading -> Notice(stringResource(R.string.media_loading), null)
browse.unavailable -> Notice( browse.unavailable -> Notice(
"$appLabel keeps its library to itself.", stringResource(R.string.media_keeps_library, appLabel),
"Start music in the app on your phone. Play, pause, skip and seek work from here.", stringResource(R.string.media_browse_start),
) )
browse.failed -> Notice("Could not load this list.", null) browse.failed -> Notice(stringResource(R.string.media_load_failed), null)
browse.entries.isEmpty() -> Notice("Nothing here.", null) browse.entries.isEmpty() -> Notice(stringResource(R.string.media_nothing_here), null)
else -> LazyColumn(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) { else -> BrowseList(browse.entries, media)
items(browse.entries, key = { it.id }) { entry -> EntryRow(entry, media) } }
}
}
/**
* A list laid out the way the app asked: under its section headings, covers where it asked for a
* grid and rows everywhere else. A grid section among others is a shelf that scrolls sideways; a
* list that is one grid and nothing else wraps into rows of covers instead.
*/
@Composable
private fun BrowseList(entries: List<MediaEntry>, media: CarMedia) {
val sections = remember(entries) { browseSections(entries) }
BoxWithConstraints(Modifier.fillMaxSize()) {
val onlyGrid = sections.size == 1 && sections[0].grid && sections[0].heading == null
val columns = ((maxWidth + SHELF_GAP) / (SHELF_COVER + SHELF_GAP)).toInt().coerceAtLeast(2)
LazyColumn(
Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 8.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
sections.forEachIndexed { i, section ->
val heading = section.heading
if (heading != null && sections.getOrNull(i - 1)?.heading != heading) {
item(key = "heading $i") {
Text(
heading,
style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold,
maxLines = 1, overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = if (i == 0) 0.dp else 8.dp),
)
}
}
when {
!section.grid -> items(section.entries, key = { it.id }) { entry -> EntryRow(entry, media) }
onlyGrid -> items(section.entries.chunked(columns)) { row ->
Row(horizontalArrangement = Arrangement.spacedBy(SHELF_GAP)) {
row.forEach { CoverTile(it, media, Modifier.weight(1f)) }
repeat(columns - row.size) { Spacer(Modifier.weight(1f)) }
}
}
else -> item(key = "shelf $i") {
androidx.compose.foundation.lazy.LazyRow(horizontalArrangement = Arrangement.spacedBy(SHELF_GAP)) {
items(section.entries, key = { it.id }) { CoverTile(it, media, Modifier.width(SHELF_COVER)) }
}
}
}
} }
} }
} }
} }
private val SHELF_COVER = 132.dp
private val SHELF_GAP = 14.dp
/** A run of entries under one heading that are all covers or all rows. */
private class BrowseSection(val heading: String?, val grid: Boolean, val entries: List<MediaEntry>)
private fun browseSections(entries: List<MediaEntry>): List<BrowseSection> {
val out = mutableListOf<BrowseSection>()
var run = mutableListOf<MediaEntry>()
for (entry in entries) {
val last = run.lastOrNull()
if (last != null && (last.group != entry.group || last.grid != entry.grid)) {
out += BrowseSection(last.group, last.grid, run)
run = mutableListOf()
}
run += entry
}
run.lastOrNull()?.let { out += BrowseSection(it.group, it.grid, run) }
return out
}
/** One entry as a cover with its name under it: what the app's grids are made of. */
@Composable
private fun CoverTile(entry: MediaEntry, media: CarMedia, modifier: Modifier) {
val context = LocalContext.current
val icon by produceState(entry.iconBitmap, entry.iconUri) {
value = entry.iconBitmap ?: MediaArt.load(context, entry.iconUri)
}
val shape = RoundedCornerShape(16.dp)
Column(modifier.clip(shape).clickable { media.select(entry) }) {
Box(
Modifier
.fillMaxWidth()
.aspectRatio(1f)
.gsPanelShadow(shape)
.clip(shape)
.background(MaterialTheme.colorScheme.surfaceContainerHigh),
contentAlignment = Alignment.Center,
) {
icon?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) }
?: Icon(
if (entry.browsable) MediaIcons.Folder else MediaIcons.MusicNote, null,
tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.fillMaxSize(0.35f),
)
}
Text(
entry.title, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold,
maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 8.dp, start = 2.dp),
)
if (entry.subtitle.isNotEmpty()) {
Text(
entry.subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(start = 2.dp, bottom = 4.dp),
)
}
}
}
// --- up next ----------------------------------------------------------------------------------- // --- up next -----------------------------------------------------------------------------------
/** /**
@@ -127,12 +234,15 @@ internal fun QueuePanel(queue: List<QueueTrack>, now: NowPlaying, media: CarMedi
Column(modifier) { Column(modifier) {
if (upNext.isEmpty()) { if (upNext.isEmpty()) {
Notice( Notice(
"Nothing queued.", stringResource(R.string.media_nothing_queued),
"${if (now.title.isEmpty()) "Nothing playing" else "Only what's already playing"} - " + stringResource(if (now.title.isEmpty()) R.string.media_queue_empty_idle else R.string.media_queue_empty_playing),
"not every app tells Gearslip what comes next.",
) )
} else { } else {
LazyColumn(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) { LazyColumn(
Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(upNext, key = { it.queueId }) { track -> QueueRow(track, media) } items(upNext, key = { it.queueId }) { track -> QueueRow(track, media) }
} }
} }
@@ -148,18 +258,18 @@ private fun QueueRow(track: QueueTrack, media: CarMedia) {
Surface( Surface(
onClick = { media.playQueueItem(track) }, onClick = { media.playQueueItem(track) },
shape = MaterialTheme.shapes.large, shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surfaceVariant, color = MaterialTheme.colorScheme.surfaceContainer,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
Row(Modifier.padding(10.dp), verticalAlignment = Alignment.CenterVertically) { Row(Modifier.padding(8.dp), verticalAlignment = Alignment.CenterVertically) {
RowArt(icon, MediaIcons.MusicNote) RowArt(icon, MediaIcons.MusicNote)
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) { Column(Modifier.weight(1f).padding(horizontal = 14.dp)) {
Text(track.title, style = MaterialTheme.typography.titleSmall, maxLines = 1, overflow = TextOverflow.Ellipsis) Text(track.title, style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
if (track.subtitle.isNotEmpty()) { if (track.subtitle.isNotEmpty()) {
Text(track.subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) Text(track.subtitle, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
} }
} }
Icon(Icons.Filled.PlayArrow, "Play", tint = MaterialTheme.colorScheme.onSurfaceVariant) Icon(Icons.Filled.PlayArrow, stringResource(R.string.media_play), tint = MaterialTheme.colorScheme.onSurfaceVariant)
} }
} }
} }
@@ -173,19 +283,19 @@ internal fun EntryRow(entry: MediaEntry, media: CarMedia, onClick: () -> Unit =
Surface( Surface(
onClick = onClick, onClick = onClick,
shape = MaterialTheme.shapes.large, shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surfaceVariant, color = MaterialTheme.colorScheme.surfaceContainer,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
Row(Modifier.padding(10.dp), verticalAlignment = Alignment.CenterVertically) { Row(Modifier.padding(8.dp), verticalAlignment = Alignment.CenterVertically) {
RowArt(icon, if (entry.browsable) MediaIcons.Folder else MediaIcons.MusicNote) RowArt(icon, if (entry.browsable) MediaIcons.Folder else MediaIcons.MusicNote)
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) { Column(Modifier.weight(1f).padding(horizontal = 14.dp)) {
Text(entry.title, style = MaterialTheme.typography.titleSmall, maxLines = 1, overflow = TextOverflow.Ellipsis) Text(entry.title, style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
if (entry.subtitle.isNotEmpty()) { if (entry.subtitle.isNotEmpty()) {
Text(entry.subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) Text(entry.subtitle, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
} }
} }
if (entry.browsable && entry.playable) { if (entry.browsable && entry.playable) {
GsIconButton(Icons.Filled.PlayArrow, "Play", { media.playAll(entry) }) GsIconButton(Icons.Filled.PlayArrow, stringResource(R.string.media_play), { media.playAll(entry) })
} }
if (entry.browsable) Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, null) if (entry.browsable) Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, null)
} }
@@ -196,7 +306,7 @@ internal fun EntryRow(entry: MediaEntry, media: CarMedia, onClick: () -> Unit =
@Composable @Composable
private fun RowArt(icon: android.graphics.Bitmap?, fallback: ImageVector) { private fun RowArt(icon: android.graphics.Bitmap?, fallback: ImageVector) {
Box( Box(
Modifier.size(62.dp).clip(MaterialTheme.shapes.medium).background(MaterialTheme.colorScheme.surfaceContainerHigh), Modifier.size(56.dp).clip(RoundedCornerShape(12.dp)).background(MaterialTheme.colorScheme.surfaceContainerHigh),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
if (icon != null) Image(icon.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) if (icon != null) Image(icon.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
@@ -217,8 +327,14 @@ internal object MediaIcons {
val Shuffle = icon("Shuffle", "M10.59,9.17L5.41,4 4,5.41l5.17,5.17 1.42,-1.41zM14.5,4l2.04,2.04L4,18.59 5.41,20 17.96,7.46 20,9.5L20,4h-5.5zM14.83,13.41l-1.41,1.41 3.13,3.13L14.5,20L20,20v-5.5l-2.04,2.04 -3.13,-3.13z") val Shuffle = icon("Shuffle", "M10.59,9.17L5.41,4 4,5.41l5.17,5.17 1.42,-1.41zM14.5,4l2.04,2.04L4,18.59 5.41,20 17.96,7.46 20,9.5L20,4h-5.5zM14.83,13.41l-1.41,1.41 3.13,3.13L14.5,20L20,20v-5.5l-2.04,2.04 -3.13,-3.13z")
val Repeat = icon("Repeat", "M7,7h10v3l4,-4 -4,-4v3L5,5v6h2L7,7zM17,17L7,17v-3l-4,4 4,4v-3h12v-6h-2v4z") val Repeat = icon("Repeat", "M7,7h10v3l4,-4 -4,-4v3L5,5v6h2L7,7zM17,17L7,17v-3l-4,4 4,4v-3h12v-6h-2v4z")
val RepeatOne = icon("RepeatOne", "M7,7h10v3l4,-4 -4,-4v3L5,5v6h2L7,7zM17,17L7,17v-3l-4,4 4,4v-3h12v-6h-2v4zM13,15L13,9h-1l-2,1v1h1.5v4L13,15z") val RepeatOne = icon("RepeatOne", "M7,7h10v3l4,-4 -4,-4v3L5,5v6h2L7,7zM17,17L7,17v-3l-4,4 4,4v-3h12v-6h-2v4zM13,15L13,9h-1l-2,1v1h1.5v4L13,15z")
/** A screen with a filled right-hand panel: "put the player beside the map". */ /**
val DockRight = icon("DockRight", "M2,4h20v16H2zM4,6h10v12H4z", PathFillType.EvenOdd) * A screen split into the map and a side panel: outlined while the panel is closed ("put the
* player beside the map"), filled in while it's open ("close it"), on the side it opens on.
*/
val DockRight = icon("DockRight", "M2,4h20v16H2zM4,6h10v12H4zM16,6h4v12h-4z", PathFillType.EvenOdd)
val DockRightOpen = icon("DockRightOpen", "M2,4h20v16H2zM4,6h10v12H4z", PathFillType.EvenOdd)
val DockLeft = icon("DockLeft", "M2,4h20v16H2zM4,6h4v12H4zM10,6h10v12H10z", PathFillType.EvenOdd)
val DockLeftOpen = icon("DockLeftOpen", "M2,4h20v16H2zM10,6h10v12H10z", PathFillType.EvenOdd)
val Fullscreen = icon("Fullscreen", "M7,14L5,14v5h5v-2L7,17v-3zM5,10h2L7,7h3L10,5L5,5v5zM17,17h-3v2h5v-5h-2v3zM14,5v2h3v3h2L19,5h-5z") val Fullscreen = icon("Fullscreen", "M7,14L5,14v5h5v-2L7,17v-3zM5,10h2L7,7h3L10,5L5,5v5zM17,17h-3v2h5v-5h-2v3zM14,5v2h3v3h2L19,5h-5z")
val FullscreenExit = icon("FullscreenExit", "M5,16h3v3h2v-5L5,14v2zM8,8L5,8v2h5L10,5L8,5v3zM14,19h2v-3h3v-2h-5v5zM16,8L16,5h-2v5h5L19,8h-3z") val FullscreenExit = icon("FullscreenExit", "M5,16h3v3h2v-5L5,14v2zM8,8L5,8v2h5L10,5L8,5v3zM14,19h2v-3h3v-2h-5v5zM16,8L16,5h-2v5h5L19,8h-3z")
@@ -1,9 +1,14 @@
package app.seb3thehacker.gearslip.car package app.seb3thehacker.gearslip.car
import androidx.compose.ui.res.stringResource
import app.seb3thehacker.gearslip.R
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import app.seb3thehacker.gearslip.car.theme.* import app.seb3thehacker.gearslip.car.theme.*
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.ui.unit.sp
import androidx.compose.foundation.text.TextAutoSize
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
@@ -19,6 +24,7 @@ import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
@@ -49,6 +55,9 @@ 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.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.graphics.PathFillType import androidx.compose.ui.graphics.PathFillType
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
@@ -97,18 +106,18 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) {
val art = rememberArt(now) val art = rememberArt(now)
// The art's colour behind everything, header included; the buttons keep their own. // The theme's background, with the cover's colours glowing faintly behind the cover on the left.
Surface(Modifier.fillMaxSize(), color = rememberArtColor(art, MaterialTheme.colorScheme.background, darkLightness = 0.15f, lightLightness = 0.9f)) { Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
if (phase == CarMedia.Phase.READY && !searching) AlbumGlow(art, Modifier.fillMaxSize(), wide = true)
if (phase != CarMedia.Phase.READY) { if (phase != CarMedia.Phase.READY) {
Column(Modifier.fillMaxSize()) { Column(Modifier.fillMaxSize().padding(bottom = LocalNavBarInset.current)) {
AppIdentity(app, onExit, Modifier.padding(horizontal = 16.dp, vertical = 10.dp)) AppIdentity(app, onExit, Modifier.padding(horizontal = 16.dp, vertical = 10.dp))
when (phase) { when (phase) {
CarMedia.Phase.REJECTED -> when (rejection) { CarMedia.Phase.REJECTED -> when (rejection) {
CarMedia.Rejection.NEEDS_NOTIFICATION_ACCESS -> Notice( CarMedia.Rejection.NEEDS_NOTIFICATION_ACCESS -> Notice(
"${app.label} keeps its library to itself.", stringResource(R.string.media_keeps_library, app.label),
"Gearslip can still control it once it has Notification access on the phone. " + stringResource(R.string.media_needs_access),
"Allow it under Settings > Notifications > Notification access.", action = stringResource(R.string.media_open_on_phone) to {
action = "Open on phone" to {
val app = context.applicationContext val app = context.applicationContext
runCatching { runCatching {
app.startActivity( app.startActivity(
@@ -118,24 +127,23 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) {
}, },
) )
CarMedia.Rejection.NEEDS_APP_RUNNING -> Notice( CarMedia.Rejection.NEEDS_APP_RUNNING -> Notice(
"${app.label} isn't running.", stringResource(R.string.media_not_running, app.label),
"${app.label} plays through Gearslip's own player, but only while it's " + stringResource(R.string.media_not_running_body, app.label),
"open on the phone. Open it once and it keeps playing here in the car.", action = stringResource(R.string.media_open_app, app.label) to { media.launchAndConnect(app) },
action = "Open ${app.label}" to { media.launchAndConnect(app) },
) )
CarMedia.Rejection.REFUSED -> Notice( CarMedia.Rejection.REFUSED -> Notice(
"${app.label} would not let Gearslip browse it.", stringResource(R.string.media_refused, app.label),
"Some media apps only accept Google's own host.", stringResource(R.string.media_refused_body),
) )
} }
else -> Notice("Connecting to ${app.label}…", null) else -> Notice(stringResource(R.string.media_connecting, app.label), null)
} }
} }
return@Surface return@Surface
} }
if (searching) { if (searching) {
SearchPanel( Box(Modifier.padding(bottom = LocalNavBarInset.current)) { SearchPanel(
appLabel = app.label, appLabel = app.label,
media = media, media = media,
canPickForYou = now.canSearch, canPickForYou = now.canSearch,
@@ -153,52 +161,91 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) {
tab = Tab.NOW_PLAYING tab = Tab.NOW_PLAYING
}, },
onClose = { searching = false }, onClose = { searching = false },
) ) }
return@Surface return@Surface
} }
// Two columns, split the same way on every tab so nothing jumps when switching: the app // The page runs down under the see-through nav bar, so the cover's glow carries on behind
// and the art (or, beside a list, the compact player) on the left; the tabs across the // it; the content stops at the bar's top edge. Lists run right to it; the rest keeps a gap.
// top of the right column, over whatever the tab shows. val barTop = LocalNavBarInset.current
Row( val barInset = barTop + 8.dp
Modifier.fillMaxSize().padding(start = 16.dp, end = 16.dp, top = 10.dp, bottom = 8.dp), val tabs: @Composable (Modifier) -> Unit = { tabModifier ->
horizontalArrangement = Arrangement.spacedBy(24.dp), MediaTab(stringResource(R.string.media_now_playing), tab == Tab.NOW_PLAYING, tabModifier) { tab = Tab.NOW_PLAYING }
) { MediaTab(stringResource(R.string.media_up_next), tab == Tab.QUEUE, tabModifier) { tab = Tab.QUEUE }
Column(Modifier.weight(0.8f).fillMaxHeight()) { MediaTab(stringResource(R.string.media_browse), tab == Tab.BROWSE, tabModifier) { tab = Tab.BROWSE }
AppIdentity(app, onExit) MediaTab(stringResource(R.string.storage_lyrics), tab == Tab.LYRICS, tabModifier) { tab = Tab.LYRICS }
Spacer(Modifier.height(12.dp)) }
if (tab == Tab.NOW_PLAYING) { val searchButton: @Composable () -> Unit = {
CoverArt(art, Modifier.weight(1f).fillMaxWidth()) if (now.canSearch || media.canSearchLibrary) {
} else { GsIconButton(
CompactPlayer(now, media, Modifier.weight(1f).fillMaxWidth()) Icons.Filled.Search,
stringResource(R.string.media_search, app.label),
{ searching = true },
colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHigh, MaterialTheme.colorScheme.onSurface),
size = 44.dp,
shape = MaterialTheme.shapes.large,
)
}
}
val tabContent: @Composable (Modifier) -> Unit = { contentModifier ->
Box(contentModifier) {
when (tab) {
Tab.NOW_PLAYING -> StageControls(now, media, Modifier.fillMaxSize().padding(bottom = barInset))
Tab.BROWSE -> BrowsePanel(browse, media, app.label, Modifier.fillMaxSize().padding(bottom = barTop))
Tab.QUEUE -> QueuePanel(queue, now, media, Modifier.fillMaxSize().padding(bottom = barTop))
Tab.LYRICS -> LyricsPanel(now, Modifier.fillMaxSize().padding(bottom = barInset))
} }
} }
Column(Modifier.weight(1.2f).fillMaxHeight()) { }
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
MediaTab("Now playing", tab == Tab.NOW_PLAYING, Modifier.weight(1f)) { tab = Tab.NOW_PLAYING } BoxWithConstraints(Modifier.fillMaxSize().padding(start = 16.dp, end = 16.dp, top = 10.dp)) {
MediaTab("Browse", tab == Tab.BROWSE, Modifier.weight(1f)) { tab = Tab.BROWSE } // A small screen, or a big one at Extra large size, has no room for two columns: the
MediaTab("Up next", tab == Tab.QUEUE, Modifier.weight(1f)) { tab = Tab.QUEUE } // app and the tabs share one bar, and a list takes the whole width. The nav bar keeps
MediaTab("Lyrics", tab == Tab.LYRICS, Modifier.weight(1f)) { tab = Tab.LYRICS } // the controls, so nothing is lost.
if (now.canSearch || media.canSearchLibrary) { if (maxWidth < NARROW_WIDTH || maxHeight - barInset < NARROW_HEIGHT) {
GsIconButton( Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Icons.Filled.Search, Row(
"Search ${app.label}", Modifier.fillMaxWidth(),
{ searching = true }, horizontalArrangement = Arrangement.spacedBy(12.dp),
colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHigh, MaterialTheme.colorScheme.onSurface), verticalAlignment = Alignment.CenterVertically,
size = 44.dp, ) {
shape = MaterialTheme.shapes.large, AppIdentity(app, onExit, Modifier.widthIn(max = 180.dp))
) Row(
Modifier.weight(1f).horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
) { tabs(Modifier) }
searchButton()
}
search?.let { SearchStatus(it, app.label, media::dismissSearch) }
if (tab == Tab.NOW_PLAYING) NowPlayingPage(now, media, Modifier.weight(1f).fillMaxWidth().padding(bottom = barInset), padded = false)
else tabContent(Modifier.weight(1f).fillMaxWidth())
}
return@BoxWithConstraints
}
// Two columns, split the same way on every tab so nothing jumps when switching: the app
// and the art (or, beside a list, the compact player) on the left; the tabs across the
// top of the right column, over whatever the tab shows.
Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.spacedBy(24.dp)) {
Column(Modifier.weight(0.8f).fillMaxHeight().padding(bottom = barInset)) {
AppIdentity(app, onExit)
Spacer(Modifier.height(12.dp))
if (tab == Tab.NOW_PLAYING) {
CoverArt(art, Modifier.weight(1f).fillMaxWidth())
} else {
CompactPlayer(now, media, art, Modifier.weight(1f).fillMaxWidth())
} }
} }
search?.let { SearchStatus(it, app.label, media::dismissSearch) } Column(Modifier.weight(1.2f).fillMaxHeight()) {
Spacer(Modifier.height(12.dp)) // The tabs share the row evenly; a long word (a translation, a big text size)
Box(Modifier.weight(1f).fillMaxWidth()) { // shrinks to fit its tab instead.
when (tab) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Tab.NOW_PLAYING -> NowPlayingControls(now, media, Modifier.fillMaxSize()) tabs(Modifier.weight(1f))
Tab.BROWSE -> BrowsePanel(browse, media, app.label, Modifier.fillMaxSize()) searchButton()
Tab.QUEUE -> QueuePanel(queue, now, media, Modifier.fillMaxSize())
Tab.LYRICS -> LyricsPanel(now, Modifier.fillMaxSize())
} }
search?.let { SearchStatus(it, app.label, media::dismissSearch) }
Spacer(Modifier.height(12.dp))
tabContent(Modifier.weight(1f).fillMaxWidth())
} }
} }
} }
@@ -213,7 +260,7 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) {
private fun AppIdentity(app: MediaApp, onExit: () -> Unit, modifier: Modifier = Modifier) { private fun AppIdentity(app: MediaApp, onExit: () -> Unit, modifier: Modifier = Modifier) {
val icon = remember(app) { findEntry(app.component.flattenToString())?.icon } val icon = remember(app) { findEntry(app.component.flattenToString())?.icon }
Row(modifier, verticalAlignment = Alignment.CenterVertically) { Row(modifier, verticalAlignment = Alignment.CenterVertically) {
GsIconButton(Icons.Filled.ArrowBack, "Back", onExit, size = 44.dp) GsIconButton(Icons.Filled.ArrowBack, stringResource(R.string.back), onExit, size = 44.dp)
Spacer(Modifier.width(14.dp)) Spacer(Modifier.width(14.dp))
icon?.let { icon?.let {
Image(it, null, Modifier.size(26.dp).clip(RoundedCornerShape(7.dp))) Image(it, null, Modifier.size(26.dp).clip(RoundedCornerShape(7.dp)))
@@ -273,10 +320,10 @@ private fun SearchPanel(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
GsIconButton(Icons.Filled.ArrowBack, "Back", onClose, size = ICON_BUTTON_SIZE) GsIconButton(Icons.Filled.ArrowBack, stringResource(R.string.back), onClose, size = ICON_BUTTON_SIZE)
SearchField( SearchField(
query, query,
"Search $appLabel", stringResource(R.string.media_search, appLabel),
Modifier.weight(1f).clickable { typing = true }, Modifier.weight(1f).clickable { typing = true },
onClear = { onClear = {
query = "" query = ""
@@ -287,12 +334,12 @@ private fun SearchPanel(
Box(Modifier.weight(1f).fillMaxWidth().padding(horizontal = 16.dp)) { Box(Modifier.weight(1f).fillMaxWidth().padding(horizontal = 16.dp)) {
val entries = current?.entries val entries = current?.entries
when { when {
trimmed.isEmpty() -> SearchNote("Type a song, artist, album, or playlist.") trimmed.isEmpty() -> SearchNote(stringResource(R.string.media_search_hint))
unsupported && canPickForYou -> unsupported && canPickForYou ->
SearchNote("$appLabel can't list results. Press search, and $appLabel picks what to play.") SearchNote(stringResource(R.string.media_search_no_list, appLabel))
unsupported -> SearchNote("$appLabel can't search from the car.") unsupported -> SearchNote(stringResource(R.string.media_search_unsupported, appLabel))
entries == null -> SearchNote("Searching $appLabel…") entries == null -> SearchNote(stringResource(R.string.media_searching, appLabel))
entries.isEmpty() -> SearchNote("$appLabel found nothing for \"$trimmed\".") entries.isEmpty() -> SearchNote(stringResource(R.string.media_search_nothing, appLabel, trimmed))
else -> LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp), contentPadding = PaddingValues(bottom = 8.dp)) { else -> LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp), contentPadding = PaddingValues(bottom = 8.dp)) {
items(entries) { entry -> items(entries) { entry ->
EntryRow(entry, media) { EntryRow(entry, media) {
@@ -350,8 +397,8 @@ private fun SearchStatus(search: CarMedia.Search, appLabel: String, onDismiss: (
modifier = Modifier.fillMaxWidth().padding(top = 8.dp), modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
) { ) {
Text( Text(
if (search.ignored) "$appLabel didn't play anything for \"${search.query}\"." if (search.ignored) stringResource(R.string.media_search_ignored, appLabel, search.query)
else "Searching $appLabel for \"${search.query}\"…", else stringResource(R.string.media_searching_for, appLabel, search.query),
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
maxLines = 2, maxLines = 2,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
@@ -376,14 +423,17 @@ private fun MediaTab(label: String, selected: Boolean, modifier: Modifier, onCli
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.titleSmall,
fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal, fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, autoSize = TextAutoSize.StepBased(minFontSize = 11.sp, maxFontSize = MaterialTheme.typography.titleSmall.fontSize),
modifier = Modifier.padding(horizontal = 8.dp),
) )
} }
} }
/** /**
* The song's lyrics, fetched when this tab opens. Synced lyrics follow the playback position with * The song's lyrics, fetched when this tab opens. Synced lyrics follow playback: the line being
* the current line in the accent colour; unsynced ones are plain scrolling text. * sung sits a little above the middle in bold, lines already sung dim, and the list fades out at
* both edges instead of cutting a line in half. Word-timed lyrics also light up each word as it's
* sung. Unsynced ones are plain scrolling text.
*/ */
@Composable @Composable
internal fun LyricsPanel( internal fun LyricsPanel(
@@ -394,43 +444,89 @@ internal fun LyricsPanel(
val context = LocalContext.current val context = LocalContext.current
val lyrics by produceState<LyricsState>(LyricsState.Loading, now.title, now.artist, now.album, now.durationMs) { val lyrics by produceState<LyricsState>(LyricsState.Loading, now.title, now.artist, now.album, now.durationMs) {
value = LyricsState.Loading value = LyricsState.Loading
value = Lyrics.find(context, now.title, now.artist, now.album, now.durationMs) val found = Lyrics.find(context, now.title, now.artist, now.album, now.durationMs)
?.let { LyricsState.Found(it) } ?: LyricsState.Missing value = found?.let { LyricsState.Found(it) } ?: LyricsState.Missing
} // Cached lyrics show at once; if a source now has better ones, they replace them.
val list = rememberLazyListState() if (found != null) {
Lyrics.upgrade(context, now.title, now.artist, now.album, now.durationMs, found)
// Ticks with playback so the highlighted line moves; only synced lyrics need it. ?.let { value = LyricsState.Found(it) }
var position by remember { mutableLongStateOf(0L) }
LaunchedEffect(now) {
while (true) {
position = now.currentPosition()
kotlinx.coroutines.delay(300)
} }
} }
when (val state = lyrics) { when (val state = lyrics) {
LyricsState.Loading -> Notice("Looking for lyrics…", null) LyricsState.Loading -> Notice(stringResource(R.string.media_looking_lyrics), null)
LyricsState.Missing -> Notice( LyricsState.Missing -> Notice(
if (now.title.isEmpty()) "Nothing playing." else "No lyrics found for this track.", stringResource(if (now.title.isEmpty()) R.string.media_nothing_playing_dot else R.string.media_no_lyrics),
null, null,
) )
is LyricsState.Found -> { is LyricsState.Found -> Column(modifier) {
val lines = state.result.lines if (state.result.synced) SyncedLyrics(now, state.result, lineStyle, Modifier.weight(1f).fillMaxWidth())
val current = if (state.result.synced) lines.indexOfLast { it.timeMs <= position } else -1 else PlainLyrics(state.result, lineStyle, Modifier.weight(1f).fillMaxWidth())
LaunchedEffect(current) { // Debug builds name the service the lyrics came from, to see which one answers.
if (current >= 0) list.animateScrollToItem((current - 1).coerceAtLeast(0)) if (app.seb3thehacker.gearslip.BuildConfig.DEBUG) {
val from = state.result.source.ifEmpty { "an older cache" }
val kind = when {
state.result.lines.any { it.words.isNotEmpty() } -> "word-timed"
state.result.synced -> "synced"
else -> "plain"
}
Text(
"From $from · $kind",
Modifier.padding(top = 4.dp),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.tertiary,
)
} }
LazyColumn(modifier, state = list, verticalArrangement = Arrangement.spacedBy(6.dp)) { }
itemsIndexed(lines) { index, line -> }
}
@Composable
private fun SyncedLyrics(
now: NowPlaying,
result: LyricsResult,
lineStyle: androidx.compose.ui.text.TextStyle,
modifier: Modifier,
) {
val lines = result.lines
val wordTimed = lines.any { it.words.isNotEmpty() }
// Every frame while words are filling in, so a word doesn't light up in steps; otherwise a
// few times a second is enough to move the line along.
var position by remember { mutableLongStateOf(now.currentPosition()) }
LaunchedEffect(now, wordTimed) {
while (true) {
position = now.currentPosition()
if (wordTimed && now.state == PlaybackState.STATE_PLAYING) androidx.compose.runtime.withFrameMillis { }
else delay(300)
}
}
// Only the line changing recomposes the list; the moving position is read by the one line.
val current by remember(lines) {
androidx.compose.runtime.derivedStateOf { lines.indexOfLast { it.timeMs <= position } }
}
val list = rememberLazyListState()
LaunchedEffect(current) {
if (current >= 0) list.animateScrollToItem(current)
}
val scheme = MaterialTheme.colorScheme
val sung = scheme.onSurfaceVariant.copy(alpha = 0.55f)
BoxWithConstraints(modifier.fadingEdges()) {
LazyColumn(
Modifier.fillMaxSize(),
state = list,
// The padding lets the first line start, and the last line end, near the middle.
contentPadding = PaddingValues(top = maxHeight * 0.32f, bottom = maxHeight * 0.6f),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
itemsIndexed(lines) { index, line ->
if (index == current) {
CurrentLine(line, { position }, lineStyle)
} else {
Text( Text(
line.text.ifEmpty { " " }, line.text.ifEmpty { " " },
style = lineStyle, style = lineStyle,
fontWeight = if (index == current) FontWeight.Bold else FontWeight.Normal, color = if (index < current) sung else scheme.onSurfaceVariant,
color = when {
!state.result.synced -> MaterialTheme.colorScheme.onSurface
index == current -> MaterialTheme.colorScheme.primary
else -> MaterialTheme.colorScheme.onSurfaceVariant
},
) )
} }
} }
@@ -438,6 +534,72 @@ internal fun LyricsPanel(
} }
} }
/**
* The line being sung, in bold and a size up. With word timings, words already sung are in the
* accent colour, the one being sung fills in letter by letter, and the rest wait dimmed.
*/
@Composable
private fun CurrentLine(line: app.seb3thehacker.gearslip.media.LyricLine, position: () -> Long, lineStyle: androidx.compose.ui.text.TextStyle) {
val scheme = MaterialTheme.colorScheme
val style = lineStyle.copy(fontWeight = FontWeight.Bold, fontSize = lineStyle.fontSize * 1.15f)
if (line.words.isEmpty()) {
Text(line.text.ifEmpty { " " }, style = style, color = scheme.primary)
return
}
val lit = scheme.primary
val waiting = scheme.onSurfaceVariant.copy(alpha = 0.7f)
val now = position()
val text = androidx.compose.ui.text.buildAnnotatedString {
line.words.forEachIndexed { i, word ->
// The line's first word has no leading space to trim, the last no trailing one.
val shown = if (i == line.words.lastIndex) word.text.trimEnd() else word.text
val progress = when {
now >= word.endMs -> 1f
now <= word.startMs -> 0f
else -> (now - word.startMs).toFloat() / (word.endMs - word.startMs).coerceAtLeast(1)
}
val letters = shown.trimEnd().length
val filled = progress * letters
shown.forEachIndexed { c, ch ->
val amount = (filled - c).coerceIn(0f, 1f)
withStyle(androidx.compose.ui.text.SpanStyle(color = androidx.compose.ui.graphics.lerp(waiting, lit, amount))) {
append(ch)
}
}
}
}
Text(text, style = style)
}
@Composable
private fun PlainLyrics(result: LyricsResult, lineStyle: androidx.compose.ui.text.TextStyle, modifier: Modifier) {
LazyColumn(
modifier.fadingEdges(),
contentPadding = PaddingValues(vertical = 24.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(result.lines) { line ->
Text(line.text.ifEmpty { " " }, style = lineStyle, color = MaterialTheme.colorScheme.onSurface)
}
}
}
/** Fades the top and bottom edges out, so a line scrolling past doesn't end in a hard cut. */
private fun Modifier.fadingEdges(): Modifier = this
.graphicsLayer { compositingStrategy = androidx.compose.ui.graphics.CompositingStrategy.Offscreen }
.drawWithContent {
drawContent()
drawRect(
androidx.compose.ui.graphics.Brush.verticalGradient(
0f to androidx.compose.ui.graphics.Color.Transparent,
0.14f to androidx.compose.ui.graphics.Color.Black,
0.86f to androidx.compose.ui.graphics.Color.Black,
1f to androidx.compose.ui.graphics.Color.Transparent,
),
blendMode = androidx.compose.ui.graphics.BlendMode.DstIn,
)
}
private sealed interface LyricsState { private sealed interface LyricsState {
data object Loading : LyricsState data object Loading : LyricsState
data object Missing : LyricsState data object Missing : LyricsState
@@ -468,29 +630,39 @@ internal fun Notice(title: String, detail: String?, action: Pair<String, () -> U
// --- now playing ----------------------------------------------------------------------------- // --- now playing -----------------------------------------------------------------------------
/** /**
* The now-playing view on its own, for places with no tabs around it (an app's own * The now-playing view on its own: an app's own now-playing template, or the Now playing tab on a
* now-playing template): the cover on the left, [NowPlayingControls] on the right. * small screen. The cover on the left and [NowPlayingControls] on the right where there's room;
* narrower, the cover sits above the controls, and only when they still fit under it.
*/ */
@Composable @Composable
internal fun NowPlayingPage(now: NowPlaying, media: CarMedia, modifier: Modifier) { internal fun NowPlayingPage(now: NowPlaying, media: CarMedia, modifier: Modifier, padded: Boolean = true) {
Row( BoxWithConstraints(if (padded) modifier.padding(horizontal = 24.dp, vertical = 8.dp) else modifier) {
modifier.padding(horizontal = 24.dp, vertical = 8.dp), val art = rememberArt(now)
horizontalArrangement = Arrangement.spacedBy(24.dp), if (maxWidth >= 520.dp && maxHeight >= 260.dp) {
) { val artWidth = minOf(maxHeight, (maxWidth - 24.dp) * 0.4f)
CoverArt(rememberArt(now), Modifier.weight(0.8f).fillMaxHeight()) Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.spacedBy(24.dp)) {
NowPlayingControls(now, media, Modifier.weight(1.2f).fillMaxHeight()) CoverArt(art, Modifier.width(artWidth).fillMaxHeight())
NowPlayingControls(now, media, Modifier.weight(1f).fillMaxHeight())
}
} else {
val artHeight = if (maxHeight >= 400.dp) minOf(maxHeight * 0.25f, 160.dp) else 0.dp
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
if (artHeight > 0.dp) CoverArt(art, Modifier.fillMaxWidth().height(artHeight))
NowPlayingControls(now, media, Modifier.weight(1f).fillMaxWidth())
}
}
} }
} }
/** The cover, as big a square as its space allows, centred in it. */ /** The cover, as big a square as its space allows, centred in it. */
@Composable @Composable
private fun CoverArt(art: android.graphics.Bitmap?, modifier: Modifier) { private fun CoverArt(art: android.graphics.Bitmap?, modifier: Modifier, alignment: Alignment = Alignment.Center) {
BoxWithConstraints(modifier, contentAlignment = Alignment.Center) { BoxWithConstraints(modifier, contentAlignment = alignment) {
val shape = RoundedCornerShape(22.dp)
Surface( Surface(
shape = MaterialTheme.shapes.large, shape = shape,
color = MaterialTheme.colorScheme.surfaceVariant, color = MaterialTheme.colorScheme.surfaceContainerHigh,
shadowElevation = 6.dp, modifier = Modifier.size(minOf(maxWidth, maxHeight)).gsPanelShadow(shape),
modifier = Modifier.size(minOf(maxWidth, maxHeight)),
) { ) {
if (art != null) { if (art != null) {
Image(art.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) Image(art.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
@@ -504,65 +676,138 @@ private fun CoverArt(art: android.graphics.Bitmap?, modifier: Modifier) {
} }
} }
/**
* Now playing beside the big cover: the title, artist and seek bar along the top, and the
* controls in two rows under them, as big as the height left over allows - the transport, then
* like, shuffle, repeat and the app's own buttons.
*/
@Composable
private fun StageControls(now: NowPlaying, media: CarMedia, modifier: Modifier) {
BoxWithConstraints(modifier) {
// Short of height (a small screen, or Extra large size), the title steps down a size.
val compact = maxHeight < 360.dp
val showTimes = maxHeight >= 260.dp
val titleRoom = maxHeight * 0.4f
Column(Modifier.fillMaxSize().padding(start = 4.dp, end = 4.dp, top = if (compact) 6.dp else 16.dp, bottom = 4.dp)) {
// The title gives up its height first and scrolls, so the controls keep theirs.
Column(Modifier.heightIn(max = titleRoom).verticalScroll(rememberScrollState())) {
Text(
now.title.ifEmpty { stringResource(R.string.media_nothing_playing) },
style = MaterialTheme.typography.displaySmall, fontWeight = FontWeight.Bold,
maxLines = 2, overflow = TextOverflow.Ellipsis,
autoSize = TextAutoSize.StepBased(
minFontSize = if (compact) 20.sp else 22.sp,
maxFontSize = MaterialTheme.typography.displaySmall.fontSize,
stepSize = 2.sp,
),
)
Text(
listOf(now.artist, now.album).filter { it.isNotEmpty() }.joinToString(" · "),
style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1, overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 4.dp),
)
now.error?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error, maxLines = 2) }
if (now.title.isEmpty() && now.error == null) {
Text(
stringResource(R.string.media_press_play),
style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
PlayerProgress(now, media, Modifier.fillMaxWidth().padding(top = if (compact) 4.dp else 12.dp), showTimes = showTimes)
// What's left goes to the buttons: play takes about half of it, the row below a third,
// and neither grows past what a row of them fits across.
BoxWithConstraints(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
val gap = (maxHeight * 0.08f).coerceIn(6.dp, 20.dp)
val play = minOf(maxHeight * 0.5f, maxWidth / 3.2f, 128.dp).coerceAtLeast(56.dp)
val mode = minOf(maxHeight * 0.32f, maxWidth / 6.5f, 80.dp).coerceAtLeast(44.dp)
Column(verticalArrangement = Arrangement.spacedBy(gap), horizontalAlignment = Alignment.CenterHorizontally) {
TransportRow(now, media, skip = play / 1.5f, playScale = 1.5f, modifier = Modifier.fillMaxWidth())
PlayModeRow(now, media, size = mode, modifier = Modifier.fillMaxWidth(), maxOther = 2)
}
}
}
}
}
/** /**
* Everything about the track and every control for it, read top to bottom: title and artist, * Everything about the track and every control for it, read top to bottom: title and artist,
* the seek bar, the transport, then like, shuffle and repeat as big icon-only buttons. * the seek bar, the transport, then like, shuffle and repeat as big icon-only buttons.
*/ */
@Composable @Composable
private fun NowPlayingControls(now: NowPlaying, media: CarMedia, modifier: Modifier) { private fun NowPlayingControls(now: NowPlaying, media: CarMedia, modifier: Modifier) {
Column( BoxWithConstraints(modifier) {
modifier.verticalScroll(rememberScrollState()), val compact = maxHeight < 360.dp || maxWidth < 420.dp
verticalArrangement = Arrangement.spacedBy(14.dp, Alignment.CenterVertically), val showTimes = maxHeight >= 260.dp
) { Column(
Column { Modifier.fillMaxSize(),
Text( verticalArrangement = Arrangement.spacedBy(if (compact) 8.dp else 14.dp, Alignment.CenterVertically),
now.title.ifEmpty { "Nothing playing" }, ) {
style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, // The title gives up its height first and scrolls, so the controls never leave the screen.
maxLines = 2, overflow = TextOverflow.Ellipsis, Column(Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState())) {
)
Text(
listOf(now.artist, now.album).filter { it.isNotEmpty() }.joinToString(" - "),
style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
now.error?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error, maxLines = 2) }
if (now.title.isEmpty() && now.error == null) {
Text( Text(
"Press play to pick up where you left off, or find something in Browse.", now.title.ifEmpty { stringResource(R.string.media_nothing_playing) },
style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold,
maxLines = 2, overflow = TextOverflow.Ellipsis,
) )
Text(
listOf(now.artist, now.album).filter { it.isNotEmpty() }.joinToString(" - "),
style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
now.error?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error, maxLines = 2) }
if (now.title.isEmpty() && now.error == null) {
Text(
stringResource(R.string.media_press_play),
style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} }
PlayerProgress(now, media, Modifier.fillMaxWidth(), showTimes = showTimes)
TransportRow(now, media, skip = if (compact) 48.dp else 60.dp, modifier = Modifier.fillMaxWidth())
PlayModeRow(now, media, size = if (compact) 46.dp else 56.dp, modifier = Modifier.fillMaxWidth(), maxOther = 2)
} }
PlayerProgress(now, media, Modifier.fillMaxWidth())
TransportRow(now, media, skip = 60.dp, modifier = Modifier.fillMaxWidth())
PlayModeRow(now, media, size = 56.dp, modifier = Modifier.fillMaxWidth(), maxOther = 2)
} }
} }
/** Below these, the media app drops to one column: see [MediaScreen]. */
private val NARROW_WIDTH = 600.dp
private val NARROW_HEIGHT = 300.dp
/** /**
* The player beside the browse list, the queue and the lyrics: no art, which the list needs the * The player beside the browse list, the queue and the lyrics: the cover as big as the controls
* room for, just what's playing, the seek bar and the controls. * leave room for, with the title and artist beside it rather than under it, so the cover gets the
* height; then the seek bar and the controls along the bottom.
*/ */
@Composable @Composable
private fun CompactPlayer(now: NowPlaying, media: CarMedia, modifier: Modifier) { private fun CompactPlayer(now: NowPlaying, media: CarMedia, art: android.graphics.Bitmap?, modifier: Modifier) {
Column( Column(modifier) {
modifier.verticalScroll(rememberScrollState()), BoxWithConstraints(Modifier.weight(1f).fillMaxWidth()) {
verticalArrangement = Arrangement.spacedBy(14.dp), val cover = minOf(maxHeight, maxWidth * 0.62f).coerceAtLeast(0.dp)
) { Row(Modifier.fillMaxSize(), verticalAlignment = Alignment.Bottom) {
Column { CoverArt(art, Modifier.size(cover))
Text( Spacer(Modifier.width(16.dp))
now.title.ifEmpty { "Nothing playing" }, Column(Modifier.weight(1f).padding(bottom = 4.dp)) {
style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, Text(
maxLines = 2, overflow = TextOverflow.Ellipsis, now.title.ifEmpty { stringResource(R.string.media_nothing_playing) },
) style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold,
Text( maxLines = 3, overflow = TextOverflow.Ellipsis,
now.artist, )
style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, Text(
maxLines = 1, overflow = TextOverflow.Ellipsis, now.artist,
) style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2, overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 4.dp),
)
}
}
} }
Spacer(Modifier.height(10.dp))
PlayerProgress(now, media, Modifier.fillMaxWidth()) PlayerProgress(now, media, Modifier.fillMaxWidth())
TransportRow(now, media, skip = 52.dp, modifier = Modifier.fillMaxWidth()) Spacer(Modifier.height(6.dp))
TransportRow(now, media, skip = 54.dp, modifier = Modifier.fillMaxWidth())
Spacer(Modifier.height(10.dp))
PlayModeRow(now, media, size = 48.dp, modifier = Modifier.fillMaxWidth()) PlayModeRow(now, media, size = 48.dp, modifier = Modifier.fillMaxWidth())
} }
} }
@@ -1,5 +1,7 @@
package app.seb3thehacker.gearslip.car package app.seb3thehacker.gearslip.car
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import android.os.SystemClock import android.os.SystemClock
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.horizontalScroll
@@ -83,9 +85,10 @@ internal fun ReplyGlyph(modifier: Modifier = Modifier) =
Icon(ImageVector.vectorResource(R.drawable.voice_selection_24), null, modifier) Icon(ImageVector.vectorResource(R.drawable.voice_selection_24), null, modifier)
/** "Alex", or "Alex · 3 new" when a burst of messages from one chat has been merged. */ /** "Alex", or "Alex · 3 new" when a burst of messages from one chat has been merged. */
@Composable
private fun CarNotification.heading(): String { private fun CarNotification.heading(): String {
val who = title.ifBlank { appLabel } val who = title.ifBlank { appLabel }
return if (burst > 1) "$who · $burst new" else who return if (burst > 1) pluralStringResource(R.plurals.msg_burst, burst, who, burst) else who
} }
/** The card. It shows the voice reply too, wherever that was started from. */ /** The card. It shows the voice reply too, wherever that was started from. */
@@ -106,17 +109,17 @@ fun NotificationPopup(showNew: Boolean, modifier: Modifier = Modifier) {
Text(n.heading(), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis) Text(n.heading(), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(n.text, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) Text(n.text, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
} }
GsIconButton(Icons.Filled.Close, "Dismiss", { CarNotifications.dismissPopup(n.id) }, size = 40.dp, iconSize = 20.dp) GsIconButton(Icons.Filled.Close, stringResource(R.string.msg_dismiss), { CarNotifications.dismissPopup(n.id) }, size = 40.dp, iconSize = 20.dp)
} }
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
GsButton(onClick = { VoiceReply.read(n) }, tone = GsTone.Neutral, modifier = Modifier.weight(1f)) { GsButton(onClick = { VoiceReply.read(n) }, tone = GsTone.Neutral, modifier = Modifier.weight(1f)) {
ReadGlyph(Modifier.size(20.dp)) ReadGlyph(Modifier.size(20.dp))
Text("Read", Modifier.padding(start = 8.dp)) Text(stringResource(R.string.msg_read), Modifier.padding(start = 8.dp))
} }
if (n.reply != null) { if (n.reply != null) {
GsButton(onClick = { VoiceReply.reply(n) }, modifier = Modifier.weight(1f)) { GsButton(onClick = { VoiceReply.reply(n) }, modifier = Modifier.weight(1f)) {
ReplyGlyph(Modifier.size(20.dp)) ReplyGlyph(Modifier.size(20.dp))
Text("Reply", Modifier.padding(start = 8.dp)) Text(stringResource(R.string.crumb_reply), Modifier.padding(start = 8.dp))
} }
} }
} }
@@ -191,36 +194,36 @@ private fun VoiceReplyBody(s: VoiceReply.State, n: CarNotification) {
when (s.phase) { when (s.phase) {
VoiceReply.Phase.READING, VoiceReply.Phase.READ -> { VoiceReply.Phase.READING, VoiceReply.Phase.READ -> {
GsButton(onClick = VoiceReply::cancel, tone = GsTone.Neutral, modifier = wide) { GsButton(onClick = VoiceReply::cancel, tone = GsTone.Neutral, modifier = wide) {
Text(if (s.phase == VoiceReply.Phase.READING) "Stop" else "Close") Text(if (s.phase == VoiceReply.Phase.READING) stringResource(R.string.msg_stop) else stringResource(R.string.close))
} }
if (n.reply != null) { if (n.reply != null) {
GsButton(onClick = { VoiceReply.reply(n, readFirst = false) }, modifier = wide) { GsButton(onClick = { VoiceReply.reply(n, readFirst = false) }, modifier = wide) {
ReplyGlyph(Modifier.size(20.dp)) ReplyGlyph(Modifier.size(20.dp))
Text("Reply", Modifier.padding(start = 8.dp)) Text(stringResource(R.string.crumb_reply), Modifier.padding(start = 8.dp))
} }
} }
} }
VoiceReply.Phase.OFFERING -> { VoiceReply.Phase.OFFERING -> {
GsButton(onClick = VoiceReply::cancel, tone = GsTone.Neutral, modifier = wide) { Text("No") } GsButton(onClick = VoiceReply::cancel, tone = GsTone.Neutral, modifier = wide) { Text(stringResource(R.string.msg_no)) }
GsButton(onClick = { VoiceReply.reply(n, readFirst = false) }, modifier = wide) { GsButton(onClick = { VoiceReply.reply(n, readFirst = false) }, modifier = wide) {
ReplyGlyph(Modifier.size(20.dp)) ReplyGlyph(Modifier.size(20.dp))
Text("Reply", Modifier.padding(start = 8.dp)) Text(stringResource(R.string.crumb_reply), Modifier.padding(start = 8.dp))
} }
} }
VoiceReply.Phase.ASKING, VoiceReply.Phase.LISTENING -> VoiceReply.Phase.ASKING, VoiceReply.Phase.LISTENING ->
GsButton(onClick = VoiceReply::cancel, tone = GsTone.Neutral, modifier = wide) { Text("Cancel") } GsButton(onClick = VoiceReply::cancel, tone = GsTone.Neutral, modifier = wide) { Text(stringResource(R.string.cancel)) }
VoiceReply.Phase.CONFIRMING -> { VoiceReply.Phase.CONFIRMING -> {
GsButton(onClick = VoiceReply::cancel, tone = GsTone.Neutral, modifier = wide) { Text("Cancel") } GsButton(onClick = VoiceReply::cancel, tone = GsTone.Neutral, modifier = wide) { Text(stringResource(R.string.cancel)) }
GsButton(onClick = VoiceReply::sendNow, modifier = wide) { Text("Send now") } GsButton(onClick = VoiceReply::sendNow, modifier = wide) { Text(stringResource(R.string.msg_send_now)) }
} }
VoiceReply.Phase.ERROR -> { VoiceReply.Phase.ERROR -> {
GsButton(onClick = VoiceReply::cancel, tone = GsTone.Neutral, modifier = wide) { Text("Close") } GsButton(onClick = VoiceReply::cancel, tone = GsTone.Neutral, modifier = wide) { Text(stringResource(R.string.close)) }
if (n.reply != null) { if (n.reply != null) {
GsButton(onClick = { VoiceReply.reply(n, readFirst = false) }, tone = GsTone.Tonal, modifier = wide) { Text("Try again") } GsButton(onClick = { VoiceReply.reply(n, readFirst = false) }, tone = GsTone.Tonal, modifier = wide) { Text(stringResource(R.string.update_try_again)) }
GsButton( GsButton(
onClick = { VoiceReply.cancel(); navigator.notifications(replyTo = n.key) }, onClick = { VoiceReply.cancel(); navigator.notifications(replyTo = n.key) },
tone = GsTone.Neutral, modifier = wide, tone = GsTone.Neutral, modifier = wide,
) { Text("Type") } ) { Text(stringResource(R.string.msg_type)) }
} }
} }
VoiceReply.Phase.SENT, VoiceReply.Phase.IDLE -> Unit VoiceReply.Phase.SENT, VoiceReply.Phase.IDLE -> Unit
@@ -228,13 +231,14 @@ private fun VoiceReplyBody(s: VoiceReply.State, n: CarNotification) {
} }
} }
@Composable
private fun voiceHeading(s: VoiceReply.State, who: String): String = when (s.phase) { private fun voiceHeading(s: VoiceReply.State, who: String): String = when (s.phase) {
VoiceReply.Phase.READING, VoiceReply.Phase.READ -> who VoiceReply.Phase.READING, VoiceReply.Phase.READ -> who
VoiceReply.Phase.OFFERING, VoiceReply.Phase.ASKING -> "Reply to $who" VoiceReply.Phase.OFFERING, VoiceReply.Phase.ASKING -> stringResource(R.string.msg_reply_to, who)
VoiceReply.Phase.LISTENING -> "Listening" VoiceReply.Phase.LISTENING -> stringResource(R.string.msg_listening)
VoiceReply.Phase.CONFIRMING -> "Sending to $who" VoiceReply.Phase.CONFIRMING -> stringResource(R.string.msg_sending_to, who)
VoiceReply.Phase.SENT -> "Sent to $who" VoiceReply.Phase.SENT -> stringResource(R.string.msg_sent_to, who)
VoiceReply.Phase.ERROR -> "Couldn't reply" VoiceReply.Phase.ERROR -> stringResource(R.string.msg_reply_failed)
VoiceReply.Phase.IDLE -> "" VoiceReply.Phase.IDLE -> ""
} }
@@ -1,5 +1,8 @@
package app.seb3thehacker.gearslip.car package app.seb3thehacker.gearslip.car
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import app.seb3thehacker.gearslip.R
import app.seb3thehacker.gearslip.car.theme.* import app.seb3thehacker.gearslip.car.theme.*
import android.content.Intent import android.content.Intent
import android.provider.Settings import android.provider.Settings
@@ -8,6 +11,8 @@ import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
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.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@@ -60,14 +65,14 @@ fun MessagesScreen(packageName: String) {
val navigator = LocalCarNavigator.current val navigator = LocalCarNavigator.current
val context = LocalContext.current.applicationContext val context = LocalContext.current.applicationContext
val entry = remember(packageName) { findEntry(MESSAGING_PREFIX + packageName) } val entry = remember(packageName) { findEntry(MESSAGING_PREFIX + packageName) }
val label = entry?.label ?: history.firstOrNull { it.packageName == packageName }?.appLabel ?: "Messages" val label = entry?.label ?: history.firstOrNull { it.packageName == packageName }?.appLabel ?: stringResource(R.string.msg_messages)
val threads = history.filter { it.packageName == packageName } val threads = history.filter { it.packageName == packageName }
LaunchedEffect(history) { CarNotifications.dismissPopupFrom(packageName) } LaunchedEffect(history) { CarNotifications.dismissPopupFrom(packageName) }
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { Column(Modifier.fillMaxSize().padding(start = 16.dp, end = 16.dp, top = 16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
GsIconButton(Icons.AutoMirrored.Filled.ArrowBack, "Back", navigator::back) GsIconButton(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back), navigator::back)
entry?.icon?.let { Image(it, contentDescription = null, modifier = Modifier.size(36.dp).clip(RoundedCornerShape(8.dp))) } entry?.icon?.let { Image(it, contentDescription = null, modifier = Modifier.size(36.dp).clip(RoundedCornerShape(8.dp))) }
Text(label, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) Text(label, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
} }
@@ -82,7 +87,7 @@ fun MessagesScreen(packageName: String) {
if (threads.isEmpty()) { if (threads.isEmpty()) {
Text( Text(
"No recent messages. New $label messages appear here as they arrive on the phone.", stringResource(R.string.msg_none_recent, label),
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@@ -104,6 +109,7 @@ fun MessagesScreen(packageName: String) {
} }
} }
@OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
private fun ConversationCard(n: CarNotification, onRead: () -> Unit, onReply: () -> Unit, onMarkRead: () -> Unit) { private fun ConversationCard(n: CarNotification, onRead: () -> Unit, onReply: () -> Unit, onMarkRead: () -> Unit) {
var expanded by rememberSaveable(n.key) { mutableStateOf(false) } var expanded by rememberSaveable(n.key) { mutableStateOf(false) }
@@ -143,40 +149,48 @@ private fun ConversationCard(n: CarNotification, onRead: () -> Unit, onReply: ()
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1, maxLines = 1,
) )
if (n.replied) {
Icon(
Icons.Filled.Check,
contentDescription = stringResource(R.string.notif_replied),
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 8.dp).size(20.dp),
)
}
} }
if (!expanded && lines.size > COLLAPSED_LINES) { if (!expanded && lines.size > COLLAPSED_LINES) {
Text( Text(
"${lines.size - COLLAPSED_LINES} earlier", pluralStringResource(R.plurals.msg_earlier, lines.size - COLLAPSED_LINES, lines.size - COLLAPSED_LINES),
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
) )
} }
shown.forEach { MessageLine(it, showSender = n.isGroup) } shown.forEach { MessageLine(it, showSender = n.isGroup) }
// Below the messages and side by side, so the text keeps the card's whole width.
FlowRow(
Modifier.padding(top = 6.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
GsButton(onClick = onRead, tone = GsTone.Neutral) {
ReadGlyph(Modifier.size(20.dp))
Text(stringResource(R.string.msg_read), Modifier.padding(start = 8.dp))
}
if (n.reply != null) {
GsButton(onClick = onReply, tone = GsTone.Tonal) {
ReplyGlyph(Modifier.size(20.dp))
Text(stringResource(R.string.crumb_reply), Modifier.padding(start = 8.dp))
}
GsButton(onClick = { quick = !quick }, tone = GsTone.Neutral) {
Text(stringResource(if (quick) R.string.msg_hide_quick else R.string.msg_quick))
}
}
if (n.markRead != null) {
GsButton(onClick = onMarkRead, tone = GsTone.Neutral) { Text(stringResource(R.string.msg_mark_read)) }
}
}
if (quick) QuickReplies(n, Modifier.padding(top = 6.dp)) if (quick) QuickReplies(n, Modifier.padding(top = 6.dp))
} }
Column(horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(8.dp)) {
if (n.replied) {
Icon(Icons.Filled.Check, contentDescription = "Replied", tint = MaterialTheme.colorScheme.primary)
}
GsButton(onClick = onRead, tone = GsTone.Neutral) {
ReadGlyph(Modifier.size(20.dp))
Text("Read", Modifier.padding(start = 8.dp))
}
if (n.reply != null) {
GsButton(onClick = onReply, tone = GsTone.Tonal) {
ReplyGlyph(Modifier.size(20.dp))
Text("Reply", Modifier.padding(start = 8.dp))
}
}
if (n.reply != null) {
GsButton(onClick = { quick = !quick }, tone = GsTone.Neutral) {
Text(if (quick) "Hide quick replies" else "Quick reply")
}
}
if (n.markRead != null) {
GsButton(onClick = onMarkRead, tone = GsTone.Neutral) { Text("Mark read") }
}
}
} }
} }
} }
@@ -184,8 +198,8 @@ private fun ConversationCard(n: CarNotification, onRead: () -> Unit, onReply: ()
@Composable @Composable
private fun MessageLine(line: ChatLine, showSender: Boolean) { private fun MessageLine(line: ChatLine, showSender: Boolean) {
val who = when { val who = when {
line.sender == null -> "You: " line.sender == null -> stringResource(R.string.msg_you)
showSender -> "${line.sender}: " showSender -> stringResource(R.string.msg_sender, line.sender)
else -> "" else -> ""
} }
Text( Text(
@@ -1,5 +1,8 @@
package app.seb3thehacker.gearslip.car package app.seb3thehacker.gearslip.car
import androidx.annotation.StringRes
import androidx.compose.ui.res.stringResource
import app.seb3thehacker.gearslip.R
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@@ -16,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
@@ -65,26 +69,49 @@ private val CarGlyph: ImageVector = ImageVector.Builder("Car", 24.dp, 24.dp, 24f
*/ */
internal class BuiltInApp( internal class BuiltInApp(
val id: String, val id: String,
/** English, for matching what a driver says; [labelRes] is what the screen shows. */
val label: String, val label: String,
@param:StringRes val labelRes: Int,
val glyph: ImageVector, val glyph: ImageVector,
val screen: CarScreen?, val screen: CarScreen?,
val open: (CarNavigator) -> Unit, val open: (CarNavigator) -> Unit,
) )
internal object BuiltInApps { internal object BuiltInApps {
val Web = BuiltInApp("web", "Browser", Icons.Filled.Search, CarScreen.App("web")) { it.open("web") } val Web = BuiltInApp("web", "Browser", R.string.app_browser, Icons.Filled.Search, CarScreen.App("web")) { it.open("web") }
val ScreenSharing = BuiltInApp("phone", "Screen sharing", Icons.Filled.Share, CarScreen.App("phone")) { it.open("phone") } val ScreenSharing = BuiltInApp("phone", "Screen sharing", R.string.app_screen_sharing, Icons.Filled.Share, CarScreen.App("phone")) { it.open("phone") }
val Phone = BuiltInApp("dialer", "Phone", Icons.Filled.Call, CarScreen.App("dialer")) { it.open("dialer") } val Phone = BuiltInApp("dialer", "Phone", R.string.carset_phone, Icons.Filled.Call, CarScreen.App("dialer")) { it.open("dialer") }
val VehicleData = BuiltInApp("vehicle", "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", 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", 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. */
@Composable
internal fun Entry.shownLabel(): String = builtIn?.let { stringResource(it.labelRes) } ?: label
/** Vehicle data is experimental: raw, unverified sensor labels (see [CarSensors]). */ /** Vehicle data is experimental: raw, unverified sensor labels (see [CarSensors]). */
@Composable @Composable
internal fun rememberVehicleDataShown(): Boolean { internal fun rememberVehicleDataShown(): Boolean {
@@ -215,7 +242,7 @@ internal fun AppMenu(navigator: CarNavigator, running: Map<String, RunningSlot>)
entry.icon?.let { Image(it, null, Modifier.size(44.dp).clip(RoundedCornerShape(10.dp))) } entry.icon?.let { Image(it, null, Modifier.size(44.dp).clip(RoundedCornerShape(10.dp))) }
entry.builtIn?.let { Icon(it.glyph, null, Modifier.size(36.dp)) } entry.builtIn?.let { Icon(it.glyph, null, Modifier.size(36.dp)) }
Text( Text(
entry.label, entry.shownLabel(),
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(start = 14.dp), modifier = Modifier.padding(start = 14.dp),
@@ -232,9 +259,9 @@ internal fun AppMenu(navigator: CarNavigator, running: Map<String, RunningSlot>)
) { ) {
Text( Text(
when { when {
isPinned -> "Unpin from nav bar" isPinned -> stringResource(R.string.navapps_unpin)
barFull -> "Nav bar is full (${CarSettings.MAX_PINNED} pinned)" barFull -> stringResource(R.string.navapps_full, CarSettings.MAX_PINNED)
else -> "Pin to nav bar" else -> stringResource(R.string.navapps_pin)
}, },
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
) )
@@ -248,13 +275,13 @@ internal fun AppMenu(navigator: CarNavigator, running: Map<String, RunningSlot>)
}, },
tone = GsTone.Danger, tone = GsTone.Danger,
modifier = Modifier.fillMaxWidth().height(56.dp), modifier = Modifier.fillMaxWidth().height(56.dp),
) { Text("Close app", style = MaterialTheme.typography.titleMedium) } ) { Text(stringResource(R.string.navapps_close), style = MaterialTheme.typography.titleMedium) }
} }
GsButton( GsButton(
onClick = navigator::dismissAppMenu, onClick = navigator::dismissAppMenu,
tone = GsTone.Neutral, tone = GsTone.Neutral,
modifier = Modifier.fillMaxWidth().height(56.dp), modifier = Modifier.fillMaxWidth().height(56.dp),
) { Text("Cancel", style = MaterialTheme.typography.titleMedium) } ) { Text(stringResource(R.string.cancel), style = MaterialTheme.typography.titleMedium) }
} }
} }
} }
@@ -267,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) {
@@ -281,19 +309,19 @@ private fun MoveButtons(id: String) {
tone = GsTone.Tonal, tone = GsTone.Tonal,
enabled = index > 0, enabled = index > 0,
modifier = Modifier.weight(1f).height(56.dp), modifier = Modifier.weight(1f).height(56.dp),
) { Text("Move left", style = MaterialTheme.typography.titleMedium) } ) { Text(stringResource(R.string.navapps_move_left), style = MaterialTheme.typography.titleMedium) }
GsButton( GsButton(
onClick = { move(1) }, onClick = { move(1) },
tone = GsTone.Tonal, tone = GsTone.Tonal,
enabled = index < order.lastIndex, enabled = index < order.lastIndex,
modifier = Modifier.weight(1f).height(56.dp), modifier = Modifier.weight(1f).height(56.dp),
) { Text("Move right", style = MaterialTheme.typography.titleMedium) } ) { Text(stringResource(R.string.navapps_move_right), style = MaterialTheme.typography.titleMedium) }
} }
if (custom.isNotEmpty()) { if (custom.isNotEmpty()) {
GsButton( GsButton(
onClick = { CarSettings.setAppOrder(emptyList()) }, onClick = { CarSettings.setAppOrder(emptyList()) },
tone = GsTone.Neutral, tone = GsTone.Neutral,
modifier = Modifier.fillMaxWidth().height(56.dp), modifier = Modifier.fillMaxWidth().height(56.dp),
) { Text("Reset app order", style = MaterialTheme.typography.titleMedium) } ) { Text(stringResource(R.string.navapps_reset), style = MaterialTheme.typography.titleMedium) }
} }
} }
@@ -1,5 +1,7 @@
package app.seb3thehacker.gearslip.car package app.seb3thehacker.gearslip.car
import androidx.compose.ui.res.stringResource
import app.seb3thehacker.gearslip.R
import app.seb3thehacker.gearslip.car.theme.* import app.seb3thehacker.gearslip.car.theme.*
import android.content.Intent import android.content.Intent
import android.provider.Settings import android.provider.Settings
@@ -79,12 +81,12 @@ fun NotificationsSection() {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Text( Text(
"Notifications", stringResource(R.string.notif_title),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
if (history.isNotEmpty()) TextButton(onClick = CarNotifications::clear) { Text("Clear all") } if (history.isNotEmpty()) TextButton(onClick = CarNotifications::clear) { Text(stringResource(R.string.notif_clear_all)) }
} }
if (!listening) { if (!listening) {
@@ -97,7 +99,7 @@ fun NotificationsSection() {
if (history.isEmpty()) { if (history.isEmpty()) {
Text( Text(
"Nothing yet. New notifications from the phone will show up here.", stringResource(R.string.notif_empty),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@@ -124,11 +126,11 @@ internal fun AccessBanner(onOpen: () -> Unit) {
) { ) {
Icon(Icons.Filled.Warning, contentDescription = null) Icon(Icons.Filled.Warning, contentDescription = null)
Text( Text(
"Notifications are off. Allow Gearslip under Settings > Notifications > Notification access on the phone.", stringResource(R.string.notif_off),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
GsButton(onClick = onOpen) { Text("Open") } GsButton(onClick = onOpen) { Text(stringResource(R.string.guide_open)) }
} }
} }
} }
@@ -160,10 +162,10 @@ private fun NotificationRow(n: CarNotification, onReply: (CarNotification) -> Un
} }
} }
when { when {
n.replied -> Icon(Icons.Filled.Check, contentDescription = "Replied", tint = MaterialTheme.colorScheme.primary) n.replied -> Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.notif_replied), tint = MaterialTheme.colorScheme.primary)
n.reply != null -> GsButton(onClick = { VoiceReply.reply(n) }, tone = GsTone.Tonal) { n.reply != null -> GsButton(onClick = { VoiceReply.reply(n) }, tone = GsTone.Tonal) {
ReplyGlyph(Modifier.size(20.dp)) ReplyGlyph(Modifier.size(20.dp))
Text("Reply", Modifier.padding(start = 8.dp)) Text(stringResource(R.string.crumb_reply), Modifier.padding(start = 8.dp))
} }
} }
} }
@@ -198,14 +200,14 @@ private fun ReplyPane(n: CarNotification, onDone: () -> Unit) {
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
GsIconButton(Icons.AutoMirrored.Filled.ArrowBack, "Back", onDone) GsIconButton(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back), onDone)
Surface( Surface(
shape = RoundedCornerShape(24.dp), shape = RoundedCornerShape(24.dp),
color = MaterialTheme.colorScheme.surfaceContainerHighest, color = MaterialTheme.colorScheme.surfaceContainerHighest,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) { ) {
Text( Text(
if (text.isEmpty()) "Reply to ${n.title.ifBlank { n.appLabel }}" else "$text|", if (text.isEmpty()) stringResource(R.string.msg_reply_to, n.title.ifBlank { n.appLabel }) else "$text|",
color = if (text.isEmpty()) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface, color = if (text.isEmpty()) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
@@ -1,5 +1,7 @@
package app.seb3thehacker.gearslip.car package app.seb3thehacker.gearslip.car
import androidx.compose.ui.res.stringResource
import app.seb3thehacker.gearslip.R
import app.seb3thehacker.gearslip.car.theme.* import app.seb3thehacker.gearslip.car.theme.*
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -52,8 +54,8 @@ fun PhoneAppsScreen() {
value = PhoneApps.cached() ?: withContext(Dispatchers.IO) { PhoneApps.installed(context).also { PhoneApps.warm(context) } } value = PhoneApps.cached() ?: withContext(Dispatchers.IO) { PhoneApps.installed(context).also { PhoneApps.warm(context) } }
} }
Column(Modifier.fillMaxSize().padding(horizontal = 20.dp, vertical = 12.dp)) { Column(Modifier.fillMaxSize().padding(start = 20.dp, end = 20.dp, top = 12.dp)) {
ScreenHeader("Screen sharing") ScreenHeader(stringResource(R.string.app_screen_sharing))
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
if (!TouchRelayService.enabled) { if (!TouchRelayService.enabled) {
TouchRelayWarning() TouchRelayWarning()
@@ -63,7 +65,7 @@ fun PhoneAppsScreen() {
LazyVerticalGrid( LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 108.dp), columns = GridCells.Adaptive(minSize = 108.dp),
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 8.dp), contentPadding = PaddingValues(bottom = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
@@ -94,17 +96,17 @@ private fun TouchRelayWarning() {
) { ) {
Icon(Icons.Filled.Warning, contentDescription = null) Icon(Icons.Filled.Warning, contentDescription = null)
Text( Text(
"Touch is off. Enable Gearslip under Settings > Accessibility on the phone.", stringResource(R.string.phoneapps_touch_off),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
GsButton(onClick = { PhoneApps.openAccessibilitySettings(context) }) { Text("Open") } GsButton(onClick = { PhoneApps.openAccessibilitySettings(context) }) { Text(stringResource(R.string.guide_open)) }
} }
} }
} }
@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),
@@ -1,5 +1,7 @@
package app.seb3thehacker.gearslip.car package app.seb3thehacker.gearslip.car
import androidx.compose.ui.res.stringResource
import app.seb3thehacker.gearslip.R
import app.seb3thehacker.gearslip.car.theme.* import app.seb3thehacker.gearslip.car.theme.*
import android.Manifest import android.Manifest
import android.content.Context import android.content.Context
@@ -12,6 +14,7 @@ import app.seb3thehacker.gearslip.GearslipLog
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.PaddingValues
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
@@ -136,13 +139,13 @@ fun PhoneDialerScreen() {
var hasPermissions by remember { mutableStateOf(hasCallPermissions(context)) } var hasPermissions by remember { mutableStateOf(hasCallPermissions(context)) }
var tab by remember { mutableStateOf(TAB_CONTACTS) } var tab by remember { mutableStateOf(TAB_CONTACTS) }
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { Column(Modifier.fillMaxSize().padding(start = 16.dp, end = 16.dp, top = 16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
// The tabs share the title's row, so the list and keypad get the height back. // The tabs share the title's row, so the list and keypad get the height back.
ScreenHeader("Phone") { ScreenHeader(stringResource(R.string.carset_phone)) {
if (hasPermissions) { if (hasPermissions) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
DialerTab("Contacts", tab == TAB_CONTACTS, Modifier.width(140.dp)) { tab = TAB_CONTACTS } DialerTab(stringResource(R.string.dialer_contacts), tab == TAB_CONTACTS, Modifier.width(140.dp)) { tab = TAB_CONTACTS }
DialerTab("Keypad", tab == TAB_KEYPAD, Modifier.width(140.dp)) { tab = TAB_KEYPAD } DialerTab(stringResource(R.string.dialer_keypad), tab == TAB_KEYPAD, Modifier.width(140.dp)) { tab = TAB_KEYPAD }
} }
} }
} }
@@ -153,7 +156,7 @@ fun PhoneDialerScreen() {
} }
Box(Modifier.weight(1f).fillMaxWidth()) { Box(Modifier.weight(1f).fillMaxWidth()) {
if (tab == TAB_CONTACTS) ContactsList(context) else Keypad(context) if (tab == TAB_CONTACTS) ContactsList(context) else Box(Modifier.padding(bottom = 16.dp)) { Keypad(context) }
} }
} }
} }
@@ -168,14 +171,13 @@ private fun MissingPermission(context: Context, onGranted: () -> Unit) {
} }
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text( Text(
"Gearslip needs the Phone and Contacts permissions to dial from here. Allow them on " + stringResource(R.string.dialer_needs_permission),
"the phone when you're parked.",
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
GsButton(tone = GsTone.Tonal, onClick = { GsButton(tone = GsTone.Tonal, onClick = {
app.seb3thehacker.gearslip.PermissionActivity.ask(context, CALL_PERMISSIONS) app.seb3thehacker.gearslip.PermissionActivity.ask(context, CALL_PERMISSIONS)
}) { Text("Ask on the phone") } }) { Text(stringResource(R.string.dialer_ask)) }
} }
} }
@@ -211,7 +213,7 @@ private fun Keypad(context: Context) {
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(12.dp)) { Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text( Text(
number.ifEmpty { "Enter a number" }, number.ifEmpty { stringResource(R.string.dialer_enter_number) },
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineSmall,
color = if (number.isEmpty()) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface, color = if (number.isEmpty()) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface,
maxLines = 1, maxLines = 1,
@@ -240,7 +242,7 @@ private fun Keypad(context: Context) {
colors = gsColors(GsTone.Primary), colors = gsColors(GsTone.Primary),
enabled = number.isNotEmpty(), enabled = number.isNotEmpty(),
modifier = Modifier.weight(1f).fillMaxWidth(), modifier = Modifier.weight(1f).fillMaxWidth(),
) { Icon(Icons.Filled.Call, "Call") } ) { Icon(Icons.Filled.Call, stringResource(R.string.dialer_call)) }
} }
} }
} }
@@ -282,16 +284,16 @@ private fun ContactsList(context: Context) {
Box(Modifier.weight(1f).fillMaxWidth()) { Box(Modifier.weight(1f).fillMaxWidth()) {
when { when {
contacts.isEmpty() -> Text( contacts.isEmpty() -> Text(
"No contacts with a phone number found.", stringResource(R.string.dialer_no_contacts),
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
filtered.isEmpty() -> Text( filtered.isEmpty() -> Text(
"No contacts match \"$query\".", stringResource(R.string.dialer_no_match, query),
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
else -> LazyColumn(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) { else -> LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(bottom = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(filtered) { contact -> items(filtered) { contact ->
ContactRow(contact) { pendingCall = contact } ContactRow(contact) { pendingCall = contact }
} }
@@ -344,14 +346,14 @@ private fun ConfirmCallOverlay(contact: Contact, onConfirm: () -> Unit, onDismis
verticalArrangement = Arrangement.spacedBy(20.dp), verticalArrangement = Arrangement.spacedBy(20.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
Text("Call ${contact.name}?", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) Text(stringResource(R.string.dialer_call_who, contact.name), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
Text(contact.number, style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurfaceVariant) Text(contact.number, style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(16.dp)) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(16.dp)) {
GsButton(onClick = onDismiss, tone = GsTone.Outline, modifier = Modifier.weight(1f).height(56.dp)) { GsButton(onClick = onDismiss, tone = GsTone.Outline, modifier = Modifier.weight(1f).height(56.dp)) {
Text("Cancel", style = MaterialTheme.typography.titleMedium) Text(stringResource(R.string.cancel), style = MaterialTheme.typography.titleMedium)
} }
GsButton(onClick = onConfirm, modifier = Modifier.weight(1f).height(56.dp)) { GsButton(onClick = onConfirm, modifier = Modifier.weight(1f).height(56.dp)) {
Text("Call", style = MaterialTheme.typography.titleMedium) Text(stringResource(R.string.dialer_call), style = MaterialTheme.typography.titleMedium)
} }
} }
} }
@@ -368,7 +370,7 @@ private fun ContactSearchField(query: String, active: Boolean, onToggle: () -> U
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
Text( Text(
query.ifEmpty { "Search contacts" }, query.ifEmpty { stringResource(R.string.dialer_search) },
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = if (query.isEmpty()) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface, color = if (query.isEmpty()) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface,
maxLines = 1, maxLines = 1,
@@ -406,7 +408,7 @@ private fun ContactRow(contact: Contact, onClick: () -> Unit) {
Text(contact.name, style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis) Text(contact.name, style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(contact.number, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) Text(contact.number, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
} }
Icon(Icons.Filled.Call, contentDescription = "Call", tint = MaterialTheme.colorScheme.primary) Icon(Icons.Filled.Call, contentDescription = stringResource(R.string.dialer_call), tint = MaterialTheme.colorScheme.primary)
} }
} }
} }
@@ -1,5 +1,7 @@
package app.seb3thehacker.gearslip.car package app.seb3thehacker.gearslip.car
import androidx.compose.ui.res.stringResource
import app.seb3thehacker.gearslip.R
import android.media.session.PlaybackState import android.media.session.PlaybackState
import android.support.v4.media.session.PlaybackStateCompat import android.support.v4.media.session.PlaybackStateCompat
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -94,23 +96,23 @@ internal fun formatDuration(ms: Long): String {
return if (hours > 0) "%d:%02d:%02d".format(hours, minutes, seconds) else "%d:%02d".format(minutes, seconds) return if (hours > 0) "%d:%02d:%02d".format(hours, minutes, seconds) else "%d:%02d".format(minutes, seconds)
} }
/** Previous, play/pause, next. [skip] sizes the outer two; play is a step bigger. */ /** Previous, play/pause, next. [skip] sizes the outer two; play is [playScale] times bigger. */
@Composable @Composable
internal fun TransportRow(now: NowPlaying, media: CarMedia, skip: Dp, modifier: Modifier = Modifier) { internal fun TransportRow(now: NowPlaying, media: CarMedia, skip: Dp, modifier: Modifier = Modifier, playScale: Float = 1.25f) {
val play = skip * 1.25f val play = skip * playScale
Row(modifier, horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.CenterVertically) { Row(modifier, horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.CenterVertically) {
GsIconButton( GsIconButton(
MediaIcons.Previous, "Previous", media::previous, MediaIcons.Previous, stringResource(R.string.player_previous), media::previous,
enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_PREVIOUS), size = skip, iconSize = skip * 0.58f, shape = CircleShape, enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_PREVIOUS), size = skip, iconSize = skip * 0.58f, shape = CircleShape,
) )
GsIconButton( GsIconButton(
if (now.playing) MediaIcons.Pause else Icons.Filled.PlayArrow, if (now.playing) MediaIcons.Pause else Icons.Filled.PlayArrow,
if (now.playing) "Pause" else "Play", stringResource(if (now.playing) R.string.player_pause else R.string.media_play),
media::togglePlay, media::togglePlay,
tone = GsTone.Primary, size = play, iconSize = play * 0.58f, shape = CircleShape, tone = GsTone.Primary, size = play, iconSize = play * 0.58f, shape = CircleShape,
) )
GsIconButton( GsIconButton(
MediaIcons.Next, "Next", media::next, MediaIcons.Next, stringResource(R.string.player_next), media::next,
enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_NEXT), size = skip, iconSize = skip * 0.58f, shape = CircleShape, enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_NEXT), size = skip, iconSize = skip * 0.58f, shape = CircleShape,
) )
} }
@@ -143,11 +145,11 @@ internal fun PlayModeRow(
// Gearslip's own stand in for apps that report a heart rating or shuffle and repeat modes. // Gearslip's own stand in for apps that report a heart rating or shuffle and repeat modes.
val likeAction = now.likeAction val likeAction = now.likeAction
if (likeAction != null) { if (likeAction != null) {
CustomActionButton(likeAction, media, size, toggle = true) CustomActionButton(likeAction, media, size, on = liked == true)
} else { } else {
GsIconButton( GsIconButton(
if (liked == true) Icons.Filled.Favorite else Icons.Filled.FavoriteBorder, if (liked == true) Icons.Filled.Favorite else Icons.Filled.FavoriteBorder,
if (liked == true) "Unlike" else "Like", stringResource(if (liked == true) R.string.player_unlike else R.string.player_like),
media::toggleLike, media::toggleLike,
enabled = liked != null, enabled = liked != null,
tone = if (liked == true) GsTone.Primary else GsTone.Neutral, tone = if (liked == true) GsTone.Primary else GsTone.Neutral,
@@ -156,10 +158,10 @@ internal fun PlayModeRow(
} }
val shuffleAction = now.shuffleAction val shuffleAction = now.shuffleAction
if (shuffleAction != null) { if (shuffleAction != null) {
CustomActionButton(shuffleAction, media, size, toggle = true) CustomActionButton(shuffleAction, media, size, on = shuffleAction.on)
} else { } else {
GsIconButton( GsIconButton(
MediaIcons.Shuffle, if (shuffleOn) "Shuffle off" else "Shuffle on", MediaIcons.Shuffle, stringResource(if (shuffleOn) R.string.player_shuffle_off else R.string.player_shuffle_on),
media::toggleShuffle, media::toggleShuffle,
enabled = shuffleKnown, enabled = shuffleKnown,
tone = if (shuffleOn) GsTone.Primary else GsTone.Neutral, tone = if (shuffleOn) GsTone.Primary else GsTone.Neutral,
@@ -168,11 +170,11 @@ internal fun PlayModeRow(
} }
val repeatAction = now.repeatAction val repeatAction = now.repeatAction
if (repeatAction != null) { if (repeatAction != null) {
CustomActionButton(repeatAction, media, size, toggle = true) CustomActionButton(repeatAction, media, size, on = repeatAction.on)
} else { } else {
GsIconButton( GsIconButton(
if (modes.repeat == PlaybackStateCompat.REPEAT_MODE_ONE) MediaIcons.RepeatOne else MediaIcons.Repeat, if (modes.repeat == PlaybackStateCompat.REPEAT_MODE_ONE) MediaIcons.RepeatOne else MediaIcons.Repeat,
"Repeat", stringResource(R.string.player_repeat),
media::cycleRepeat, media::cycleRepeat,
enabled = repeatKnown, enabled = repeatKnown,
tone = if (repeatOn) GsTone.Primary else GsTone.Neutral, tone = if (repeatOn) GsTone.Primary else GsTone.Neutral,
@@ -189,12 +191,12 @@ internal fun PlayModeRow(
* not Gearslip's, so it's loaded from there. Its name stands in if the icon won't load. * not Gearslip's, so it's loaded from there. Its name stands in if the icon won't load.
*/ */
@Composable @Composable
private fun CustomActionButton(action: CustomAction, media: CarMedia, size: Dp, toggle: Boolean = false) { private fun CustomActionButton(action: CustomAction, media: CarMedia, size: Dp, on: Boolean = false) {
val icon = rememberActionIcon(action) val icon = rememberActionIcon(action)
GsIconBox( GsIconBox(
onClick = { media.custom(action) }, onClick = { media.custom(action) },
modifier = Modifier.size(size), modifier = Modifier.size(size),
colors = gsColors(if (toggle && action.on) GsTone.Primary else GsTone.Neutral), colors = gsColors(if (on) GsTone.Primary else GsTone.Neutral),
) { ) {
if (icon != null) { if (icon != null) {
Icon(icon, action.name, Modifier.size(size * 0.52f)) Icon(icon, action.name, Modifier.size(size * 0.52f))
@@ -229,26 +231,48 @@ internal fun rememberArt(now: NowPlaying): android.graphics.Bitmap? {
} }
/** /**
* A background taken from the album art's main colour, in place of [fallback]. Only the hue and a * Three soft pools of the cover's colours: top left, top right and the middle, read off a 3x3
* capped amount of its saturation come from the art; the lightness stays close to the theme's * shrink of it. Still, and redrawn only while a new song's colours fade in, so it costs the video
* own ([darkLightness] or [lightLightness]), so text drawn in the theme's colours reads the same * stream nothing between songs. Fainter on a light card, where colour shows more. [wide] is for
* on any cover. Art with no real colour in it keeps [fallback]. Fades between songs rather than * a whole screen: the pools gather on its left, behind the cover, and reach less far across it.
* jumping.
*/ */
@Composable @Composable
internal fun rememberArtColor( internal fun AlbumGlow(art: android.graphics.Bitmap?, modifier: Modifier, wide: Boolean = false) {
art: android.graphics.Bitmap?, val spots by produceState<List<Color>>(emptyList(), art) {
fallback: Color, value = art?.let { kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Default) { glowColours(it) } }.orEmpty()
darkLightness: Float = 0.2f, }
lightLightness: Float = 0.86f, val strength = if (MaterialTheme.colorScheme.surface.luminance() < 0.5f) 0.30f else 0.18f
): Color { val fade = tween<Color>(600)
val dark = fallback.luminance() < 0.5f val first by androidx.compose.animation.animateColorAsState(spots.getOrNull(0)?.copy(alpha = strength) ?: Color.Transparent, fade, label = "glow")
val tint by produceState<ArtColor.Tint?>(null, art) { val second by androidx.compose.animation.animateColorAsState(spots.getOrNull(1)?.copy(alpha = strength) ?: Color.Transparent, fade, label = "glow")
value = art?.let { withContext(Dispatchers.Default) { runCatching { ArtColor.of(it) }.getOrNull() } } val third by androidx.compose.animation.animateColorAsState(spots.getOrNull(2)?.copy(alpha = strength * 0.8f) ?: Color.Transparent, fade, label = "glow")
androidx.compose.foundation.Canvas(modifier) {
fun pool(colour: Color, x: Float, y: Float, reach: Float) {
if (colour.alpha <= 0f) return
drawRect(
androidx.compose.ui.graphics.Brush.radialGradient(
listOf(colour, Color.Transparent),
center = androidx.compose.ui.geometry.Offset(size.width * x, size.height * y),
radius = size.width * reach,
),
)
}
if (wide) {
pool(third, 0.22f, 0.8f, 0.35f)
pool(first, 0.1f, 0.25f, 0.42f)
pool(second, 0.3f, 0.45f, 0.38f)
} else {
pool(third, 0.5f, 0.5f, 0.85f)
pool(first, 0.2f, 0.1f, 0.9f)
pool(second, 0.85f, 0.22f, 0.8f)
}
} }
val target = tint?.let { }
if (dark) Color.hsl(it.hue, it.saturation.coerceIn(0.3f, 0.5f), darkLightness)
else Color.hsl(it.hue, it.saturation.coerceIn(0.35f, 0.55f), lightLightness) /** The cover's colours at its top left, top right and middle. */
} ?: fallback private fun glowColours(art: android.graphics.Bitmap): List<Color> {
return animateColorAsState(target, tween(600), label = "art colour").value // A hardware bitmap can't be scaled or read on the CPU, so copy it out first.
val readable = if (art.config == android.graphics.Bitmap.Config.HARDWARE) art.copy(android.graphics.Bitmap.Config.ARGB_8888, false) else art
val small = android.graphics.Bitmap.createScaledBitmap(readable ?: return emptyList(), 3, 3, true)
return listOf(small.getPixel(0, 0), small.getPixel(2, 0), small.getPixel(1, 1)).map { Color(it).copy(alpha = 1f) }
} }
@@ -0,0 +1,114 @@
package app.seb3thehacker.gearslip.car
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.location.Geocoder
import android.location.Location
import android.location.LocationManager
import app.seb3thehacker.gearslip.AppSettings
import app.seb3thehacker.gearslip.GearslipLog
import java.util.Locale
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import org.json.JSONArray
import org.json.JSONObject
import kotlin.coroutines.resume
/** One saved place. Home and Work keep a blank [label]; their names come from strings.xml. */
data class SavedPlace(val label: String = "", val address: String = "")
/**
* Up to [COUNT] places a driver can name by voice in any map app: slot [HOME], slot [WORK],
* then three they name themselves. "Navigate home" sends the saved address as the search.
*/
object SavedPlaces {
const val COUNT = 5
const val HOME = 0
const val WORK = 1
private const val KEY = "saved_places"
private val HOME_WORDS = setOf("home", "house", "place", "home address", "my home", "my house", "my place")
private val WORK_WORDS = setOf("work", "office", "job", "work address", "my work", "my office", "my job", "the office")
fun all(context: Context): List<SavedPlace> {
val saved = runCatching {
val array = JSONArray(AppSettings.getString(context, KEY, "[]"))
List(array.length()) { i ->
array.getJSONObject(i).let { SavedPlace(it.optString("label"), it.optString("address")) }
}
}.getOrDefault(emptyList())
return List(COUNT) { saved.getOrNull(it) ?: SavedPlace() }
}
fun set(context: Context, index: Int, place: SavedPlace) {
val next = all(context).toMutableList().also { it[index] = place }
val array = JSONArray()
next.forEach { array.put(JSONObject().put("label", it.label).put("address", it.address)) }
AppSettings.putString(context, KEY, array.toString())
}
/**
* The slot [spoken] names, if any: "home", "my house", "work", or a custom label. Returns the
* slot even when its address is blank, so the assistant can say it isn't set yet.
*/
fun match(context: Context, spoken: String): Pair<Int, SavedPlace>? {
val said = spoken.lowercase().trim()
val places = all(context)
if (said in HOME_WORDS) return HOME to places[HOME]
if (said in WORK_WORDS) return WORK to places[WORK]
val squashed = squash(said.removePrefix("my ").removePrefix("the "))
if (squashed.isEmpty()) return null
places.forEachIndexed { i, place ->
if (i > WORK && place.label.isNotBlank() && squash(place.label) == squashed) return i to place
}
return null
}
private fun squash(s: String) = s.lowercase().filter { it.isLetterOrDigit() }
fun hasLocationPermission(context: Context) =
context.checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
context.checkSelfPermission(Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
/** Where the phone is now, as a street address, or as coordinates when there's no geocoder. Null if unknown. */
suspend fun here(context: Context): String? = withContext(Dispatchers.IO) {
val location = locate(context) ?: return@withContext null
val address = runCatching {
if (!Geocoder.isPresent()) return@runCatching null
@Suppress("DEPRECATION")
Geocoder(context, Locale.getDefault()).getFromLocation(location.latitude, location.longitude, 1)
?.firstOrNull()?.getAddressLine(0)
}.getOrNull()
address ?: "%.6f,%.6f".format(Locale.US, location.latitude, location.longitude)
}
private suspend fun locate(context: Context): Location? {
if (!hasLocationPermission(context)) return null
val manager = context.getSystemService(LocationManager::class.java) ?: return null
val provider = listOf(LocationManager.FUSED_PROVIDER, LocationManager.GPS_PROVIDER, LocationManager.NETWORK_PROVIDER)
.firstOrNull { it in manager.allProviders && manager.isProviderEnabled(it) }
// A fresh fix: saving the spot you're standing on wants better than an hour-old one.
val fresh = provider?.let {
withTimeoutOrNull(FIX_TIMEOUT_MS) {
suspendCancellableCoroutine { done ->
runCatching {
manager.getCurrentLocation(it, null, context.mainExecutor) { fix -> done.resume(fix) }
}.onFailure { e ->
GearslipLog.w("places: fix failed: ${e.message}")
done.resume(null)
}
}
}
}
return fresh ?: manager.allProviders
.mapNotNull { runCatching { manager.getLastKnownLocation(it) }.getOrNull() }
.maxByOrNull { it.time }
}
private const val FIX_TIMEOUT_MS = 15_000L
}
@@ -1,5 +1,7 @@
package app.seb3thehacker.gearslip.car package app.seb3thehacker.gearslip.car
import androidx.compose.ui.res.stringResource
import app.seb3thehacker.gearslip.R
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.RowScope
@@ -29,7 +31,7 @@ fun ScreenHeader(title: String, modifier: Modifier = Modifier, trailing: @Compos
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
GsIconButton(Icons.AutoMirrored.Filled.ArrowBack, "Back", navigator::back) GsIconButton(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back), navigator::back)
Text( Text(
title, title,
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineSmall,
@@ -1,5 +1,7 @@
package app.seb3thehacker.gearslip.car package app.seb3thehacker.gearslip.car
import androidx.compose.ui.res.stringResource
import app.seb3thehacker.gearslip.R
import app.seb3thehacker.gearslip.car.theme.* import app.seb3thehacker.gearslip.car.theme.*
import android.graphics.Rect import android.graphics.Rect
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
@@ -99,11 +101,12 @@ fun TemplateChrome(template: Template?, modifier: Modifier = Modifier) {
* A template that isn't map-backed covers the surface completely, and reporting an empty * A template that isn't map-backed covers the surface completely, and reporting an empty
* rectangle is how the app is told to stop bothering. * rectangle is how the app is told to stop bothering.
*/ */
fun visibleAreaFor(template: Template?, width: Int, height: Int, measured: Rect? = null): Rect = when (template) { fun visibleAreaFor(template: Template?, width: Int, height: Int, measured: Rect? = null, cardsAtEnd: Boolean = false): Rect = when (template) {
is NavigationTemplate -> measured ?: Rect(0, 0, width, height) is NavigationTemplate -> measured ?: Rect(0, 0, width, height)
is MapWithContentTemplate, is MapTemplate, is PlaceListNavigationTemplate, is MapWithContentTemplate, is MapTemplate, is PlaceListNavigationTemplate,
is RoutePreviewNavigationTemplate, is PlaceListMapTemplate, is RoutePreviewNavigationTemplate, is PlaceListMapTemplate,
-> measured ?: Rect((width * CARD_COLUMN_FRACTION).toInt(), 0, width, height) -> measured ?: if (cardsAtEnd) Rect(0, 0, (width * (1 - CARD_COLUMN_FRACTION)).toInt(), height)
else Rect((width * CARD_COLUMN_FRACTION).toInt(), 0, width, height)
null -> Rect(0, 0, width, height) null -> Rect(0, 0, width, height)
else -> Rect(0, 0, 0, 0) else -> Rect(0, 0, 0, 0)
} }
@@ -152,6 +155,24 @@ internal class ChromeCover {
internal val LocalChromeCover = compositionLocalOf<ChromeCover?> { null } internal val LocalChromeCover = compositionLocalOf<ChromeCover?> { null }
/**
* The app's cards go to the far edge of the map and its buttons to the near one, set while the
* player is docked on the left: the player, the map and the cards each keep their own space
* instead of the cards crowding up against the player.
*/
internal val LocalCardsAtEnd = compositionLocalOf { false }
/**
* How much of the map's width the docked player floats over, on the side opposite the cards. The
* map itself runs underneath at full size; the app is told this part is covered, and the chrome
* keeps clear of it.
*/
internal val LocalSidePanelInset = compositionLocalOf { 0.dp }
/** The edge the app's cards cover, and the one its map buttons do. */
internal val cardEdge @Composable get() = if (LocalCardsAtEnd.current) CoverEdge.END else CoverEdge.START
internal val buttonEdge @Composable get() = if (LocalCardsAtEnd.current) CoverEdge.START else CoverEdge.END
/** /**
* The floating nav bar, as far as the app's map is concerned. Its full [height] at once rather * The floating nav bar, as far as the app's map is concerned. Its full [height] at once rather
* than following the slide, so the app is told once instead of every frame. * than following the slide, so the app is told once instead of every frame.
@@ -196,12 +217,15 @@ private fun NavigationBars(
) { ) {
val connection = LocalCarAppConnection.current val connection = LocalCarAppConnection.current
val pan by connection.panMode.collectAsState() val pan by connection.panMode.collectAsState()
val atEnd = LocalCardsAtEnd.current
val cardEdge = cardEdge
val buttonEdge = buttonEdge
androidx.compose.foundation.layout.BoxWithConstraints(modifier.fillMaxSize()) { androidx.compose.foundation.layout.BoxWithConstraints(modifier.fillMaxSize()) {
val column = cardColumnWidth(maxWidth) val column = cardColumnWidth(maxWidth)
Row(Modifier.fillMaxSize()) { val cards = @Composable {
if (routing != null || estimate != null) { if (routing != null || estimate != null) {
Column( Column(
Modifier.width(column).fillMaxHeight().covers("nav-cards", CoverEdge.START).padding(12.dp), Modifier.width(column).fillMaxHeight().covers("nav-cards", cardEdge).padding(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
routing?.let { RoutingCard(it, Modifier.fillMaxWidth()) } routing?.let { RoutingCard(it, Modifier.fillMaxWidth()) }
@@ -209,14 +233,21 @@ private fun NavigationBars(
estimate?.let { TravelEstimateCard(it, Modifier.fillMaxWidth()) } estimate?.let { TravelEstimateCard(it, Modifier.fillMaxWidth()) }
} }
} }
Spacer(Modifier.weight(1f)) }
val buttons = @Composable {
RightEdgeControls( RightEdgeControls(
if (pan) null else template.actionStrip, if (pan) null else template.actionStrip,
template.mapActionStrip, template.mapActionStrip,
Modifier.padding(12.dp), Modifier.padding(12.dp),
mapActionsModifier = Modifier.covers("nav-buttons", CoverEdge.END), mapActionsModifier = Modifier.covers("nav-buttons", buttonEdge),
alignment = if (atEnd) Alignment.Start else Alignment.End,
) )
} }
Row(Modifier.fillMaxSize()) {
if (atEnd) buttons() else cards()
Spacer(Modifier.weight(1f))
if (atEnd) cards() else buttons()
}
} }
} }
@@ -228,7 +259,7 @@ private fun NavigationBars(
private fun RoutingCard(info: RoutingInfo, modifier: Modifier = Modifier) { private fun RoutingCard(info: RoutingInfo, modifier: Modifier = Modifier) {
if (info.isLoading) { if (info.isLoading) {
ChromeSurface(modifier) { ChromeSurface(modifier) {
Text("Finding a route…", Modifier.padding(horizontal = 18.dp, vertical = 16.dp), style = ChromeType.title) Text(stringResource(R.string.nav_finding_route), Modifier.padding(horizontal = 18.dp, vertical = 16.dp), style = ChromeType.title)
} }
return return
} }
@@ -283,7 +314,7 @@ private fun RoutingCard(info: RoutingInfo, modifier: Modifier = Modifier) {
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp), Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text("Then", style = ChromeType.label, color = scheme.onSurfaceVariant) Text(stringResource(R.string.nav_then), style = ChromeType.label, color = scheme.onSurfaceVariant)
Spacer(Modifier.width(10.dp)) Spacer(Modifier.width(10.dp))
CarGlyph(next.maneuver?.icon, Modifier.size(28.dp)) CarGlyph(next.maneuver?.icon, Modifier.size(28.dp))
val nextText = next.cue.text().ifEmpty { next.road.text() } val nextText = next.cue.text().ifEmpty { next.road.text() }
@@ -345,7 +376,7 @@ private fun LaneTile(lane: Lane) {
) { ) {
Icon( Icon(
Icons.Filled.KeyboardArrowUp, Icons.Filled.KeyboardArrowUp,
contentDescription = if (recommended) "Recommended lane" else "Lane", contentDescription = stringResource(if (recommended) R.string.nav_lane_recommended else R.string.nav_lane),
tint = if (recommended) MaterialTheme.colorScheme.onPrimaryContainer tint = if (recommended) MaterialTheme.colorScheme.onPrimaryContainer
else MaterialTheme.colorScheme.onSurfaceVariant, else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(24.dp).rotate(shown.turnDegrees()), modifier = Modifier.size(24.dp).rotate(shown.turnDegrees()),
@@ -398,7 +429,7 @@ private fun TravelEstimateCard(estimate: TravelEstimate, modifier: Modifier = Mo
) )
} }
val detail = listOfNotNull( val detail = listOfNotNull(
arrival.takeIf { it.isNotEmpty() }?.let { "Arrive $it" }, arrival.takeIf { it.isNotEmpty() }?.let { stringResource(R.string.nav_arrive, it) },
distance.takeIf { it.isNotEmpty() }, distance.takeIf { it.isNotEmpty() },
).joinToString(" • ") ).joinToString(" • ")
if (detail.isNotEmpty()) { if (detail.isNotEmpty()) {
@@ -413,7 +444,7 @@ private fun TravelEstimateCard(estimate: TravelEstimate, modifier: Modifier = Mo
// pops the on-screen card, leaving the app still believing it is navigating. // pops the on-screen card, leaving the app still believing it is navigating.
GsIconButton( GsIconButton(
Icons.Filled.Close, Icons.Filled.Close,
"Stop navigating", stringResource(R.string.nav_stop),
{ connection.stopNavigating() }, { connection.stopNavigating() },
colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.colorScheme.onSurface), colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.colorScheme.onSurface),
size = 52.dp, size = 52.dp,
@@ -445,12 +476,14 @@ private fun MapPaneLayout(
// Pan mode hands the whole screen to the map, leaving only its own controls. // Pan mode hands the whole screen to the map, leaving only its own controls.
val pan by connection.panMode.collectAsState() val pan by connection.panMode.collectAsState()
val edgeActions = (actionStrip?.actions.orEmpty() + mapActionStrip?.actions.orEmpty()).map { it.identity() }.toSet() val edgeActions = (actionStrip?.actions.orEmpty() + mapActionStrip?.actions.orEmpty()).map { it.identity() }.toSet()
val atEnd = LocalCardsAtEnd.current
val cardEdge = cardEdge
androidx.compose.foundation.layout.BoxWithConstraints(modifier.fillMaxWidth()) { androidx.compose.foundation.layout.BoxWithConstraints(modifier.fillMaxWidth()) {
if (!pan) { if (!pan) {
ChromeSurface( ChromeSurface(
Modifier Modifier
.align(Alignment.TopStart) .align(if (atEnd) Alignment.TopEnd else Alignment.TopStart)
.covers("map-pane", CoverEdge.START) .covers("map-pane", cardEdge)
.padding(12.dp) .padding(12.dp)
.width(cardColumnWidth(maxWidth) - 24.dp) .width(cardColumnWidth(maxWidth) - 24.dp)
.let { if (fullHeight) it.height(maxHeight - 24.dp) else it.heightIn(max = maxHeight - 24.dp) }, .let { if (fullHeight) it.height(maxHeight - 24.dp) else it.heightIn(max = maxHeight - 24.dp) },
@@ -463,7 +496,8 @@ private fun MapPaneLayout(
RightEdgeControls( RightEdgeControls(
if (pan) null else actionStrip, if (pan) null else actionStrip,
mapActionStrip, mapActionStrip,
Modifier.align(Alignment.TopEnd).padding(12.dp), Modifier.align(if (atEnd) Alignment.TopStart else Alignment.TopEnd).padding(12.dp),
alignment = if (atEnd) Alignment.Start else Alignment.End,
) )
} }
} }
@@ -543,7 +577,7 @@ private fun RoutePreviewChrome(template: RoutePreviewNavigationTemplate, modifie
) )
Box(Modifier.weight(1f, fill = false)) { Box(Modifier.weight(1f, fill = false)) {
if (template.isLoading) { if (template.isLoading) {
Text("Finding routes…", Modifier.padding(14.dp), style = ChromeType.title) Text(stringResource(R.string.nav_finding_routes), Modifier.padding(14.dp), style = ChromeType.title)
} else { } else {
ItemListColumn(template.itemList) ItemListColumn(template.itemList)
} }
@@ -567,7 +601,7 @@ private fun ListPane(
ChromeSurface(modifier, shape = androidx.compose.foundation.shape.RoundedCornerShape(0.dp)) { ChromeSurface(modifier, shape = androidx.compose.foundation.shape.RoundedCornerShape(0.dp)) {
Column { Column {
HeaderBar(title, startAction) HeaderBar(title, startAction)
if (loading) Text("Loading…", Modifier.padding(14.dp), style = ChromeType.title) if (loading) Text(stringResource(R.string.media_loading), Modifier.padding(14.dp), style = ChromeType.title)
else ItemListColumn(itemList) else ItemListColumn(itemList)
} }
} }
@@ -1,5 +1,7 @@
package app.seb3thehacker.gearslip.car package app.seb3thehacker.gearslip.car
import androidx.compose.ui.res.stringResource
import app.seb3thehacker.gearslip.R
import app.seb3thehacker.gearslip.car.theme.* import app.seb3thehacker.gearslip.car.theme.*
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
@@ -317,7 +319,7 @@ internal fun SearchField(
Spacer(Modifier.width(12.dp)) Spacer(Modifier.width(12.dp))
} }
Text( Text(
query.ifEmpty { hint.ifEmpty { "Search" } }, query.ifEmpty { hint.ifEmpty { stringResource(R.string.template_search) } },
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = if (query.isEmpty()) muted else MaterialTheme.colorScheme.onSurface, color = if (query.isEmpty()) muted else MaterialTheme.colorScheme.onSurface,
maxLines = 1, maxLines = 1,
@@ -327,7 +329,7 @@ internal fun SearchField(
if (onClear != null && query.isNotEmpty()) { if (onClear != null && query.isNotEmpty()) {
GsIconButton( GsIconButton(
androidx.compose.material.icons.Icons.Filled.Close, androidx.compose.material.icons.Icons.Filled.Close,
"Clear", stringResource(R.string.storage_clear),
onClear, onClear,
colors = GsColors(androidx.compose.ui.graphics.Color.Transparent, muted), colors = GsColors(androidx.compose.ui.graphics.Color.Transparent, muted),
size = 42.dp, size = 42.dp,
@@ -623,7 +625,7 @@ private fun MediaPlaybackContent(template: MediaPlaybackTemplate, modifier: Modi
} else { } else {
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) { Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
Text( Text(
"Nothing playing yet.", stringResource(R.string.template_nothing_playing),
style = ChromeType.body, style = ChromeType.body,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = androidx.compose.ui.text.style.TextAlign.Center, textAlign = androidx.compose.ui.text.style.TextAlign.Center,
@@ -647,6 +649,6 @@ private typealias ColumnScopeAlias = androidx.compose.foundation.layout.ColumnSc
@Composable @Composable
private fun Loading() { private fun Loading() {
Box(Modifier.fillMaxWidth().height(80.dp), contentAlignment = Alignment.Center) { Box(Modifier.fillMaxWidth().height(80.dp), contentAlignment = Alignment.Center) {
Text("Loading…", style = ChromeType.title) Text(stringResource(R.string.media_loading), style = ChromeType.title)
} }
} }
@@ -1,5 +1,8 @@
package app.seb3thehacker.gearslip.car package app.seb3thehacker.gearslip.car
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.input.pointer.changedToUp
import app.seb3thehacker.gearslip.R
import app.seb3thehacker.gearslip.car.theme.* import app.seb3thehacker.gearslip.car.theme.*
import androidx.compose.runtime.produceState import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -34,6 +37,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Call import androidx.compose.material.icons.filled.Call
import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Warning import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -228,6 +232,8 @@ internal fun RightEdgeControls(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
/** For the stack of map buttons alone, without the row of the app's own above it. */ /** For the stack of map buttons alone, without the row of the app's own above it. */
mapActionsModifier: Modifier = Modifier, mapActionsModifier: Modifier = Modifier,
/** End normally; Start when the app's cards have moved to the far edge and these to the near one. */
alignment: Alignment.Horizontal = Alignment.End,
) { ) {
val connection = LocalCarAppConnection.current val connection = LocalCarAppConnection.current
val actions = actionStrip?.actions.orEmpty().filter { it.isDrawable() } val actions = actionStrip?.actions.orEmpty().filter { it.isDrawable() }
@@ -244,6 +250,11 @@ internal fun RightEdgeControls(
// Kept up while the knob is on one of them, or the ring would fade out along with the button. // Kept up while the knob is on one of them, or the ring would fade out along with the button.
var holdsFocus by remember { mutableStateOf(false) } var holdsFocus by remember { mutableStateOf(false) }
val visible = awake || pan || holdsFocus val visible = awake || pan || holdsFocus
// With the player beside the map there is no room for the app's row and the map controls
// both, so the app's own buttons fold behind one; opening it swaps them in for the map's.
val folded = LocalSidePanelInset.current > 0.dp && actions.size > 1
var open by remember { mutableStateOf(false) }
androidx.compose.runtime.LaunchedEffect(visible, folded) { if (!visible || !folded) open = false }
val alpha by androidx.compose.animation.core.animateFloatAsState( val alpha by androidx.compose.animation.core.animateFloatAsState(
targetValue = if (visible) 1f else RESTING_ALPHA, targetValue = if (visible) 1f else RESTING_ALPHA,
animationSpec = androidx.compose.animation.core.tween(if (visible) 150 else 500), animationSpec = androidx.compose.animation.core.tween(if (visible) 150 else 500),
@@ -262,18 +273,45 @@ internal fun RightEdgeControls(
} }
} }
}, },
horizontalAlignment = Alignment.End, horizontalAlignment = alignment,
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
if (actions.isNotEmpty()) { if (folded) {
val scheme = MaterialTheme.colorScheme
GsIconButton(
Icons.Filled.MoreVert,
stringResource(R.string.car_group_more),
onClick = { open = !open },
colors = GsColors(if (open) scheme.primary else scheme.surfaceContainerHighest, if (open) scheme.onPrimary else scheme.onSurface),
size = ICON_BUTTON_SIZE,
iconSize = ICON_BUTTON_GLYPH,
shape = androidx.compose.foundation.shape.CircleShape,
)
if (open) {
// A tap on any of them closes the stack again; the tap still reaches the button.
Column(
Modifier.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
if (awaitPointerEvent(PointerEventPass.Initial).changes.any { it.changedToUp() }) open = false
}
}
},
horizontalAlignment = alignment,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
actions.forEach { ActionButton(it) }
}
}
} else if (actions.isNotEmpty()) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
actions.forEach { ActionButton(it) } actions.forEach { ActionButton(it) }
} }
} }
if (mapActions.isNotEmpty()) { if (mapActions.isNotEmpty() && !(folded && open)) {
Column( Column(
mapActionsModifier, mapActionsModifier,
horizontalAlignment = Alignment.End, horizontalAlignment = alignment,
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
mapActions.forEach { ActionButton(it) } mapActions.forEach { ActionButton(it) }
@@ -423,7 +461,7 @@ internal fun AutoStartButton(action: Action, modifier: Modifier = Modifier) {
) { ) {
if (hasGlyph) CarGlyph(action.icon, Modifier.size(24.dp).align(Alignment.CenterStart)) if (hasGlyph) CarGlyph(action.icon, Modifier.size(24.dp).align(Alignment.CenterStart))
Text( Text(
action.title.text().ifEmpty { "Start" }, action.title.text().ifEmpty { stringResource(R.string.template_start) },
style = ChromeType.title, style = ChromeType.title,
maxLines = 1, maxLines = 1,
) )
@@ -1,5 +1,7 @@
package app.seb3thehacker.gearslip.car package app.seb3thehacker.gearslip.car
import androidx.compose.ui.res.stringResource
import app.seb3thehacker.gearslip.R
import app.seb3thehacker.gearslip.car.theme.* import app.seb3thehacker.gearslip.car.theme.*
import androidx.compose.runtime.produceState import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -300,7 +302,7 @@ private fun DetailRow(row: CarRow, placeName: String) {
Row(Modifier.padding(horizontal = 16.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically) { Row(Modifier.padding(horizontal = 16.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically) {
androidx.compose.material3.Icon(Icons.Filled.Call, null, Modifier.size(20.dp)) androidx.compose.material3.Icon(Icons.Filled.Call, null, Modifier.size(20.dp))
Spacer(Modifier.width(6.dp)) Spacer(Modifier.width(6.dp))
Text("Call", style = ChromeType.body) Text(stringResource(R.string.dialer_call), style = ChromeType.body)
} }
} }
} }
@@ -1,5 +1,6 @@
package app.seb3thehacker.gearslip.car package app.seb3thehacker.gearslip.car
import androidx.compose.foundation.layout.PaddingValues
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
@@ -27,7 +28,7 @@ import androidx.compose.ui.unit.dp
fun VehicleDataScreen() { fun VehicleDataScreen() {
val readings by CarSensors.readings.collectAsState() val readings by CarSensors.readings.collectAsState()
Column(Modifier.fillMaxSize().padding(horizontal = 24.dp, vertical = 16.dp)) { Column(Modifier.fillMaxSize().padding(start = 24.dp, end = 24.dp, top = 16.dp)) {
ScreenHeader("Vehicle data") ScreenHeader("Vehicle data")
Text( Text(
"Raw sensor channel from the connected head unit. Field meaning is a best guess - " + "Raw sensor channel from the connected head unit. Field meaning is a best guess - " +
@@ -46,7 +47,7 @@ fun VehicleDataScreen() {
return@Column return@Column
} }
LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) { LazyColumn(contentPadding = PaddingValues(bottom = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(readings, key = { it.typeId }) { reading -> ReadingRow(reading) } items(readings, key = { it.typeId }) { reading -> ReadingRow(reading) }
} }
} }
@@ -1,5 +1,9 @@
package app.seb3thehacker.gearslip.car package app.seb3thehacker.gearslip.car
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.annotation.StringRes
import app.seb3thehacker.gearslip.R
import android.Manifest import android.Manifest
import android.content.Context import android.content.Context
import android.content.pm.PackageManager import android.content.pm.PackageManager
@@ -38,7 +42,7 @@ class WeatherData(
sealed interface WeatherState { sealed interface WeatherState {
data object Loading : WeatherState data object Loading : WeatherState
data class Unavailable(val reason: String) : WeatherState data class Unavailable(@param:StringRes val reason: Int) : WeatherState
data class Ready(val data: WeatherData) : WeatherState data class Ready(val data: WeatherData) : WeatherState
} }
@@ -78,7 +82,7 @@ object Weather {
if (flow.value !is WeatherState.Ready) { if (flow.value !is WeatherState.Ready) {
val allowed = hasLocationPermission(context) val allowed = hasLocationPermission(context)
flow.value = WeatherState.Unavailable( flow.value = WeatherState.Unavailable(
if (allowed) "Waiting for a location fix." else "Allow location for Gearslip on the phone to see the weather.", if (allowed) R.string.weather_waiting_fix else R.string.weather_allow_location,
) )
} }
return return
@@ -92,7 +96,7 @@ object Weather {
// No place name: logs get shared in bug reports. // No place name: logs get shared in bug reports.
GearslipLog.i("weather: loaded") GearslipLog.i("weather: loaded")
} }
else if (flow.value !is WeatherState.Ready) flow.value = WeatherState.Unavailable("Could not reach the weather service.") else if (flow.value !is WeatherState.Ready) flow.value = WeatherState.Unavailable(R.string.weather_unreachable)
} }
// --- location ------------------------------------------------------------------------- // --- location -------------------------------------------------------------------------
@@ -197,6 +201,26 @@ object Weather {
) )
} }
/** Open-Meteo's WMO weather codes, in words on the screen. [describe] is the English the assistant says. */
@StringRes
fun conditionRes(code: Int): Int = when (code) {
0 -> R.string.weather_clear
1 -> R.string.weather_mostly_clear
2 -> R.string.weather_partly_cloudy
3 -> R.string.weather_overcast
45, 48 -> R.string.weather_fog
in 51..57 -> R.string.weather_drizzle
in 61..67 -> R.string.weather_rain
in 71..77 -> R.string.weather_snow
in 80..82 -> R.string.weather_rain_showers
in 85..86 -> R.string.weather_snow_showers
in 95..99 -> R.string.weather_thunderstorm
else -> 0
}
@Composable
fun condition(code: Int): String = conditionRes(code).let { if (it == 0) "-" else stringResource(it) }
/** Open-Meteo's WMO weather codes, in words. */ /** Open-Meteo's WMO weather codes, in words. */
fun describe(code: Int): String = when (code) { fun describe(code: Int): String = when (code) {
0 -> "Clear" 0 -> "Clear"
@@ -1,5 +1,7 @@
package app.seb3thehacker.gearslip.car package app.seb3thehacker.gearslip.car
import androidx.compose.ui.res.stringResource
import app.seb3thehacker.gearslip.R
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
@@ -41,11 +43,11 @@ fun WeatherScreen() {
Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = 24.dp, vertical = 16.dp), Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = 24.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(20.dp), verticalArrangement = Arrangement.spacedBy(20.dp),
) { ) {
ScreenHeader("Weather") ScreenHeader(stringResource(R.string.crumb_weather))
when (val s = state) { when (val s = state) {
WeatherState.Loading -> Text("Getting the weather…", style = MaterialTheme.typography.titleMedium) WeatherState.Loading -> Text(stringResource(R.string.weather_getting), style = MaterialTheme.typography.titleMedium)
is WeatherState.Unavailable -> Text( is WeatherState.Unavailable -> Text(
s.reason, style = MaterialTheme.typography.titleMedium, stringResource(s.reason), style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
is WeatherState.Ready -> Forecast(s.data) is WeatherState.Ready -> Forecast(s.data)
@@ -66,18 +68,18 @@ private fun Forecast(w: WeatherData) {
WeatherIcon(w.code, w.isDay, size = 88.dp) WeatherIcon(w.code, w.isDay, size = 88.dp)
Column { Column {
Text("${w.temp}${w.tempUnit}", fontSize = 56.sp, fontWeight = FontWeight.Bold) Text("${w.temp}${w.tempUnit}", fontSize = 56.sp, fontWeight = FontWeight.Bold)
Text(Weather.describe(w.code), style = MaterialTheme.typography.titleLarge) Text(Weather.condition(w.code), style = MaterialTheme.typography.titleLarge)
} }
Spacer(Modifier.weight(1f)) Spacer(Modifier.weight(1f))
Column(horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(4.dp)) { Column(horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text("Feels like ${w.feelsLike}${w.tempUnit}", style = MaterialTheme.typography.bodyLarge) Text(stringResource(R.string.weather_feels_like, "${w.feelsLike}${w.tempUnit}"), style = MaterialTheme.typography.bodyLarge)
Text("Humidity ${w.humidity}%", style = MaterialTheme.typography.bodyLarge) Text(stringResource(R.string.weather_humidity, w.humidity), style = MaterialTheme.typography.bodyLarge)
Text("Wind ${w.wind} ${w.windUnit}", style = MaterialTheme.typography.bodyLarge) Text(stringResource(R.string.weather_wind, "${w.wind} ${w.windUnit}"), style = MaterialTheme.typography.bodyLarge)
} }
} }
} }
Text("Next hours", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) Text(stringResource(R.string.weather_next_hours), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
LazyRow(horizontalArrangement = Arrangement.spacedBy(10.dp)) { LazyRow(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
items(w.hours) { h -> items(w.hours) { h ->
Surface(color = MaterialTheme.colorScheme.surfaceContainerHigh, shape = MaterialTheme.shapes.large) { Surface(color = MaterialTheme.colorScheme.surfaceContainerHigh, shape = MaterialTheme.shapes.large) {
@@ -101,7 +103,7 @@ private fun Forecast(w: WeatherData) {
} }
} }
Text("This week", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) Text(stringResource(R.string.weather_this_week), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
w.days.forEachIndexed { i, d -> w.days.forEachIndexed { i, d ->
Surface(color = MaterialTheme.colorScheme.surfaceContainerHigh, shape = MaterialTheme.shapes.large) { Surface(color = MaterialTheme.colorScheme.surfaceContainerHigh, shape = MaterialTheme.shapes.large) {
@@ -111,11 +113,11 @@ private fun Forecast(w: WeatherData) {
horizontalArrangement = Arrangement.spacedBy(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
Text( Text(
if (i == 0) "Today" else SimpleDateFormat("EEEE", Locale.getDefault()).format(Date(d.date)), if (i == 0) stringResource(R.string.weather_today) else SimpleDateFormat("EEEE", Locale.getDefault()).format(Date(d.date)),
Modifier.width(120.dp), style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold, Modifier.width(120.dp), style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold,
) )
WeatherIcon(d.code, true, size = 28.dp) WeatherIcon(d.code, true, size = 28.dp)
Text(Weather.describe(d.code), Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium) Text(Weather.condition(d.code), Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium)
if (d.rainChance > 0) Text("${d.rainChance}%", color = MaterialTheme.colorScheme.primary) if (d.rainChance > 0) Text("${d.rainChance}%", color = MaterialTheme.colorScheme.primary)
Text("${d.low}° / ${d.high}°", style = MaterialTheme.typography.bodyLarge) Text("${d.low}° / ${d.high}°", style = MaterialTheme.typography.bodyLarge)
} }
@@ -123,7 +125,7 @@ private fun Forecast(w: WeatherData) {
} }
} }
Text( Text(
"Weather data by Open-Meteo.com (CC BY 4.0)", stringResource(R.string.weather_credit),
style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
@@ -1,5 +1,7 @@
package app.seb3thehacker.gearslip.car package app.seb3thehacker.gearslip.car
import androidx.compose.ui.res.stringResource
import app.seb3thehacker.gearslip.R
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.view.View import android.view.View
@@ -95,19 +97,19 @@ fun WebApp(content: String) {
Column(Modifier.fillMaxSize()) { Column(Modifier.fillMaxSize()) {
// Back steps through the page's history first (the interceptor above), then leaves. // Back steps through the page's history first (the interceptor above), then leaves.
if (!fullscreen) { if (!fullscreen) {
ScreenHeader(pageTitle.ifEmpty { "Browser" }, Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { ScreenHeader(pageTitle.ifEmpty { stringResource(R.string.app_browser) }, Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
GsIconButton(androidx.compose.material.icons.Icons.Filled.Search, "Search or type an address", { GsIconButton(androidx.compose.material.icons.Icons.Filled.Search, stringResource(R.string.web_search), {
fieldText = null fieldText = null
address = "" address = ""
}) })
if (canGoForward) { if (canGoForward) {
GsIconButton(androidx.compose.material.icons.Icons.AutoMirrored.Filled.ArrowForward, "Forward", { web?.goForward() }) GsIconButton(androidx.compose.material.icons.Icons.AutoMirrored.Filled.ArrowForward, stringResource(R.string.web_forward), { web?.goForward() })
} }
GsIconButton(androidx.compose.material.icons.Icons.Filled.Refresh, "Reload", { web?.reload() }) GsIconButton(androidx.compose.material.icons.Icons.Filled.Refresh, stringResource(R.string.web_reload), { web?.reload() })
GsIconButton(androidx.compose.material.icons.Icons.Filled.Home, "Start page", { GsIconButton(androidx.compose.material.icons.Icons.Filled.Home, stringResource(R.string.web_start_page), {
web?.let { view -> loadContent(view, content) } web?.let { view -> loadContent(view, content) }
}) })
GsIconButton(MediaIcons.Fullscreen, "Full screen", CarFullscreen::enter) GsIconButton(MediaIcons.Fullscreen, stringResource(R.string.web_fullscreen), CarFullscreen::enter)
} }
} }
Box(Modifier.weight(1f).fillMaxSize()) { Box(Modifier.weight(1f).fillMaxSize()) {
@@ -3,6 +3,7 @@ package app.seb3thehacker.gearslip.car.theme
import androidx.compose.material3.ColorScheme import androidx.compose.material3.ColorScheme
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.graphics.lerp
/** /**
* The phone's Material You colours, with its accent washed over the surfaces. Material You's own * The phone's Material You colours, with its accent washed over the surfaces. Material You's own
@@ -11,7 +12,8 @@ import androidx.compose.ui.graphics.compositeOver
* Material 3's surface tint does, and it changes with the wallpaper like everything else. * Material 3's surface tint does, and it changes with the wallpaper like everything else.
* Higher containers take more of the tint, so cards still stand off the background. * Higher containers take more of the tint, so cards still stand off the background.
*/ */
fun ColorScheme.withTintedSurfaces(dark: Boolean): ColorScheme { fun ColorScheme.withTintedSurfaces(dark: Boolean, style: DarkStyle = DarkStyle.GREY): ColorScheme {
if (dark && style != DarkStyle.GREY) return withDarkStyle(style)
val base = if (dark) 0.03f else 0.02f val base = if (dark) 0.03f else 0.02f
fun tint(own: Color, step: Int) = primary.copy(alpha = base + step * (if (dark) 0.015f else 0.01f)).compositeOver(own) fun tint(own: Color, step: Int) = primary.copy(alpha = base + step * (if (dark) 0.015f else 0.01f)).compositeOver(own)
return copy( return copy(
@@ -27,3 +29,56 @@ fun ColorScheme.withTintedSurfaces(dark: Boolean): ColorScheme {
surfaceVariant = tint(surfaceVariant, 3), surfaceVariant = tint(surfaceVariant, 3),
) )
} }
/**
* How dark the car's dark mode goes. [GREY] is Material You's own dark, with the tint above;
* the others are darker, and the two colour ones take their hue from the phone's accent.
*/
enum class DarkStyle { GREY, BLACK, DEEP_COLOUR, BLACK_COLOUR }
/**
* The surfaces rebuilt at [style]'s brightness, from the background up to the highest container.
* The levels are 0-255 greys; each surface keeps Material You's own slight hue at that level.
*/
private fun ColorScheme.withDarkStyle(style: DarkStyle): ColorScheme {
val levels = when (style) {
DarkStyle.BLACK -> listOf(0f, 12f, 17f, 25f, 34f)
DarkStyle.DEEP_COLOUR -> listOf(11f, 19f, 24f, 32f, 41f)
else -> listOf(0f, 17f, 22f, 30f, 39f)
}
val colour = style != DarkStyle.BLACK
// The accent's saturated container colour, not the pastel accent itself: tinting with the
// pastel only makes a surface lighter, where this one gives it colour at the same brightness.
fun at(own: Color, step: Int): Color {
val level = levels[step] / 255f
if (level == 0f) return Color.Black
val neutral = own.scaledTo(level)
return if (colour) {
lerp(neutral, primaryContainer.scaledTo(level), (0.6f + 0.08f * step).coerceAtMost(0.95f))
} else {
primary.copy(alpha = 0.02f + 0.015f * step).compositeOver(neutral)
}
}
return copy(
// Grey text that read fine on grey looks dim on black, so it moves a little towards white.
onSurfaceVariant = lerp(onSurfaceVariant, onSurface, 0.3f),
background = at(background, 0),
surface = at(surface, 0),
surfaceDim = at(surfaceDim, 0),
surfaceContainerLowest = at(surfaceContainerLowest, 0),
surfaceContainerLow = at(surfaceContainerLow, 1),
surfaceContainer = at(surfaceContainer, 2),
surfaceContainerHigh = at(surfaceContainerHigh, 3),
surfaceContainerHighest = at(surfaceContainerHighest, 4),
surfaceBright = at(surfaceBright, 4),
surfaceVariant = at(surfaceVariant, 3),
)
}
/** This colour's hue at an average brightness of [level], 0 to 1. */
private fun Color.scaledTo(level: Float): Color {
val mean = (red + green + blue) / 3f
if (mean <= 0f) return Color(level, level, level)
val k = level / mean
return Color((red * k).coerceAtMost(1f), (green * k).coerceAtMost(1f), (blue * k).coerceAtMost(1f))
}
@@ -9,6 +9,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.dropShadow
import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.drawOutline import androidx.compose.ui.graphics.drawOutline
import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.DrawScope
@@ -172,6 +173,46 @@ fun Modifier.gsSurface(
* outlines: cheap, works on every API level, and already soft enough that the car's video * outlines: cheap, works on every API level, and already soft enough that the car's video
* compression has nothing crisp to smear. * compression has nothing crisp to smear.
*/ */
/**
* The current theme's shadow under a panel floating over the map, such as the docked player.
* Draws nothing in a look without one.
*/
@Composable
fun Modifier.gsPanelShadow(shape: Shape): Modifier {
val look = gsLook()
if (look.panelShadow <= 0.dp || look.panelShadowAlpha <= 0f) return this
// A real blur, as soft as the nav bar's gradient and as strong: stacked outlines showed as
// rings around a card this big.
return dropShadow(
shape,
androidx.compose.ui.graphics.shadow.Shadow(
radius = look.panelShadow * 3,
color = Color.Black.copy(alpha = look.panelShadowAlpha * 0.6f),
offset = androidx.compose.ui.unit.DpOffset(0.dp, look.panelShadow / 2),
),
)
}
/** The same shadow cast upwards from the top edge of a bar along the bottom of the screen. */
@Composable
fun Modifier.gsBarShadow(): Modifier {
val look = gsLook()
if (look.panelShadow <= 0.dp || look.panelShadowAlpha <= 0f) return this
return drawBehind {
val reach = look.panelShadow.toPx() * 1.5f
drawRect(
Brush.verticalGradient(
0f to Color.Transparent,
1f to Color.Black.copy(alpha = look.panelShadowAlpha * 0.6f),
startY = -reach,
endY = 0f,
),
topLeft = androidx.compose.ui.geometry.Offset(0f, -reach),
size = Size(size.width, reach),
)
}
}
private fun DrawScope.drawSoftShadow(shape: Shape, elevation: Float, alpha: Float, lift: Float) { private fun DrawScope.drawSoftShadow(shape: Shape, elevation: Float, alpha: Float, lift: Float) {
if (alpha <= 0f) return if (alpha <= 0f) return
val blur = elevation * 2f val blur = elevation * 2f
@@ -64,6 +64,12 @@ data class ControlLook(
* from; a flat look takes the dot. * from; a flat look takes the dot.
*/ */
val holdMarker: HoldMarkerStyle = HoldMarkerStyle.PEEL, val holdMarker: HoldMarkerStyle = HoldMarkerStyle.PEEL,
/**
* The shadow a panel floating over the map casts onto it (the docked player, the lyrics), and
* the nav bar casts up. 0 for none, so a flat look stays flat.
*/
val panelShadow: Dp = 0.dp,
val panelShadowAlpha: Float = 0f,
/** Replaces this look when the UI is light, for effects that only work on one background. */ /** Replaces this look when the UI is light, for effects that only work on one background. */
val light: ControlLook? = null, val light: ControlLook? = null,
) )
@@ -72,7 +78,11 @@ object GsThemes {
val Keycap = GsTheme( val Keycap = GsTheme(
id = "keycap", id = "keycap",
name = "Keycap", name = "Keycap",
control = ControlLook(lip = 4.dp, pressedScale = 1f, holdMarker = HoldMarkerStyle.PEEL), // Short and dark, like the keys' own solid base.
control = ControlLook(
lip = 4.dp, pressedScale = 1f, holdMarker = HoldMarkerStyle.PEEL,
panelShadow = 4.dp, panelShadowAlpha = 0.45f,
),
) )
val Tactile = GsTheme( val Tactile = GsTheme(
@@ -89,6 +99,8 @@ object GsThemes {
pressedDim = 0.12f, pressedDim = 0.12f,
pressedScale = 1f, pressedScale = 1f,
holdMarker = HoldMarkerStyle.PEEL, holdMarker = HoldMarkerStyle.PEEL,
panelShadow = 12.dp,
panelShadowAlpha = 0.42f,
// A wide soft shadow bands once the car's video stream compresses it on a light // A wide soft shadow bands once the car's video stream compresses it on a light
// background, so light mode gets a tight one and leans on the bevel instead. // background, so light mode gets a tight one and leans on the bevel instead.
light = ControlLook( light = ControlLook(
@@ -102,6 +114,8 @@ object GsThemes {
pressedDim = 0.12f, pressedDim = 0.12f,
pressedScale = 1f, pressedScale = 1f,
holdMarker = HoldMarkerStyle.PEEL, holdMarker = HoldMarkerStyle.PEEL,
panelShadow = 4.dp,
panelShadowAlpha = 0.22f,
), ),
), ),
) )
@@ -227,6 +227,7 @@ class CarAppConnection(private val context: Context) {
private fun disconnect(keepLocation: Boolean) { private fun disconnect(keepLocation: Boolean) {
main.removeCallbacks(handshakeTimeout) main.removeCallbacks(handshakeTimeout)
hardware.stop()
clearToasts() clearToasts()
lent?.let { sendSurfaceDestroyed(it) } lent?.let { sendSurfaceDestroyed(it) }
lent = null lent = null
@@ -599,6 +600,7 @@ class CarAppConnection(private val context: Context) {
fun reportAreas(visible: Rect, stable: Rect) { fun reportAreas(visible: Rect, stable: Rect) {
areas = visible to stable areas = visible to stable
val callback = surfaceCallback ?: return val callback = surfaceCallback ?: return
GearslipLog.i("host: areas for $appPackage: visible=${visible.toShortString()} stable=${stable.toShortString()}")
runCatching { callback.onStableAreaChanged(stable, noop("onStableAreaChanged")) } runCatching { callback.onStableAreaChanged(stable, noop("onStableAreaChanged")) }
runCatching { callback.onVisibleAreaChanged(visible, noop("onVisibleAreaChanged")) } runCatching { callback.onVisibleAreaChanged(visible, noop("onVisibleAreaChanged")) }
} }
@@ -657,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
} }
@@ -684,38 +694,25 @@ class CarAppConnection(private val context: Context) {
} }
/** /**
* Answers car-hardware questions with "this car doesn't have that". * Answers car-hardware questions from what the car itself sends: see [CarHardwareFeed].
* *
* A phone knows nothing about a car's sensors, so every answer here is "unsupported" - but * Every question has to get an actual answer, even "unsupported". Returning no binder for
* it has to be an actual answer. Returning no binder for this service at all is what made * this service at all is what made Organic Maps crash in onAppStart: its CarSensorsManager
* Organic Maps crash in onAppStart: its CarSensorsManager assumes a host that offers the * assumes a host that offers the service, and the library's own "unsupported" path only runs
* service, and the library's own "unsupported" path only runs once a reply arrives. * once a reply arrives. The reply's IBinder must be a real IOnDoneCallback: the app
* * dispatches the reply through it.
* The reply's Bundleable is ignored by the library whenever isSupported is false, but the
* IBinder must be a real IOnDoneCallback - the app dispatches the reply through it.
*/ */
private val hardware = CarHardwareFeed { name -> noop(name).asBinder() }
private val hardwareHost = object : ICarHardwareHost.Stub() { private val hardwareHost = object : ICarHardwareHost.Stub() {
override fun getInterfaceVersion(): Int = ICarHardwareHost.VERSION override fun getInterfaceVersion(): Int = ICarHardwareHost.VERSION
override fun getCarHardwareResult( override fun getCarHardwareResult(type: Int, params: Bundleable?, result: ICarHardwareResult?) =
type: Int, hardware.get(type, result)
params: Bundleable?,
result: ICarHardwareResult?,
) = unsupported(type, result)
override fun subscribeCarHardwareResult( override fun subscribeCarHardwareResult(type: Int, params: Bundleable?, result: ICarHardwareResult?) =
type: Int, hardware.subscribe(type, result)
params: Bundleable?,
result: ICarHardwareResult?,
) = unsupported(type, result)
override fun unsubscribeCarHardwareResult(type: Int, params: Bundleable?) = Unit override fun unsubscribeCarHardwareResult(type: Int, params: Bundleable?) = hardware.unsubscribe(type)
private fun unsupported(type: Int, result: ICarHardwareResult?) {
val target = result ?: return
runCatching {
target.onCarHardwareResult(type, false, null, noop("carHardwareResult").asBinder())
}.onFailure { GearslipLog.w("host: could not answer hardware result $type") }
}
} }
/** /**
@@ -850,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()
} }
@@ -0,0 +1,171 @@
package app.seb3thehacker.gearslip.host
import android.os.IBinder
import androidx.car.app.hardware.ICarHardwareResult
import androidx.car.app.hardware.common.CarUnit
import androidx.car.app.hardware.common.CarValue
import androidx.car.app.hardware.info.CarHardwareLocation
import androidx.car.app.hardware.info.Compass
import androidx.car.app.hardware.info.EnergyLevel
import androidx.car.app.hardware.info.Mileage
import androidx.car.app.hardware.info.Model
import androidx.car.app.hardware.info.Speed
import androidx.car.app.serialization.Bundleable
import app.seb3thehacker.gearslip.GearslipLog
import app.seb3thehacker.gearslip.car.CarData
import app.seb3thehacker.gearslip.car.CarSettings
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CopyOnWriteArrayList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
/**
* What a car app gets when it asks the car hardware API (CarInfo, CarSensors) about the car:
* the model, speed, odometer, fuel and range, compass and the car's own GPS, read from [CarData].
* Anything else, or anything the car never sends, is answered "this car doesn't have that".
*
* A one-off question ([get]) is answered at once. A subscription gets the current value, if
* there is one, and then each new one as the car sends it, until the app unsubscribes or the
* connection ends ([stop]).
*/
internal class CarHardwareFeed(private val callback: (name: String) -> IBinder) {
private val subscribers = ConcurrentHashMap<Int, CopyOnWriteArrayList<ICarHardwareResult>>()
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@Volatile private var watching: Job? = null
fun get(type: Int, result: ICarHardwareResult?) {
val target = result ?: return
send(target, type, valueOf(type, CarData.state.value) ?: return unsupported(target, type))
}
fun subscribe(type: Int, result: ICarHardwareResult?) {
val target = result ?: return
if (type !in SENSOR_OF) return unsupported(target, type)
subscribers.getOrPut(type) { CopyOnWriteArrayList() }.add(target)
GearslipLog.i("host: an app subscribed to car hardware ${NAMES[type] ?: type}")
valueOf(type, CarData.state.value)?.let { send(target, type, it) }
watch()
}
fun unsubscribe(type: Int) {
subscribers.remove(type)
}
fun stop() {
subscribers.clear()
watching?.cancel()
watching = null
}
fun close() {
stop()
scope.cancel()
}
/** Sends each subscribed kind again when the car's reading of it changes. */
private fun watch() {
if (watching?.isActive == true) return
watching = scope.launch {
var seen = CarData.state.value.updatedAt
CarData.state.collect { s ->
for ((type, targets) in subscribers) {
val sensor = SENSOR_OF[type] ?: continue
if (s.updatedAt[sensor] == seen[sensor]) continue
val value = valueOf(type, s) ?: continue
targets.forEach { send(it, type, value) }
}
seen = s.updatedAt
}
}
}
private fun valueOf(type: Int, s: CarData.Snapshot): Any? {
val imperial = CarSettings.imperial()
val distanceUnit = known(if (imperial) CarUnit.MILE else CarUnit.KILOMETER)
val at = s.updatedAt[SENSOR_OF[type]] ?: System.currentTimeMillis()
fun <T : Any> v(value: T) = CarValue(value, at, CarValue.STATUS_SUCCESS)
return when (type) {
TYPE_MODEL -> CarData.model?.let { m ->
Model.Builder().setName(v(m.name)).apply { m.year?.let { setYear(v(it)) } }.build()
}
TYPE_SPEED -> s.speedMps?.let {
Speed.Builder()
.setRawSpeedMetersPerSecond(v(it))
.setDisplaySpeedMetersPerSecond(v(it))
.setSpeedDisplayUnit(known(if (imperial) CarUnit.MILES_PER_HOUR else CarUnit.KILOMETERS_PER_HOUR))
.build()
}
TYPE_MILEAGE -> s.odometerKm?.let {
Mileage.Builder().setOdometerMeters(v(it * 1000f)).setDistanceDisplayUnit(distanceUnit).build()
}
TYPE_ENERGY_LEVEL -> if (s.fuelPercent == null && s.rangeKm == null && s.fuelLow == null) null else {
EnergyLevel.Builder().apply {
s.fuelPercent?.let { setFuelPercent(v(it.toFloat())) }
s.rangeKm?.let { setRangeRemainingMeters(v(it * 1000f)) }
s.fuelLow?.let { setEnergyIsLow(v(it)) }
setDistanceDisplayUnit(distanceUnit)
}.build()
}
TYPE_COMPASS -> s.compassDegrees?.let { Compass(v(listOf(it))) }
TYPE_CAR_LOCATION -> s.location?.let { l ->
val location = android.location.Location("car").apply {
latitude = l.latitude
longitude = l.longitude
l.accuracyMeters?.let { accuracy = it }
l.altitudeMeters?.let { altitude = it }
l.speedMps?.let { speed = it }
l.bearingDegrees?.let { bearing = it }
time = at
}
CarHardwareLocation(v(location))
}
else -> null
}
}
private fun known(value: Int) = CarValue(value, System.currentTimeMillis(), CarValue.STATUS_SUCCESS)
private fun send(target: ICarHardwareResult, type: Int, value: Any) {
runCatching {
target.onCarHardwareResult(type, true, Bundleable.create(value), callback("carHardwareResult"))
}.onFailure {
// The app went away: stop telling it.
subscribers.values.forEach { list -> list.remove(target) }
GearslipLog.w("host: could not send car hardware ${NAMES[type] ?: type}: ${it.javaClass.simpleName}")
}
}
private fun unsupported(target: ICarHardwareResult, type: Int) {
runCatching { target.onCarHardwareResult(type, false, null, callback("carHardwareResult")) }
.onFailure { GearslipLog.w("host: could not answer hardware result $type") }
}
companion object {
// ICarHardwareResultTypes in the car library.
private const val TYPE_MODEL = 1
private const val TYPE_ENERGY_LEVEL = 4
private const val TYPE_SPEED = 5
private const val TYPE_MILEAGE = 6
private const val TYPE_COMPASS = 21
private const val TYPE_CAR_LOCATION = 23
/** Which of the car's sensors each kind comes from, for knowing when it changed. */
private val SENSOR_OF = mapOf(
TYPE_ENERGY_LEVEL to CarData.FUEL,
TYPE_SPEED to CarData.SPEED,
TYPE_MILEAGE to CarData.ODOMETER,
TYPE_COMPASS to CarData.COMPASS,
TYPE_CAR_LOCATION to CarData.LOCATION,
)
private val NAMES = mapOf(
TYPE_MODEL to "model", TYPE_ENERGY_LEVEL to "energy level", TYPE_SPEED to "speed",
TYPE_MILEAGE to "mileage", TYPE_COMPASS to "compass", TYPE_CAR_LOCATION to "car location",
)
}
}
@@ -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
@@ -37,17 +38,33 @@ class MediaEntry(
val iconBitmap: Bitmap?, val iconBitmap: Bitmap?,
val browsable: Boolean, val browsable: Boolean,
val playable: Boolean, val playable: Boolean,
/** Shown as a cover in a grid rather than a row, as the app asked. */
val grid: Boolean = false,
/** The heading the app files this under, when it splits a list into sections. */
val group: String? = null,
) )
/** [icon] is a drawable id inside the media app's own package, not Gearslip's; 0 for none. */ /**
class CustomAction(val id: String, val name: String, val extras: Bundle?, val icon: Int = 0, val iconName: String = "") { * [icon] is a drawable id inside the media app's own package, not Gearslip's; 0 for none.
* [hasOutline] says the app also has an outline of the same icon (ic_heart beside ic_heart_outline).
*/
class CustomAction(
val id: String,
val name: String,
val extras: Bundle?,
val icon: Int = 0,
val iconName: String = "",
val hasOutline: Boolean = false,
) {
/** /**
* Whether a toggle like heart or shuffle is on, read from the icon's name - Tidal swaps * Whether a toggle like heart or shuffle is on, read from the icon's name - Tidal swaps
* ic_favorite for ic_favorite_filled and ic_shuffle for ic_shuffle_active, and keeps the * ic_favorite for ic_favorite_filled and ic_shuffle for ic_shuffle_active, and keeps the
* button's name the same. Whole words only, so "inactive" doesn't count as "active". * button's name the same. Whole words only, so "inactive" doesn't count as "active".
* Metrolist swaps ic_heart_outline for a plain ic_heart, so a plain icon that has an outline
* twin counts as on too.
*/ */
val on: Boolean val on: Boolean
get() = iconName.lowercase().split('_', '.', '-').any { it in ON_WORDS } get() = hasOutline || iconName.lowercase().split('_', '.', '-').any { it in ON_WORDS }
private companion object { private companion object {
val ON_WORDS = setOf("filled", "fill", "active", "on", "selected", "checked", "enabled", "solid", "one", "all") val ON_WORDS = setOf("filled", "fill", "active", "on", "selected", "checked", "enabled", "solid", "one", "all")
@@ -113,8 +130,8 @@ class NowPlaying(
* only says which way it goes next by its name ("Remove from Liked Songs"), so that's read. * only says which way it goes next by its name ("Remove from Liked Songs"), so that's read.
*/ */
val liked: Boolean? val liked: Boolean?
get() = heart ?: likeAction?.name?.let { name -> get() = heart ?: likeAction?.let { action ->
listOf("remove", "unlike", "unfav", "unheart").any { name.contains(it, ignoreCase = true) } action.on || listOf("remove", "unlike", "unfav", "unheart").any { action.name.contains(it, ignoreCase = true) }
} }
val playing get() = state == PlaybackState.STATE_PLAYING || state == PlaybackState.STATE_BUFFERING val playing get() = state == PlaybackState.STATE_PLAYING || state == PlaybackState.STATE_BUFFERING
@@ -271,7 +288,10 @@ class CarMedia(private val context: Context) {
GearslipLog.w("media: ${app.label} went away") GearslipLog.w("media: ${app.label} went away")
_phase.value = Phase.REJECTED _phase.value = Phase.REJECTED
} }
}, null) }, Bundle().apply {
// Tells the app it may ask for grids and section headings, as it would of Android Auto.
putBoolean(STYLE_SUPPORTED, true)
})
browser = mb browser = mb
GearslipLog.i("media: connecting to ${app.label}'s library") GearslipLog.i("media: connecting to ${app.label}'s library")
runCatching { mb.connect() }.onFailure { runCatching { mb.connect() }.onFailure {
@@ -342,6 +362,8 @@ class CarMedia(private val context: Context) {
GearslipLog.i("media: connected to ${app.label}, root=${mb.root}") GearslipLog.i("media: connected to ${app.label}, root=${mb.root}")
attach(MediaController(context, mb.sessionToken)) attach(MediaController(context, mb.sessionToken))
rootId = mb.root rootId = mb.root
childStyles.clear()
mb.extras?.let { childStyles[mb.root] = it.getInt(STYLE_BROWSABLE, 0) to it.getInt(STYLE_PLAYABLE, 0) }
path.clear() path.clear()
_phase.value = Phase.READY _phase.value = Phase.READY
open(mb.root) open(mb.root)
@@ -382,7 +404,10 @@ 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 {
val iconName = iconName(it.icon)
CustomAction(it.action, it.name.toString(), it.extras, it.icon, iconName, hasOutline(it.icon, iconName))
}),
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) {
@@ -395,16 +420,48 @@ 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
/**
* 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) {
appResources = pkg to runCatching { context.packageManager.getResourcesForApplication(pkg) }.getOrNull()
outlines.clear()
}
return appResources?.second
}
/** 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 { private fun iconName(icon: Int): String {
val pkg = controller?.packageName ?: return "" val pkg = controller?.packageName ?: return ""
if (appResources?.first != pkg) { val res = resourcesOf(pkg) ?: return ""
appResources = pkg to runCatching { context.packageManager.getResourcesForApplication(pkg) }.getOrNull()
}
val res = appResources?.second ?: return ""
return runCatching { res.getResourceEntryName(icon) }.getOrDefault("") return runCatching { res.getResourceEntryName(icon) }.getOrDefault("")
} }
/** Whether each icon has an outline twin, by icon id: looked up once, since state changes often. */
private val outlines = HashMap<Int, Boolean>()
private fun hasOutline(icon: Int, name: String): Boolean {
val pkg = controller?.packageName ?: return false
val res = resourcesOf(pkg) ?: return false
if (name.isEmpty() || OUTLINE_WORDS.any { name.contains(it, ignoreCase = true) }) return false
return outlines.getOrPut(icon) {
OUTLINE_WORDS.any { res.getIdentifier("${name}_$it", "drawable", pkg) != 0 }
}
}
private var loggedButtons: String? = null private var loggedButtons: String? = null
/** The app's own buttons, by id, name and icon, each time they change. */ /** The app's own buttons, by id, name and icon, each time they change. */
@@ -427,7 +484,7 @@ class CarMedia(private val context: Context) {
if (parentId != subscribed) return if (parentId != subscribed) return
_browse.value = BrowseState( _browse.value = BrowseState(
trail = path.map { it.second }, trail = path.map { it.second },
entries = children.map(::entryOf), entries = children.map { entryOf(it, childStyles[parentId]) },
loading = false, loading = false,
// An empty top level is an app keeping its library to itself, not an empty one. // An empty top level is an app keeping its library to itself, not an empty one.
unavailable = path.isEmpty() && children.isEmpty(), unavailable = path.isEmpty() && children.isEmpty(),
@@ -441,8 +498,21 @@ class CarMedia(private val context: Context) {
}) })
} }
private fun entryOf(item: MediaBrowser.MediaItem): MediaEntry { /**
* How a folder asked for its children to be shown, browsable ones and playable ones: by
* folder id, read off the folder itself (or the root's extras) before it's opened.
*/
private val childStyles = HashMap<String, Pair<Int, Int>>()
private fun entryOf(item: MediaBrowser.MediaItem, parentStyle: Pair<Int, Int>?): MediaEntry {
val d = item.description val d = item.description
val extras = d.extras
if (extras != null && item.isBrowsable) {
childStyles[item.mediaId.orEmpty()] = extras.getInt(STYLE_BROWSABLE, 0) to extras.getInt(STYLE_PLAYABLE, 0)
}
// The item's own hint first, then what its folder asked for its kind of child.
val style = extras?.getInt(STYLE_SINGLE_ITEM, 0)?.takeIf { it != 0 }
?: (if (item.isBrowsable) parentStyle?.first else parentStyle?.second) ?: 0
return MediaEntry( return MediaEntry(
id = item.mediaId.orEmpty(), id = item.mediaId.orEmpty(),
title = d.title?.toString().orEmpty(), title = d.title?.toString().orEmpty(),
@@ -451,6 +521,8 @@ class CarMedia(private val context: Context) {
iconBitmap = d.iconBitmap, iconBitmap = d.iconBitmap,
browsable = item.isBrowsable, browsable = item.isBrowsable,
playable = item.isPlayable, playable = item.isPlayable,
grid = style == STYLE_GRID || style == STYLE_CATEGORY_GRID,
group = extras?.getString(STYLE_GROUP_TITLE)?.takeIf { it.isNotBlank() },
) )
} }
@@ -491,7 +563,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)
/** /**
@@ -770,5 +850,20 @@ class CarMedia(private val context: Context) {
/** How long an app gets to start playing a search before Gearslip says it didn't. */ /** How long an app gets to start playing a search before Gearslip says it didn't. */
const val SEARCH_WAIT_MS = 6_000L const val SEARCH_WAIT_MS = 6_000L
const val SEARCH_NOTICE_MS = 6_000L const val SEARCH_NOTICE_MS = 6_000L
/** How apps name the empty version of an icon. */
val OUTLINE_WORDS = listOf("outline", "outlined", "border", "empty")
} }
} }
/** 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_BROWSABLE = "android.media.browse.CONTENT_STYLE_BROWSABLE_HINT"
private const val STYLE_PLAYABLE = "android.media.browse.CONTENT_STYLE_PLAYABLE_HINT"
private const val STYLE_SINGLE_ITEM = "android.media.browse.CONTENT_STYLE_SINGLE_ITEM_HINT"
private const val STYLE_GROUP_TITLE = "android.media.browse.CONTENT_STYLE_GROUP_TITLE_HINT"
private const val STYLE_GRID = 2
private const val STYLE_CATEGORY_GRID = 4
@@ -85,7 +85,9 @@ class CarMediaStatus(
} }
private fun sendTrack(now: NowPlaying) { private fun sendTrack(now: NowPlaying) {
val key = "${now.title}\u0000${now.artist}\u0000${now.album}\u0000${now.durationMs}\u0000${now.art?.generationId}" // Whether there's a cover, not which bitmap: some apps (Metrolist) hand over a fresh copy
// of the same cover with every update, and resending it each second wastes the link.
val key = "${now.title}\u0000${now.artist}\u0000${now.album}\u0000${now.durationMs}\u0000${now.art != null}"
if (key == sentTrack) return if (key == sentTrack) return
sentTrack = key sentTrack = key
var body = Protobuf.stringField(1, now.title) + var body = Protobuf.stringField(1, now.title) +
@@ -1,28 +1,55 @@
package app.seb3thehacker.gearslip.media package app.seb3thehacker.gearslip.media
import android.content.Context import android.content.Context
import android.util.Base64
import android.util.Xml
import app.seb3thehacker.gearslip.GearslipLog import app.seb3thehacker.gearslip.GearslipLog
import app.seb3thehacker.gearslip.car.CarSettings
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import org.xmlpull.v1.XmlPullParser
import java.io.File import java.io.File
import java.io.StringReader
import java.net.HttpURLConnection import java.net.HttpURLConnection
import java.net.URL import java.net.URL
import java.net.URLEncoder import java.net.URLEncoder
import java.security.MessageDigest import java.security.MessageDigest
import kotlin.math.abs
class LyricLine(val timeMs: Long, val text: String)
/** [synced] means every line carries a time, so the current one can be followed along. */
class LyricsResult(val lines: List<LyricLine>, val synced: Boolean)
/** /**
* Looks lyrics up on LRCLIB (lrclib.net), a free community database that needs no key. * One line. [words] is filled only for word-timed lyrics, and joined back together it reads as
* [text]; each word carries the space after it, if any, so a word split into syllables stays whole.
*/
class LyricLine(val timeMs: Long, val text: String, val words: List<LyricWord> = emptyList())
/** A word, or a syllable of one, sung from [startMs] to [endMs]. */
class LyricWord(val startMs: Long, val endMs: Long, val text: String)
/**
* [synced] means every line carries a time, so the current one can be followed along. [source]
* names the service that had them, shown on debug builds.
*/
class LyricsResult(val lines: List<LyricLine>, val synced: Boolean, val source: String = "")
/**
* Looks lyrics up online, in the order Metrolist uses: BetterLyrics, then LRCLIB, then KuGou.
* Metrolist's other sources match songs by YouTube video id, which a song from another app
* doesn't have, and Paxsenix calls Apple Music with a borrowed token, so they're left out.
* *
* Media apps almost never publish lyrics in their session metadata, so this is the only general * - BetterLyrics (lyrics-api.boidu.dev): word-timed lyrics, but only for songs its server has
* source. It sends the track's title, artist, album and length to lrclib.net, which is why the * already fetched; anything else needs an API key and falls through.
* lookup happens only when the driver opens the lyrics view and never in the background. * - LRCLIB (lrclib.net): a free community database that needs no key.
* - KuGou (kugou.com): a large Chinese music service, strong on Asian music.
*
* Media apps almost never publish lyrics in their session metadata, so these are the only general
* source. Each lookup sends the track's title, artist, album and length to these services, which
* is why it happens only when the driver opens the lyrics view, never in the background. With
* [CarSettings.moreLyrics] off, only LRCLIB is asked.
* *
* Results are cached twice over: in memory for the life of the process, and on disk under the * Results are cached twice over: in memory for the life of the process, and on disk under the
* cache directory so a song looked up on a past drive doesn't need the network again on this one. * cache directory so a song looked up on a past drive doesn't need the network again on this one.
@@ -33,33 +60,104 @@ object Lyrics {
private val cache = HashMap<String, LyricsResult?>() private val cache = HashMap<String, LyricsResult?>()
/** Longest any one source may take, DNS included: a server that never answers can't hold the rest up. */
private const val SOURCE_TIMEOUT_MS = 12_000L
/** How long a cached song waits before its sources are asked again for better lyrics. */
private const val RECHECK_MS = 24 * 60 * 60 * 1000L
private val io = CoroutineScope(Dispatchers.IO)
/** Word-timed beats line-timed, which beats plain text. */
private const val BEST = 3
private fun quality(result: LyricsResult?): Int = when {
result == null -> 0
!result.synced -> 1
result.lines.any { it.words.isNotEmpty() } -> BEST
else -> 2
}
/** One place to look, with the best [quality] it can give. */
private class Source(val name: String, val best: Int, val lookup: () -> LyricsResult?)
private fun sources(title: String, artist: String, album: String, durationMs: Long): List<Source> {
val more = CarSettings.moreLyrics.value
val seconds = (durationMs / 1000).toInt()
return buildList {
if (more) add(Source("BetterLyrics", BEST) { betterLyrics(title, artist, album, seconds) })
add(Source("LRCLIB", 2) { exact(title, artist, album, durationMs) ?: search(title, artist) })
if (more) add(Source("KuGou", 2) { kuGou(title, artist, album, seconds) })
}
}
private fun key(title: String, artist: String, album: String, durationMs: Long) = "$title|$artist|$album|$durationMs"
suspend fun find(context: Context, title: String, artist: String, album: String, durationMs: Long): LyricsResult? { suspend fun find(context: Context, title: String, artist: String, album: String, durationMs: Long): LyricsResult? {
if (title.isBlank()) return null if (title.isBlank()) return null
val key = "$title|$artist|$album|$durationMs" val key = key(title, artist, album, durationMs)
synchronized(cache) { if (cache.containsKey(key)) return cache[key] } // A miss with only LRCLIB isn't a miss once more sources are on, so the mode is part of it.
val memoryKey = "${CarSettings.moreLyrics.value}|$key"
synchronized(cache) { if (cache.containsKey(memoryKey)) return cache[memoryKey] }
val fromDisk = withContext(Dispatchers.IO) { readDisk(context, key) } val fromDisk = withContext(Dispatchers.IO) { readDisk(context, key) }
if (fromDisk != null) { if (fromDisk != null) {
synchronized(cache) { cache[key] = fromDisk } synchronized(cache) { cache[memoryKey] = fromDisk }
return fromDisk return fromDisk
} }
val result = withContext(Dispatchers.IO) { val result = firstBetter(sources(title, artist, album, durationMs), than = 0)
runCatching { synchronized(cache) { cache[memoryKey] = result }
exact(title, artist, album, durationMs) ?: search(title, artist) if (result != null) withContext(Dispatchers.IO) { writeDisk(context, key, result, System.currentTimeMillis()) }
}.onFailure { GearslipLog.w("lyrics: lookup failed: ${it.message}") }.getOrNull()
}
synchronized(cache) { cache[key] = result }
if (result != null) withContext(Dispatchers.IO) { writeDisk(context, key, result) }
return result return result
} }
/**
* After [find] answered with [current], looks for better lyrics than it: word-timed where it has
* line-timed, timed where it has plain. Only the sources that could beat it are asked, and only
* once a day per song, so an old cache entry improves when a source gains the song without every
* play sending the song out again. Returns the better lyrics, or null to keep [current].
*/
suspend fun upgrade(
context: Context, title: String, artist: String, album: String, durationMs: Long, current: LyricsResult,
): LyricsResult? {
val have = quality(current)
if (have >= BEST || title.isBlank()) return null
val key = key(title, artist, album, durationMs)
val checked = withContext(Dispatchers.IO) { checkedAt(context, key) }
if (System.currentTimeMillis() - checked < RECHECK_MS) return null
val candidates = sources(title, artist, album, durationMs).filter { it.best > have && it.name != current.source }
val better = firstBetter(candidates, than = have)
withContext(Dispatchers.IO) { writeDisk(context, key, better ?: current, System.currentTimeMillis()) }
if (better == null) return null
GearslipLog.i("lyrics: replaced ${current.source.ifEmpty { "cached" }} lyrics with ${better.source}'s")
synchronized(cache) { cache["${CarSettings.moreLyrics.value}|$key"] = better }
return better
}
/** Asks [sources] in order and returns the first answer of a higher [quality] than [than]. */
private suspend fun firstBetter(sources: List<Source>, than: Int): LyricsResult? {
for (source in sources) {
val job = io.async {
runCatching(source.lookup).onFailure { GearslipLog.w("lyrics: ${source.name} failed: ${it.message}") }.getOrNull()
}
val result = withTimeoutOrNull(SOURCE_TIMEOUT_MS) { job.await() }
?.let { LyricsResult(it.lines, it.synced, source.name) }
if (quality(result) > than) {
GearslipLog.i("lyrics: found on ${source.name} (${if (result!!.synced) "synced" else "plain"})")
return result
}
}
return null
}
private fun cacheFile(context: Context, key: String): File { private fun cacheFile(context: Context, key: String): File {
val digest = MessageDigest.getInstance("SHA-1").digest(key.toByteArray()) val digest = MessageDigest.getInstance("SHA-1").digest(key.toByteArray())
val name = digest.joinToString("") { "%02x".format(it) } val name = digest.joinToString("") { "%02x".format(it) }
return File(context.cacheDir, "lyrics").apply { mkdirs() }.resolve("$name.json") return File(context.cacheDir, LYRICS_DIR).apply { mkdirs() }.resolve("$name.json")
} }
/** Where the lyrics cache lives under the app's cache directory, for the storage screen too. */
const val LYRICS_DIR = "lyrics"
private fun readDisk(context: Context, key: String): LyricsResult? = runCatching { private fun readDisk(context: Context, key: String): LyricsResult? = runCatching {
val file = cacheFile(context, key) val file = cacheFile(context, key)
if (!file.exists()) return null if (!file.exists()) return null
@@ -67,23 +165,153 @@ object Lyrics {
val lines = json.getJSONArray("lines").let { arr -> val lines = json.getJSONArray("lines").let { arr ->
(0 until arr.length()).map { i -> (0 until arr.length()).map { i ->
val line = arr.getJSONObject(i) val line = arr.getJSONObject(i)
LyricLine(line.getLong("t"), line.getString("x")) val words = line.optJSONArray("w")?.let { w ->
(0 until w.length()).map { j ->
val word = w.getJSONArray(j)
LyricWord(word.getLong(0), word.getLong(1), word.getString(2))
}
}.orEmpty()
LyricLine(line.getLong("t"), line.getString("x"), words)
} }
} }
LyricsResult(lines, json.getBoolean("synced")) val source = json.optString("source")
// Saved before word timings were kept: fetch again rather than wait a day for an upgrade
// that skips the source it came from.
if (source == "BetterLyrics" && lines.none { it.words.isNotEmpty() }) return null
LyricsResult(lines, json.getBoolean("synced"), source)
}.getOrNull() }.getOrNull()
private fun writeDisk(context: Context, key: String, result: LyricsResult) { /** When the song's sources were last asked; 0 for entries from before the date was kept. */
private fun checkedAt(context: Context, key: String): Long =
runCatching { JSONObject(cacheFile(context, key).readText()).optLong("checked") }.getOrDefault(0L)
private fun writeDisk(context: Context, key: String, result: LyricsResult, checked: Long) {
runCatching { runCatching {
val lines = JSONArray() val lines = JSONArray()
result.lines.forEach { line -> result.lines.forEach { line ->
lines.put(JSONObject().put("t", line.timeMs).put("x", line.text)) val entry = JSONObject().put("t", line.timeMs).put("x", line.text)
if (line.words.isNotEmpty()) {
entry.put("w", JSONArray(line.words.map { JSONArray().put(it.startMs).put(it.endMs).put(it.text) }))
}
lines.put(entry)
} }
val json = JSONObject().put("synced", result.synced).put("lines", lines) val json = JSONObject().put("synced", result.synced).put("source", result.source)
.put("checked", checked).put("lines", lines)
cacheFile(context, key).writeText(json.toString()) cacheFile(context, key).writeText(json.toString())
}.onFailure { GearslipLog.w("lyrics: could not write disk cache: ${it.message}") } }.onFailure { GearslipLog.w("lyrics: could not write disk cache: ${it.message}") }
} }
/** Forgets every cached song, on disk and in memory. */
fun clearCache(context: Context) {
synchronized(cache) { cache.clear() }
File(context.cacheDir, LYRICS_DIR).deleteRecursively()
}
// --- BetterLyrics ---
private fun betterLyrics(title: String, artist: String, album: String, seconds: Int): LyricsResult? {
if (artist.isBlank()) return null
val query = buildString {
append("s=").append(enc(title)).append("&a=").append(enc(artist))
if (seconds > 0) append("&d=").append(seconds)
if (album.isNotBlank()) append("&al=").append(enc(album))
}
val body = get("https://lyrics-api.boidu.dev/getLyrics?$query") ?: return null
val ttml = JSONObject(body).optString("ttml").takeIf { it.isNotBlank() && it != "null" } ?: return null
return parseTtml(ttml)
}
/**
* Apple-style TTML: one `<p begin=…>` per line, its words in `<span begin=… end=…>`s with
* spaces between; a word sung in syllables is several spans with no space. Background vocals,
* translations and romanisations are left out, so a line reads as sung.
*/
private fun parseTtml(ttml: String): LyricsResult? {
val parser = Xml.newPullParser().apply {
setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true)
setInput(StringReader(ttml))
}
val lines = mutableListOf<LyricLine>()
var inP = false
var lineBegin: Long? = null
var firstSpanBegin: Long? = null
var skipDepth = 0
val text = StringBuilder()
// The span being read, and the words of the line so far, each with the space after it.
var spanBegin: Long? = null
var spanEnd: Long? = null
val words = mutableListOf<Triple<Long, Long, StringBuilder>>()
var event = parser.eventType
while (event != XmlPullParser.END_DOCUMENT) {
when (event) {
XmlPullParser.START_TAG -> when {
parser.name == "p" -> {
inP = true; text.clear(); firstSpanBegin = null; words.clear()
lineBegin = attr(parser, "begin")?.let(::ttmlMs)
}
inP && skipDepth > 0 -> skipDepth++
inP && parser.name == "span" -> {
val role = attr(parser, "role")
if (role == "x-bg" || role == "x-translation" || role == "x-roman") skipDepth = 1
else {
spanBegin = attr(parser, "begin")?.let(::ttmlMs)
spanEnd = attr(parser, "end")?.let(::ttmlMs)
if (firstSpanBegin == null) firstSpanBegin = spanBegin
}
}
}
XmlPullParser.TEXT -> if (inP && skipDepth == 0) {
val raw = parser.text
text.append(raw)
val begin = spanBegin
val end = spanEnd
if (begin != null && end != null && raw.isNotBlank()) {
val word = raw.replace(Regex("\\s+"), " ").trimStart()
words.add(Triple(begin, end, StringBuilder(word)))
} else if (raw.isNotEmpty() && raw.isBlank() && words.isNotEmpty() && !words.last().third.endsWith(" ")) {
words.last().third.append(' ')
}
}
XmlPullParser.END_TAG -> when {
inP && skipDepth > 0 -> skipDepth--
inP && parser.name == "span" -> { spanBegin = null; spanEnd = null }
parser.name == "p" -> {
inP = false
val timed = words.map { (b, e, w) -> LyricWord(b, e, w.toString()) }
val line = if (timed.isNotEmpty()) timed.joinToString("") { it.text }.trim()
else text.toString().replace(Regex("\\s+"), " ").trim()
if (line.isNotEmpty()) lines.add(LyricLine(lineBegin ?: firstSpanBegin ?: -1, line, timed))
}
}
}
event = parser.next()
}
if (lines.isEmpty()) return null
val synced = lines.all { it.timeMs >= 0 } && lines.any { it.timeMs > 0 }
return if (synced) LyricsResult(lines.sortedBy { it.timeMs }, synced = true)
else LyricsResult(lines.map { LyricLine(0, it.text) }, synced = false)
}
private fun attr(parser: XmlPullParser, localName: String): String? =
(0 until parser.attributeCount).firstOrNull { parser.getAttributeName(it) == localName }
?.let { parser.getAttributeValue(it) }
/** TTML times: `1:23.456`, `1:02:03.4`, `83.456s`, `250ms` or a bare number of seconds. */
private fun ttmlMs(raw: String): Long? {
val t = raw.trim()
val seconds = when {
':' in t -> t.split(':').fold(0.0) { acc, part -> acc * 60 + (part.toDoubleOrNull() ?: return null) }
t.endsWith("ms") -> (t.dropLast(2).toDoubleOrNull() ?: return null) / 1000
t.endsWith("s") -> t.dropLast(1).toDoubleOrNull() ?: return null
t.endsWith("m") -> (t.dropLast(1).toDoubleOrNull() ?: return null) * 60
t.endsWith("h") -> (t.dropLast(1).toDoubleOrNull() ?: return null) * 3600
else -> t.toDoubleOrNull() ?: return null
}
return (seconds * 1000).toLong()
}
// --- LRCLIB ---
private fun exact(title: String, artist: String, album: String, durationMs: Long): LyricsResult? { private fun exact(title: String, artist: String, album: String, durationMs: Long): LyricsResult? {
if (artist.isBlank()) return null if (artist.isBlank()) return null
val query = buildString { val query = buildString {
@@ -111,6 +339,73 @@ object Lyrics {
return LyricsResult(plain.lines().map { LyricLine(0, it) }, synced = false) return LyricsResult(plain.lines().map { LyricLine(0, it) }, synced = false)
} }
// --- KuGou ---
/** Songs within this many seconds of the playing one count as the same recording. */
private const val KUGOU_DURATION_SLACK = 8
private val KUGOU_TIMED = Regex("""\[(\d\d):(\d\d)\.(\d{2,3})].*""")
/** A credit line such as `[00:01.00]作词:…` or `[00:02.00]Composer: …`. */
private val KUGOU_CREDIT = Regex(""".+].+[::].+""")
private const val KUGOU_CREDIT_LINES = 30
private fun kuGou(title: String, artist: String, album: String, seconds: Int): LyricsResult? {
val keyword = buildString {
append(kuGouTitle(title)).append(" - ").append(kuGouArtist(artist))
if (album.isNotBlank()) append(' ').append(album)
}
val encoded = URLEncoder.encode(keyword, "UTF-8").replace("+", "%20")
// First the song itself, matched by length, then its lyrics by the song's hash.
val songs = get(
"https://mobileservice.kugou.com/api/v3/search/song?version=9108&plat=0&pagesize=8&showtype=0&keyword=$encoded",
)?.let { JSONObject(it).optJSONObject("data")?.optJSONArray("info") }
if (songs != null) for (i in 0 until songs.length()) {
val song = songs.getJSONObject(i)
if (seconds > 0 && abs(song.optInt("duration") - seconds) > KUGOU_DURATION_SLACK) continue
val hash = song.optString("hash").takeIf { it.isNotBlank() } ?: continue
kuGouCandidate("hash=" + enc(hash))?.let { return it }
}
// Then a plain lyrics search by name.
val byName = "keyword=$encoded" + if (seconds > 0) "&duration=${seconds * 1000}" else ""
return kuGouCandidate(byName)
}
private fun kuGouCandidate(query: String): LyricsResult? {
val body = get("https://lyrics.kugou.com/search?ver=1&man=yes&client=pc&$query") ?: return null
val candidate = JSONObject(body).optJSONArray("candidates")?.optJSONObject(0) ?: return null
val id = candidate.optLong("id").takeIf { it != 0L } ?: return null
val accessKey = candidate.optString("accesskey").takeIf { it.isNotBlank() } ?: return null
val download = get(
"https://lyrics.kugou.com/download?fmt=lrc&charset=utf8&client=pc&ver=1&id=$id&accesskey=${enc(accessKey)}",
) ?: return null
val content = JSONObject(download).optString("content").takeIf { it.isNotBlank() } ?: return null
val lrc = String(Base64.decode(content, Base64.DEFAULT), Charsets.UTF_8)
val lines = parseLrc(kuGouTrim(lrc).joinToString("\n")).filter { it.text.isNotEmpty() }
return if (lines.isEmpty()) null else LyricsResult(lines, synced = true)
}
/** Keeps the timed lines and drops the songwriter and producer credits KuGou puts at either end. */
private fun kuGouTrim(lrc: String): List<String> {
val timed = lrc.lines().filter { it.matches(KUGOU_TIMED) }
if (timed.isEmpty()) return timed
val headLimit = minOf(KUGOU_CREDIT_LINES, timed.lastIndex)
val head = (headLimit downTo 0).firstOrNull { timed[it].matches(KUGOU_CREDIT) }?.plus(1) ?: 0
val rest = timed.drop(head)
if (rest.isEmpty()) return rest
val tailLimit = minOf(KUGOU_CREDIT_LINES, rest.lastIndex)
val tail = (tailLimit downTo 0).firstOrNull { rest[rest.lastIndex - it].matches(KUGOU_CREDIT) }?.plus(1) ?: 0
return rest.dropLast(tail)
}
/** KuGou's titles leave out bracketed extras such as "(feat. …)" or "(Live)". */
private fun kuGouTitle(title: String) = listOf("\\(.*\\)", "(.*)", "「.*」", "『.*』", "<.*>", "《.*》", "〈.*〉", "<.*>")
.fold(title) { acc, pattern -> acc.replace(Regex(pattern), "") }.trim()
/** KuGou joins several artists with "、". */
private fun kuGouArtist(artist: String) = artist.replace(", ", "、").replace(" & ", "、").replace(".", "")
.replace(Regex("\\(.*\\)"), "").replace(Regex("(.*)"), "").trim()
// --- shared ---
/** `[01:23.45] words` - a line may carry several stamps when a chorus repeats. */ /** `[01:23.45] words` - a line may carry several stamps when a chorus repeats. */
private fun parseLrc(text: String): List<LyricLine> { private fun parseLrc(text: String): List<LyricLine> {
val stamp = Regex("""\[(\d+):(\d+(?:\.\d+)?)]""") val stamp = Regex("""\[(\d+):(\d+(?:\.\d+)?)]""")
@@ -129,6 +424,7 @@ object Lyrics {
connectTimeout = 5_000 connectTimeout = 5_000
readTimeout = 8_000 readTimeout = 8_000
setRequestProperty("User-Agent", "Gearslip (car media host)") setRequestProperty("User-Agent", "Gearslip (car media host)")
setRequestProperty("Accept", "application/json")
} }
return try { return try {
if (connection.responseCode != 200) null else connection.inputStream.bufferedReader().readText() if (connection.responseCode != 200) null else connection.inputStream.bufferedReader().readText()
@@ -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)
@@ -26,7 +26,7 @@ object WorkedPrompt {
private var loaded = false private var loaded = false
private val flow = MutableStateFlow<String?>(null) private val flow = MutableStateFlow<String?>(null)
/** The car's name while the ask is waiting for an answer, otherwise null. */ /** The car's name while the ask is waiting for an answer (empty when the car didn't say), otherwise null. */
fun pending(context: Context): StateFlow<String?> { fun pending(context: Context): StateFlow<String?> {
if (!loaded) { if (!loaded) {
loaded = true loaded = true
@@ -99,17 +99,14 @@ object WorkedPrompt {
private fun saved(context: Context): JSONObject? = private fun saved(context: Context): JSONObject? =
prefs(context).getString(KEY_CAR, null)?.let { runCatching { JSONObject(it) }.getOrNull() } prefs(context).getString(KEY_CAR, null)?.let { runCatching { JSONObject(it) }.getOrNull() }
/** "your 2019 Clio", "your Renault Media Nav", or "your car" when it didn't say. */ /** "2019 Clio", "Renault Media Nav", or empty when the car didn't say. */
private fun carName(car: JSONObject): String { private fun carName(car: JSONObject): String {
val model = car.optString("car").trim() val model = car.optString("car").trim()
val year = car.optString("year").trim().takeIf { it.isNotEmpty() && it != "0" } val year = car.optString("year").trim().takeIf { it.isNotEmpty() && it != "0" }
val unit = "${car.optString("make")} ${car.optString("model")}".trim() val unit = "${car.optString("make")} ${car.optString("model")}".trim()
return when { return when {
model.isNotEmpty() -> "your " + listOfNotNull(year, model).joinToString(" ") model.isNotEmpty() -> listOfNotNull(year, model).joinToString(" ")
unit.isNotEmpty() -> "your $unit" else -> unit
else -> "your car"
} }
} }
const val TITLE_START = "Gearslip worked with "
} }
@@ -1,5 +1,7 @@
package app.seb3thehacker.gearslip.ui package app.seb3thehacker.gearslip.ui
import androidx.compose.ui.res.stringResource
import app.seb3thehacker.gearslip.R
import android.content.ClipData import android.content.ClipData
import android.content.ClipboardManager import android.content.ClipboardManager
import android.content.Context import android.content.Context
@@ -11,6 +13,7 @@ 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.Send import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.Build import androidx.compose.material.icons.filled.Build
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.Favorite
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -44,6 +47,7 @@ internal object GearslipLinks {
const val MATRIX = "https://matrix.to/#/!mf0_mgBd5KqKpc9FfX_wHp1jFCcwAtPrndIn_oGIyMk?via=matrix.org" const val MATRIX = "https://matrix.to/#/!mf0_mgBd5KqKpc9FfX_wHp1jFCcwAtPrndIn_oGIyMk?via=matrix.org"
const val SOURCE = "https://github.com/twinksoftware/gearslip" const val SOURCE = "https://github.com/twinksoftware/gearslip"
const val CARS = "$SOURCE/blob/main/CARS.md" const val CARS = "$SOURCE/blob/main/CARS.md"
const val TRANSLATE = "https://hosted.weblate.org/engage/gearslip/"
/** This version's release notes on GitHub; every release is tagged v plus its version name. */ /** This version's release notes on GitHub; every release is tagged v plus its version name. */
val RELEASE_NOTES = "$SOURCE/releases/tag/v${app.seb3thehacker.gearslip.BuildConfig.VERSION_NAME}" val RELEASE_NOTES = "$SOURCE/releases/tag/v${app.seb3thehacker.gearslip.BuildConfig.VERSION_NAME}"
@@ -59,14 +63,14 @@ internal object GearslipLinks {
runCatching { runCatching {
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("monero:$MONERO")).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("monero:$MONERO")).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
}.onFailure { }.onFailure {
Toast.makeText(context, "No Monero wallet app found. Copy the address instead.", Toast.LENGTH_LONG).show() Toast.makeText(context, context.getString(R.string.about_no_wallet), Toast.LENGTH_LONG).show()
} }
} }
fun copyMonero(context: Context) { fun copyMonero(context: Context) {
context.getSystemService(ClipboardManager::class.java) context.getSystemService(ClipboardManager::class.java)
?.setPrimaryClip(ClipData.newPlainText("Monero address", MONERO)) ?.setPrimaryClip(ClipData.newPlainText("Monero address", MONERO))
Toast.makeText(context, "Monero address copied", Toast.LENGTH_SHORT).show() Toast.makeText(context, context.getString(R.string.about_monero_copied), Toast.LENGTH_SHORT).show()
} }
} }
@@ -84,7 +88,7 @@ internal fun DonateRows() {
SettingsRow( SettingsRow(
"Liberapay", "Liberapay",
Modifier.clickable { GearslipLinks.open(context, GearslipLinks.LIBERAPAY) }, Modifier.clickable { GearslipLinks.open(context, GearslipLinks.LIBERAPAY) },
subtitle = "Give weekly or monthly", subtitle = stringResource(R.string.about_give_regularly),
icon = Icons.Filled.Favorite, icon = Icons.Filled.Favorite,
trailing = arrow, trailing = arrow,
) )
@@ -92,16 +96,16 @@ internal fun DonateRows() {
SettingsRow( SettingsRow(
"Buy Me a Coffee", "Buy Me a Coffee",
Modifier.clickable { GearslipLinks.open(context, GearslipLinks.BUY_ME_A_COFFEE) }, Modifier.clickable { GearslipLinks.open(context, GearslipLinks.BUY_ME_A_COFFEE) },
subtitle = "Give once or monthly, by card. Buy Me a Coffee keeps 5%", subtitle = stringResource(R.string.about_bmac_sub),
icon = Icons.Filled.Favorite, icon = Icons.Filled.Favorite,
trailing = arrow, trailing = arrow,
) )
if (GearslipLinks.PAYMENT_LINK.isNotEmpty()) { if (GearslipLinks.PAYMENT_LINK.isNotEmpty()) {
SettingsDivider() SettingsDivider()
SettingsRow( SettingsRow(
"Give once", stringResource(R.string.about_give_once),
Modifier.clickable { GearslipLinks.open(context, GearslipLinks.PAYMENT_LINK) }, Modifier.clickable { GearslipLinks.open(context, GearslipLinks.PAYMENT_LINK) },
subtitle = "Any amount, by card", subtitle = stringResource(R.string.about_any_amount),
icon = Icons.Filled.Favorite, icon = Icons.Filled.Favorite,
trailing = arrow, trailing = arrow,
) )
@@ -112,7 +116,7 @@ internal fun DonateRows() {
SettingsRow( SettingsRow(
"Monero", "Monero",
Modifier.clickable { moneroOpen = true }, Modifier.clickable { moneroOpen = true },
subtitle = "Open your wallet or copy the address", subtitle = stringResource(R.string.about_monero_sub),
icon = Icons.Filled.Favorite, icon = Icons.Filled.Favorite,
trailing = arrow, trailing = arrow,
) )
@@ -127,10 +131,10 @@ internal fun MoneroDialog(onDismiss: () -> Unit) {
AlertDialog( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
icon = { Icon(Icons.Filled.Favorite, contentDescription = null) }, icon = { Icon(Icons.Filled.Favorite, contentDescription = null) },
title = { Text("Support with Monero") }, title = { Text(stringResource(R.string.about_monero_title)) },
text = { text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("Send any amount to this address from your Monero wallet.") Text(stringResource(R.string.about_monero_body))
SelectionContainer { SelectionContainer {
Text( Text(
GearslipLinks.MONERO, GearslipLinks.MONERO,
@@ -143,20 +147,20 @@ internal fun MoneroDialog(onDismiss: () -> Unit) {
confirmButton = { confirmButton = {
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = { GearslipLinks.openMoneroWallet(context) }, Modifier.fillMaxWidth()) { Button(onClick = { GearslipLinks.openMoneroWallet(context) }, Modifier.fillMaxWidth()) {
Text("Open in wallet") Text(stringResource(R.string.about_open_wallet))
} }
OutlinedButton(onClick = { GearslipLinks.copyMonero(context) }, Modifier.fillMaxWidth()) { OutlinedButton(onClick = { GearslipLinks.copyMonero(context) }, Modifier.fillMaxWidth()) {
Text("Copy address") Text(stringResource(R.string.about_copy_address))
} }
TextButton(onClick = onDismiss, Modifier.fillMaxWidth()) { TextButton(onClick = onDismiss, Modifier.fillMaxWidth()) {
Text("Close") Text(stringResource(R.string.close))
} }
} }
}, },
) )
} }
/** The About section's links: the chat room, the source code, and credits. */ /** The About section's links: the chat room, translations, the source code, and credits. */
@Composable @Composable
internal fun AboutLinkRows() { internal fun AboutLinkRows() {
val context = LocalContext.current val context = LocalContext.current
@@ -168,25 +172,32 @@ internal fun AboutLinkRows() {
) )
} }
SettingsRow( SettingsRow(
"Join the chat on Matrix", stringResource(R.string.about_matrix),
Modifier.clickable { GearslipLinks.open(context, GearslipLinks.MATRIX) }, Modifier.clickable { GearslipLinks.open(context, GearslipLinks.MATRIX) },
subtitle = "Talk with other drivers about cars, apps, and Gearslip", subtitle = stringResource(R.string.about_matrix_sub),
icon = Icons.AutoMirrored.Filled.Send, icon = Icons.AutoMirrored.Filled.Send,
trailing = arrow, trailing = arrow,
) )
SettingsDivider() SettingsDivider()
SettingsRow( SettingsRow(
"Source code", stringResource(R.string.about_translate),
Modifier.clickable { GearslipLinks.open(context, GearslipLinks.TRANSLATE) },
subtitle = stringResource(R.string.about_translate_sub),
icon = Icons.Filled.Edit,
trailing = arrow,
)
SettingsDivider()
SettingsRow(
stringResource(R.string.about_source),
Modifier.clickable { GearslipLinks.open(context, GearslipLinks.SOURCE) }, Modifier.clickable { GearslipLinks.open(context, GearslipLinks.SOURCE) },
subtitle = "Read or change Gearslip's code under the AGPL", subtitle = stringResource(R.string.about_source_sub),
icon = Icons.Filled.Build, icon = Icons.Filled.Build,
trailing = arrow, trailing = arrow,
) )
SettingsDivider() SettingsDivider()
SettingsRow( SettingsRow(
"Voice credits", stringResource(R.string.about_voice_credits),
subtitle = "Speech recognition by sherpa-onnx (Apache 2.0), with Kroko ASR by Banafo " + subtitle = stringResource(R.string.about_voice_credits_sub),
"(CC BY-SA 4.0), and Parakeet and Canary by NVIDIA (CC BY 4.0)",
icon = Icons.Filled.Favorite, icon = Icons.Filled.Favorite,
) )
} }
@@ -1,7 +1,16 @@
package app.seb3thehacker.gearslip.ui package app.seb3thehacker.gearslip.ui
import androidx.compose.ui.res.stringResource
import app.seb3thehacker.gearslip.R
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.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.OutlinedButton
import androidx.compose.ui.Modifier
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -24,27 +33,30 @@ fun AudioChoiceDialog(onUseBluetooth: () -> Unit, onKeepAsking: () -> Unit) {
var showCommand by remember { mutableStateOf(false) } var showCommand by remember { mutableStateOf(false) }
AlertDialog( AlertDialog(
onDismissRequest = onKeepAsking, onDismissRequest = onKeepAsking,
title = { Text("Play car audio over Bluetooth?") }, title = { Text(stringResource(R.string.audio_bt_title)) },
text = { text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text( Text(stringResource(R.string.audio_bt_body))
"Android treats music sent over USB like sharing your screen, so it asks every " +
"drive. Over Bluetooth it never asks. You can change this later in Settings.",
)
if (showCommand) { if (showCommand) {
Text( Text(
"Or keep USB and stop the question for good: plug the phone into a " + stringResource(R.string.audio_keep_usb),
"computer with USB debugging on, and run this once.",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
) )
CopyableCommand("adb shell appops set ${BuildConfig.APPLICATION_ID} PROJECT_MEDIA allow") CopyableCommand("adb shell appops set ${BuildConfig.APPLICATION_ID} PROJECT_MEDIA allow")
} else { }
TextButton(onClick = { showCommand = true }) { Text("Never ask, using a computer") } // Stacked full width: three labels this long never fit the dialog's one button row.
Column(Modifier.fillMaxWidth().padding(top = 4.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = onUseBluetooth, Modifier.fillMaxWidth()) { Text(stringResource(R.string.audio_use_bt)) }
OutlinedButton(onClick = onKeepAsking, Modifier.fillMaxWidth()) { Text(stringResource(R.string.audio_keep_asking)) }
if (!showCommand) {
TextButton(onClick = { showCommand = true }, Modifier.fillMaxWidth()) {
Text(stringResource(R.string.audio_never_ask))
}
}
} }
} }
}, },
confirmButton = { TextButton(onClick = onUseBluetooth) { Text("Use Bluetooth") } }, confirmButton = {},
dismissButton = { TextButton(onClick = onKeepAsking) { Text("Keep asking") } },
) )
} }
@@ -57,15 +69,13 @@ fun AudioChoiceDialog(onUseBluetooth: () -> Unit, onKeepAsking: () -> Unit) {
fun MicBlockedDialog(onOpenSettings: () -> Unit, onDismiss: () -> Unit) { fun MicBlockedDialog(onOpenSettings: () -> Unit, onDismiss: () -> Unit) {
AlertDialog( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
title = { Text("Car audio needs the microphone permission") }, title = { Text(stringResource(R.string.audio_mic_title)) },
text = { text = {
Text( Text(
"Android lets an app send other apps' sound to the car only if it has the " + stringResource(R.string.audio_mic_body),
"microphone permission. It's turned off for Gearslip, so music plays on the " +
"phone. Turn it on under Permissions, then Microphone.",
) )
}, },
confirmButton = { TextButton(onClick = onOpenSettings) { Text("Open app settings") } }, confirmButton = { TextButton(onClick = onOpenSettings) { Text(stringResource(R.string.audio_open_settings)) } },
dismissButton = { TextButton(onClick = onDismiss) { Text("Not now") } }, dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.worked_not_now)) } },
) )
} }
@@ -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,66 +69,28 @@ 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
// it. Only here: the flag goes with this screen, and every other screen sleeps as usual.
val view = androidx.compose.ui.platform.LocalView.current
DisposableEffect(view) {
view.keepScreenOn = true
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 ->
Box( if (projector == null) {
modifier Text("Gearslip is this device's home screen, so the car UI is already showing there.", modifier)
.aspectRatio(width.toFloat() / height) } else {
.background(Color.Black) CarFrame(projector, width, height, modifier.aspectRatio(width.toFloat() / height))
.onSizeChanged { viewSize = it.width to it.height }
.pointerInteropFilter { event ->
val (viewWidth, viewHeight) = viewSize
if (viewWidth > 0 && viewHeight > 0) {
projector.dispatchTouch(
event.actionMasked.toCarAction(),
event.actionIndex,
List(event.pointerCount) { i ->
TouchPoint(
event.getPointerId(i),
event.getX(i) * width / viewWidth,
event.getY(i) * height / viewHeight,
)
},
)
}
true
},
contentAlignment = Alignment.Center,
) {
androidx.compose.runtime.key(width, height) {
androidx.compose.ui.viewinterop.AndroidView(
factory = { viewContext ->
android.view.SurfaceView(viewContext).apply {
// The car's own size; the compositor scales it to the view.
holder.setFixedSize(width, height)
holder.addCallback(object : android.view.SurfaceHolder.Callback {
override fun surfaceCreated(holder: android.view.SurfaceHolder) =
projector.setSurface(holder.surface)
override fun surfaceChanged(holder: android.view.SurfaceHolder, format: Int, w: Int, h: Int) =
projector.setSurface(holder.surface)
override fun surfaceDestroyed(holder: android.view.SurfaceHolder) =
projector.setSurface(null)
})
}
},
modifier = Modifier.fillMaxSize(),
)
}
} }
} }
@@ -163,6 +125,80 @@ fun CarPreviewScreen(onBack: () -> Unit) {
} }
} }
/**
* 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(
modifier
.background(Color.Black)
.onSizeChanged { viewSize = it.width to it.height }
.pointerInteropFilter { event ->
val (viewWidth, viewHeight) = viewSize
if (viewWidth > 0 && viewHeight > 0) {
projector.dispatchTouch(
event.actionMasked.toCarAction(),
event.actionIndex,
List(event.pointerCount) { i ->
TouchPoint(
event.getPointerId(i),
event.getX(i) * width / viewWidth,
event.getY(i) * height / viewHeight,
)
},
)
}
true
},
contentAlignment = Alignment.Center,
) {
androidx.compose.runtime.key(width, height) {
androidx.compose.ui.viewinterop.AndroidView(
factory = { viewContext ->
android.view.SurfaceView(viewContext).apply {
// The car's own size; the compositor scales it to the view.
holder.setFixedSize(width, height)
holder.addCallback(object : android.view.SurfaceHolder.Callback {
override fun surfaceCreated(holder: android.view.SurfaceHolder) =
projector.setSurface(holder.surface)
override fun surfaceChanged(holder: android.view.SurfaceHolder, format: Int, w: Int, h: Int) =
projector.setSurface(holder.surface)
override fun surfaceDestroyed(holder: android.view.SurfaceHolder) =
projector.setSurface(null)
})
}
},
modifier = Modifier.fillMaxSize(),
)
}
}
}
/** /**
* Fills the phone screen with [content], centred and letterboxed. * Fills the phone screen with [content], centred and letterboxed.
* *
@@ -255,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 ->
@@ -1,5 +1,7 @@
package app.seb3thehacker.gearslip.ui package app.seb3thehacker.gearslip.ui
import androidx.compose.ui.res.stringResource
import app.seb3thehacker.gearslip.R
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -41,19 +43,18 @@ internal fun CarScreenSection() {
if (editingCar) { if (editingCar) {
SizeDialog( SizeDialog(
title = "Car screen size", title = stringResource(R.string.screen_car_size),
note = "Use this if the car's picture looks stretched. Enter the screen's real size, " + note = stringResource(R.string.screen_car_size_note),
"such as 1280x480. It takes effect the next time you connect.",
initial = size, initial = size,
clearLabel = "Use the car's", clearLabel = stringResource(R.string.screen_use_cars),
onDismiss = { editingCar = false }, onDismiss = { editingCar = false },
onSave = { CarSettings.setScreenSize(it); editingCar = false }, onSave = { CarSettings.setScreenSize(it); editingCar = false },
) )
} }
if (editingPreview) { if (editingPreview) {
SizeDialog( SizeDialog(
title = "Preview size", title = stringResource(R.string.screen_preview_size),
note = "The size the car preview draws at, such as 1920x720.", note = stringResource(R.string.screen_preview_note),
initial = previewCustom, initial = previewCustom,
clearLabel = null, clearLabel = null,
onDismiss = { editingPreview = false }, onDismiss = { editingPreview = false },
@@ -65,62 +66,62 @@ internal fun CarScreenSection() {
} }
SettingsSection( SettingsSection(
"Car screen", stringResource(R.string.screen_car_screen),
footer = "Gearslip uses the size the car reports unless you set one.", footer = stringResource(R.string.screen_footer),
) { ) {
SettingsRow( SettingsRow(
"Screen size", stringResource(R.string.screen_size),
Modifier.clickable { editingCar = true }, Modifier.clickable { editingCar = true },
subtitle = size?.let { "$it, set by you" } ?: "From the car", subtitle = size?.let { stringResource(R.string.screen_set_by_you, it.toString()) } ?: stringResource(R.string.screen_from_car),
) )
if (size != null) { if (size != null) {
SettingsDivider() SettingsDivider()
SettingsRow( SettingsRow(
"Crop the edges", stringResource(R.string.screen_crop),
Modifier.selectable(fit == ScreenFit.CROP, role = Role.RadioButton) { CarSettings.setScreenFit(ScreenFit.CROP) }, Modifier.selectable(fit == ScreenFit.CROP, role = Role.RadioButton) { CarSettings.setScreenFit(ScreenFit.CROP) },
subtitle = "For a car that cuts off the top and bottom, or the sides", subtitle = stringResource(R.string.screen_crop_sub),
trailing = { RadioButton(selected = fit == ScreenFit.CROP, onClick = null) }, trailing = { RadioButton(selected = fit == ScreenFit.CROP, onClick = null) },
) )
SettingsDivider() SettingsDivider()
SettingsRow( SettingsRow(
"Squash to fit", stringResource(R.string.screen_squash),
Modifier.selectable(fit == ScreenFit.SQUASH, role = Role.RadioButton) { CarSettings.setScreenFit(ScreenFit.SQUASH) }, Modifier.selectable(fit == ScreenFit.SQUASH, role = Role.RadioButton) { CarSettings.setScreenFit(ScreenFit.SQUASH) },
subtitle = "For a car that squeezes the whole picture, so circles look like ovals", subtitle = stringResource(R.string.screen_squash_sub),
trailing = { RadioButton(selected = fit == ScreenFit.SQUASH, onClick = null) }, trailing = { RadioButton(selected = fit == ScreenFit.SQUASH, onClick = null) },
) )
} }
} }
SettingsSection("Car preview size") { SettingsSection(stringResource(R.string.screen_preview_title)) {
SettingsRow( SettingsRow(
"Last car", stringResource(R.string.screen_last_car),
Modifier.selectable(preview == PreviewSize.LAST_CAR, role = Role.RadioButton) { Modifier.selectable(preview == PreviewSize.LAST_CAR, role = Role.RadioButton) {
CarSettings.setPreviewSize(PreviewSize.LAST_CAR) CarSettings.setPreviewSize(PreviewSize.LAST_CAR)
}, },
subtitle = "The size of the last car you connected to", subtitle = stringResource(R.string.screen_last_car_sub),
trailing = { RadioButton(selected = preview == PreviewSize.LAST_CAR, onClick = null) }, trailing = { RadioButton(selected = preview == PreviewSize.LAST_CAR, onClick = null) },
) )
SettingsDivider() SettingsDivider()
SettingsRow( SettingsRow(
"This screen", stringResource(R.string.screen_this_screen),
Modifier.selectable(preview == PreviewSize.FILL, role = Role.RadioButton) { Modifier.selectable(preview == PreviewSize.FILL, role = Role.RadioButton) {
CarSettings.setPreviewSize(PreviewSize.FILL) CarSettings.setPreviewSize(PreviewSize.FILL)
}, },
subtitle = "Fills this device's screen, for a head unit running Gearslip itself", subtitle = stringResource(R.string.screen_this_screen_sub),
trailing = { RadioButton(selected = preview == PreviewSize.FILL, onClick = null) }, trailing = { RadioButton(selected = preview == PreviewSize.FILL, onClick = null) },
) )
SettingsDivider() SettingsDivider()
SettingsRow( SettingsRow(
"Custom", stringResource(R.string.screen_custom),
Modifier.selectable(preview == PreviewSize.CUSTOM, role = Role.RadioButton) { Modifier.selectable(preview == PreviewSize.CUSTOM, role = Role.RadioButton) {
if (previewCustom == null) editingPreview = true else CarSettings.setPreviewSize(PreviewSize.CUSTOM) if (previewCustom == null) editingPreview = true else CarSettings.setPreviewSize(PreviewSize.CUSTOM)
}, },
subtitle = previewCustom?.toString() ?: "Enter a size", subtitle = previewCustom?.toString() ?: stringResource(R.string.screen_enter_size),
trailing = { RadioButton(selected = preview == PreviewSize.CUSTOM, onClick = null) }, trailing = { RadioButton(selected = preview == PreviewSize.CUSTOM, onClick = null) },
) )
if (preview == PreviewSize.CUSTOM) { if (preview == PreviewSize.CUSTOM) {
SettingsDivider() SettingsDivider()
SettingsRow("Change the custom size", Modifier.clickable { editingPreview = true }) SettingsRow(stringResource(R.string.screen_change_custom), Modifier.clickable { editingPreview = true })
} }
} }
} }
@@ -145,14 +146,14 @@ private fun SizeDialog(
text = { text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(note) Text(note)
OutlinedTextField(width, { width = it.filter(Char::isDigit) }, label = { Text("Width") }, singleLine = true, keyboardOptions = number) OutlinedTextField(width, { width = it.filter(Char::isDigit) }, label = { Text(stringResource(R.string.screen_width)) }, singleLine = true, keyboardOptions = number)
OutlinedTextField(height, { height = it.filter(Char::isDigit) }, label = { Text("Height") }, singleLine = true, keyboardOptions = number) OutlinedTextField(height, { height = it.filter(Char::isDigit) }, label = { Text(stringResource(R.string.screen_height)) }, singleLine = true, keyboardOptions = number)
} }
}, },
confirmButton = { TextButton(onClick = { onSave(parsed) }, enabled = parsed != null) { Text("Save") } }, confirmButton = { TextButton(onClick = { onSave(parsed) }, enabled = parsed != null) { Text(stringResource(R.string.save)) } },
dismissButton = { dismissButton = {
if (clearLabel != null) TextButton(onClick = { onSave(null) }) { Text(clearLabel) } if (clearLabel != null) TextButton(onClick = { onSave(null) }) { Text(clearLabel) }
else TextButton(onClick = onDismiss) { Text("Cancel") } else TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) }
}, },
) )
} }
@@ -1,5 +1,7 @@
package app.seb3thehacker.gearslip.ui package app.seb3thehacker.gearslip.ui
import androidx.compose.ui.res.stringResource
import app.seb3thehacker.gearslip.R
import android.net.Uri import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
@@ -80,7 +82,7 @@ fun CertSetupScreen(onDone: () -> Unit) {
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
) { ) {
Text( Text(
"Certificate Setup", stringResource(R.string.certsetup_title),
style = MaterialTheme.typography.headlineLarge, style = MaterialTheme.typography.headlineLarge,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onBackground, color = MaterialTheme.colorScheme.onBackground,
@@ -88,9 +90,9 @@ fun CertSetupScreen(onDone: () -> Unit) {
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Text( Text(
if (installed) { if (installed) {
"Cars accept only Google-issued certificates, and you have one loaded." stringResource(R.string.certsetup_loaded)
} else { } else {
"Cars accept only Google-issued certificates. Gearslip doesn't include one, so choose where yours comes from." stringResource(R.string.certsetup_choose)
}, },
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
@@ -100,14 +102,24 @@ fun CertSetupScreen(onDone: () -> Unit) {
if (installed) { if (installed) {
Text( Text(
if (manualProgress) "Certificate imported." else "Certificate installed.", if (manualProgress) stringResource(R.string.cert_imported) else stringResource(R.string.certsetup_installed),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
) )
// A downloaded certificate works in most cars, not all: say where to go when it doesn't.
if (!manualProgress) {
Text(
stringResource(R.string.certsetup_installed_hint),
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp),
)
}
Spacer(Modifier.height(20.dp)) Spacer(Modifier.height(20.dp))
Button(onClick = onDone, modifier = Modifier.fillMaxWidth().height(56.dp)) { Button(onClick = onDone, modifier = Modifier.fillMaxWidth().height(56.dp)) {
Text("Continue") Text(stringResource(R.string.certsetup_continue))
} }
Spacer(Modifier.height(40.dp)) Spacer(Modifier.height(40.dp))
return@Column return@Column
@@ -130,11 +142,10 @@ fun CertSetupScreen(onDone: () -> Unit) {
}, },
modifier = Modifier.fillMaxWidth().height(56.dp), modifier = Modifier.fillMaxWidth().height(56.dp),
) { ) {
Text("Download from aasdk") Text(stringResource(R.string.cert_download))
} }
Text( Text(
"The open-source aasdk project publishes this certificate at github.com/opencardev/aasdk. " + stringResource(R.string.certsetup_aasdk_sub),
"Most cars accept it; some from around 2020 on don't.",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -146,7 +157,7 @@ fun CertSetupScreen(onDone: () -> Unit) {
Box(Modifier.fillMaxWidth().height(56.dp), contentAlignment = Alignment.Center) { Box(Modifier.fillMaxWidth().height(56.dp), contentAlignment = Alignment.Center) {
DownloadingRow { DownloadingRow {
CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp) CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp)
Text("Downloading…", style = MaterialTheme.typography.bodyLarge) Text(stringResource(R.string.cert_downloading), style = MaterialTheme.typography.bodyLarge)
} }
} }
} }
@@ -157,7 +168,7 @@ fun CertSetupScreen(onDone: () -> Unit) {
Surface(color = MaterialTheme.colorScheme.errorContainer, shape = MaterialTheme.shapes.medium) { Surface(color = MaterialTheme.colorScheme.errorContainer, shape = MaterialTheme.shapes.medium) {
Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text( Text(
"Download failed", stringResource(R.string.certsetup_failed),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onErrorContainer, color = MaterialTheme.colorScheme.onErrorContainer,
@@ -168,7 +179,7 @@ fun CertSetupScreen(onDone: () -> Unit) {
color = MaterialTheme.colorScheme.onErrorContainer, color = MaterialTheme.colorScheme.onErrorContainer,
) )
Button(onClick = { downloadState = DownloadState.Idle }, modifier = Modifier.fillMaxWidth()) { Button(onClick = { downloadState = DownloadState.Idle }, modifier = Modifier.fillMaxWidth()) {
Text("Retry") Text(stringResource(R.string.certsetup_retry))
} }
} }
} }
@@ -180,9 +191,9 @@ fun CertSetupScreen(onDone: () -> Unit) {
OutlinedButton( OutlinedButton(
onClick = { importLauncher.launch(arrayOf("*/*")) }, onClick = { importLauncher.launch(arrayOf("*/*")) },
modifier = Modifier.fillMaxWidth().height(52.dp), modifier = Modifier.fillMaxWidth().height(52.dp),
) { Text("Import my own file") } ) { Text(stringResource(R.string.cert_import)) }
Text( Text(
"A .p12 file with its private key, from wherever you choose.", stringResource(R.string.certsetup_import_sub),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -197,7 +208,7 @@ fun CertSetupScreen(onDone: () -> Unit) {
}, },
modifier = Modifier.fillMaxWidth().height(52.dp), modifier = Modifier.fillMaxWidth().height(52.dp),
) { ) {
Text("Skip car connection setup") Text(stringResource(R.string.certsetup_skip))
} }
Spacer(Modifier.height(40.dp)) Spacer(Modifier.height(40.dp))
@@ -216,7 +227,7 @@ fun CertSetupScreen(onDone: () -> Unit) {
pendingUri = null pendingUri = null
manualProgress = true manualProgress = true
}.onFailure { }.onFailure {
reportError("Wrong password or not a valid PKCS#12 file with a private key.") reportError(context.getString(R.string.cert_import_failed))
} }
} }
}, },
@@ -224,7 +235,7 @@ fun CertSetupScreen(onDone: () -> Unit) {
} }
} }
/** A tight horizontal row for the inline "Downloading…" spinner state. */ /** A tight horizontal row for the inline stringResource(R.string.cert_downloading) spinner state. */
@Composable @Composable
private fun DownloadingRow(content: @Composable RowScope.() -> Unit) { private fun DownloadingRow(content: @Composable RowScope.() -> Unit) {
Row( Row(
@@ -248,20 +259,20 @@ private fun PasswordDialog(onDismiss: () -> Unit, onConfirm: (String, (String) -
AlertDialog( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
title = { Text("Certificate password") }, title = { Text(stringResource(R.string.cert_password_title)) },
text = { text = {
OutlinedTextField( OutlinedTextField(
value = password, value = password,
onValueChange = { password = it; error = null }, onValueChange = { password = it; error = null },
singleLine = true, singleLine = true,
label = { Text("Password") }, label = { Text(stringResource(R.string.cert_password)) },
visualTransformation = PasswordVisualTransformation(), visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
isError = error != null, isError = error != null,
supportingText = { Text(error ?: "Leave empty if the file has none.") }, supportingText = { Text(error ?: stringResource(R.string.cert_password_hint)) },
) )
}, },
confirmButton = { TextButton(onClick = { onConfirm(password) { error = it } }) { Text("Import") } }, confirmButton = { TextButton(onClick = { onConfirm(password) { error = it } }) { Text(stringResource(R.string.cert_import_button)) } },
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } },
) )
} }
@@ -1,5 +1,6 @@
package app.seb3thehacker.gearslip.ui package app.seb3thehacker.gearslip.ui
import app.seb3thehacker.gearslip.R
import android.content.Context import android.content.Context
import app.seb3thehacker.gearslip.CertProvider import app.seb3thehacker.gearslip.CertProvider
import java.text.DateFormat import java.text.DateFormat
@@ -28,20 +29,20 @@ data class CertSummary(
val identity = CertProvider.loadSupplied(context) val identity = CertProvider.loadSupplied(context)
?: return CertSummary( ?: return CertSummary(
CertProvider.Kind.SELF_SIGNED, CertProvider.Kind.SELF_SIGNED,
"None loaded (self-signed; cars reject it)", context.getString(R.string.cert_none),
null, null, null, null, null, null,
) )
val cert = identity.certificate val cert = identity.certificate
val name = friendlyName(cert.subjectX500Principal.name) val name = friendlyName(cert.subjectX500Principal.name)
val prefix = when (identity.kind) { val headline = when (identity.kind) {
CertProvider.Kind.IMPORTED -> "Imported" CertProvider.Kind.IMPORTED -> R.string.cert_kind_imported
CertProvider.Kind.DOWNLOADED -> "Downloaded" CertProvider.Kind.DOWNLOADED -> R.string.cert_kind_downloaded
CertProvider.Kind.ADB_STAGED -> "Staged over adb" CertProvider.Kind.ADB_STAGED -> R.string.cert_kind_adb
CertProvider.Kind.SELF_SIGNED -> "Self-signed" CertProvider.Kind.SELF_SIGNED -> R.string.cert_kind_self
} }
return CertSummary( return CertSummary(
identity.kind, identity.kind,
"$prefix: $name", context.getString(headline, name),
cert.subjectX500Principal.name, cert.subjectX500Principal.name,
cert.issuerX500Principal.name, cert.issuerX500Principal.name,
DateFormat.getDateInstance(DateFormat.MEDIUM).format(Date(cert.notAfter.time)), DateFormat.getDateInstance(DateFormat.MEDIUM).format(Date(cert.notAfter.time)),
@@ -1,5 +1,7 @@
package app.seb3thehacker.gearslip.ui package app.seb3thehacker.gearslip.ui
import androidx.compose.ui.res.stringResource
import app.seb3thehacker.gearslip.R
import android.widget.Toast import android.widget.Toast
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -29,7 +31,7 @@ fun CopyableCommand(command: String) {
shape = MaterialTheme.shapes.small, shape = MaterialTheme.shapes.small,
onClick = { onClick = {
clipboard.setText(AnnotatedString(command)) clipboard.setText(AnnotatedString(command))
Toast.makeText(context, "Command copied", Toast.LENGTH_SHORT).show() Toast.makeText(context, context.getString(R.string.command_copied), Toast.LENGTH_SHORT).show()
}, },
modifier = Modifier.fillMaxWidth().padding(top = 4.dp), modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
) { ) {
@@ -41,7 +43,7 @@ fun CopyableCommand(command: String) {
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
) )
Text( Text(
"Tap to copy", stringResource(R.string.command_tap_copy),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
) )
@@ -1,5 +1,7 @@
package app.seb3thehacker.gearslip.ui package app.seb3thehacker.gearslip.ui
import androidx.compose.ui.res.stringResource
import app.seb3thehacker.gearslip.R
import android.content.Context import android.content.Context
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -100,31 +102,30 @@ internal fun DonateDialog(live: Boolean) {
AlertDialog( AlertDialog(
onDismissRequest = { DonatePrompt.answer(context, donated = false) }, onDismissRequest = { DonatePrompt.answer(context, donated = false) },
icon = { Icon(Icons.Filled.Favorite, contentDescription = null) }, icon = { Icon(Icons.Filled.Favorite, contentDescription = null) },
title = { Text("Glad that drive worked") }, title = { Text(stringResource(R.string.donate_title)) },
text = { text = {
Text( Text(
"Gearslip is free, with no ads and no tracking. If it gets you on the road, your " + stringResource(R.string.donate_body),
"support keeps it going.",
) )
}, },
confirmButton = { confirmButton = {
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = { give(GearslipLinks.LIBERAPAY) }, Modifier.fillMaxWidth()) { Button(onClick = { give(GearslipLinks.LIBERAPAY) }, Modifier.fillMaxWidth()) {
Text("Support on Liberapay") Text(stringResource(R.string.donate_liberapay))
} }
OutlinedButton(onClick = { give(GearslipLinks.BUY_ME_A_COFFEE) }, Modifier.fillMaxWidth()) { OutlinedButton(onClick = { give(GearslipLinks.BUY_ME_A_COFFEE) }, Modifier.fillMaxWidth()) {
Text("Buy me a coffee") Text(stringResource(R.string.donate_bmac))
} }
if (GearslipLinks.PAYMENT_LINK.isNotEmpty()) { if (GearslipLinks.PAYMENT_LINK.isNotEmpty()) {
OutlinedButton(onClick = { give(GearslipLinks.PAYMENT_LINK) }, Modifier.fillMaxWidth()) { OutlinedButton(onClick = { give(GearslipLinks.PAYMENT_LINK) }, Modifier.fillMaxWidth()) {
Text("Give once by card") Text(stringResource(R.string.donate_card))
} }
} }
OutlinedButton(onClick = { monero = true }, Modifier.fillMaxWidth()) { OutlinedButton(onClick = { monero = true }, Modifier.fillMaxWidth()) {
Text("Monero") Text("Monero")
} }
TextButton(onClick = { DonatePrompt.answer(context, donated = false) }, Modifier.fillMaxWidth()) { TextButton(onClick = { DonatePrompt.answer(context, donated = false) }, Modifier.fillMaxWidth()) {
Text("Not now") Text(stringResource(R.string.worked_not_now))
} }
} }
}, },
Loaded 100 of 148 files, more files were not shown because too many files have changed in this diff. Show more