Author SHA1 Message Date
Seb3thehacker 3d3e7669ac Apps can say they work with Gearslip, and each recommended app gets its own page
An app adds one meta-data line to its manifest and Gearslip marks it Works with Gearslip in the
Recommended apps list. Tapping an app opens its page, with an Open or Get button. A README badge
for app developers is in docs/badge, documented in BUILDING_APPS.md.
2026-11-08 01:13:18 -08:00
Seb3thehacker e72e99d699 Place lists get a map behind them, drawn by Gearslip as Android Auto draws one
RF Sentinel and other point-of-interest apps leave this map to the host. It uses MapLibre with
OpenFreeMap's tiles, which need no key or account, with a numbered pin for each place.
2026-11-06 15:58:13 -08:00
Seb3thehacker 5ea62b4bd6 Any installed app can be added to the car as a music app or a messenger
Music plays through Gearslip's player from the app's playback session, pressing its Play key when
it isn't running; messages show from its notifications. Added apps can be removed from the Apps card.
2026-11-06 15:38:11 -08:00
Seb3thehacker 9e1295d0de Browser videos start without waiting for a second tap, and video streams skip the ad lists
The blocker also logs what it cost each page, to tell whether it slows anything down.
2026-11-06 15:38:11 -08:00
Seb3thehacker 38a02974d5 Known-broken apps show with a red X and start unticked instead of being hidden 2026-11-06 15:38:11 -08:00
Seb3thehacker d857670c9f The browser's start page sits centred and narrower, and its remove buttons aren't cut off 2026-11-06 14:31:54 -08:00
Seb3thehacker 3cf41d3fc2 The nav bar's mic button can be hidden; the car's voice key still works 2026-11-06 14:31:54 -08:00
Seb3thehacker 6822abba90 Back from the car preview returns to Home, where it was opened 2026-11-06 14:31:54 -08:00
Seb3thehacker 43f9259803 The car browser gets a start page with bookmark tiles, a search engine choice, and ad blocking
Bookmarks are added from the star in the car, from the phone's settings, or by sharing a page to
Gearslip from any browser. Ads and trackers are blocked with EasyList and EasyPrivacy, or any list
added by its address, refreshed weekly on Wi-Fi, and YouTube's video ads are skipped.
2026-11-06 13:55:19 -08:00
Seb3thehacker f139ac48c4 Car display density takes any value from 60 to 480 DPI, typed in under Custom 2026-11-06 13:30:29 -08:00
Seb3thehacker bb7f2b93ea The car screen follows the language picked for Gearslip, not only the phone's 2026-11-06 13:15:07 -08:00
Seb3thehacker af4e979ab0 Screen sharing waits 30 seconds for the car to come back, and the address card shows once per car again
A wireless reconnect takes longer than 3 seconds, which had the driver asked to share the screen
again mid-drive. Cars that wait for a tap on their icon no longer get the address card every drive.
2026-11-06 13:12:20 -08:00
Seb3thehacker 9938efb706 The version now comes from the git tag, so a build can't disagree with its release
Builds after a tag name themselves after it plus the commits since, and the version code is
worked out from the numbers instead of set by hand.
2026-11-06 12:04:28 -08:00
Seb3thehacker 6e6a77d554 Waze's Go now counts down and starts the drive on its own, like a single Start does 2026-11-06 12:04:28 -08:00
Seb3thehacker bc22691f81 Map apps keep their speedometer clear of the nav bar and the map buttons 2026-11-06 12:04:28 -08:00
Seb3thehacker 2d3de20682 Faded map buttons press on the first tap instead of only waking up 2026-11-06 12:04:28 -08:00
Seb3thehacker 6d32fe2bd3 App screens drop the back button when it would only lead to Home or the app grid, which the nav bar already does 2026-11-06 12:04:28 -08:00
Seb3thehacker be92ff0b3d Car apps opened from the launcher show no back arrow on their first screen, where the close button already is 2026-11-06 12:04:28 -08:00
Seb3thehacker dd6dd742b5 A countdown button's label stays readable before the fill reaches it, like Vela's Start 2026-11-06 12:04:28 -08:00
Seb3thehacker 5d0a924c6c The voice prompt sits on a darker backdrop and suggests what to say until you start talking 2026-11-06 12:04:28 -08:00
Seb3thehacker 542dd6751f Today's weather card shows the next few hours beside the weather now 2026-11-06 12:04:28 -08:00
Seb3thehacker 59a2d38480 Every shipped language has every string again, Romanian included, for Weblate to review 2026-11-06 12:04:28 -08:00
Seb3thehacker 2a692cda84 Danish and Estonian wait on Weblate until they're mostly translated, so the app never comes up half in English 2026-11-06 12:04:28 -08:00
Seb3thehacker cb1e6cd19d Phone in the car opens on recent calls, with missed calls marked, like Android Auto 2026-11-06 12:04:28 -08:00
Seb3thehacker 564bf005c7 Notification access asks calmly in the car and points to the phone for when you're parked 2026-11-06 12:04:28 -08:00
Seb3thehacker 62facda5d9 Media apps keep their own name on the player, NewPipe and TIDAL included 2026-11-06 12:04:28 -08:00
Seb3thehacker afd05ad8c0 Car settings match the phone's: voice settings under Sound and voice, switches for on and off, screen fixes together under Screen, and one lyrics setup for everyone 2026-11-06 12:04:28 -08:00
Seb3thehacker ef5365b9ed Back in the car preview steps back in the car screens instead of closing the preview 2026-11-06 12:04:28 -08:00
Seb3thehacker cec98fcde6 Opening a media app that can't connect no longer drops the music you were listening to 2026-11-06 12:04:28 -08:00
Seb3thehacker 6ec0325f7d Turn arrows change with each step again for map apps that draw their own 2026-11-06 12:04:28 -08:00
Seb3thehacker 7719a5fff2 Merge pull request #88 from davideresigotti/honda-civic-capture-stop
Stop the audio capture and screen sharing when the car leaves
2026-11-06 12:03:12 -08:00
Seb3thehacker 2934ce2684 Merge pull request #87 from davideresigotti/honda-civic-audio-chunks
Honda Civic: send bigger audio chunks to a car that acks slowly
2026-11-06 12:02:53 -08:00
Seb3thehacker f0871eb4b1 Merge pull request #86 from davideresigotti/honda-civic-screen
Honda Civic: tell the car which phone this is, so it gives up its screen
2026-11-06 12:02:25 -08:00
Davide-Resigotti 18a655e990 Screen sharing stops when the car leaves
Nothing stopped the audio capture once the drive was over: CarAudio.release() only ran when
the activity went away, and through a wireless dongle it stays open, so the phone kept sharing
its screen and stayed muted (issue #43). A wireless session that drops also never closed the
music channel, since nothing calls stop() until the next session. run() now closes it, and
when the music channel closes the capture stops unless a car comes back within 3 seconds.
2026-11-06 14:09:20 +01:00
Davide-Resigotti 617b91ef23 Music reaches a car that acks slowly in chunks as long as its gap
The 2020 Civic (issue #43) acks one audio chunk every 45 to 85 ms whatever it holds, so the
21 ms chunks Gearslip sends reached it at a quarter to half of real time: a second of music,
then silence. While the window is full, AudioLink now measures the car's gap between acks, and
when it's well over what a chunk holds, gathers the capture into chunks that last the gap,
rounded up to a power of two in frames. The Civic gets 4096 frames. A car that keeps up never
fills the window and gets the capture as it comes.
2026-11-06 14:08:39 +01:00
Davide-Resigotti 73ffa7925e A car that keeps its screen until it knows the phone hears its Bluetooth address
The 2020 Civic (issue #43) answers every request for its screen with its own, and waits to
hear which phone is connected over the car's Bluetooth channel. Gearslip never told it:
CarBluetooth was only set up once the car screen drew, so on a car that never shows it the
typed address never left the phone. It's now set up when Gearslip starts.

Once the car hears the address it sends Play while still on its own screen, and reaching for
the media app before the car UI exists ended the session; such keys are now logged and ignored.
The second request for the screen now runs whether or not the media channel is open, and a car
that keeps its screen gets the Bluetooth channel opened, and pairing started, as a real phone
does. When the car answers there, it is asked for its screen again.
2026-11-06 14:08:06 +01:00
Seb3thehacker c389c1c586 One navigation card: turn, lanes, a clearer next turn and the trip, with the app's own stop button when it has one 2026-11-05 16:44:22 -08:00
Seb3thehacker 5d1d2337f0 Lane guidance draws the road ahead, with the lanes to use filled in and said in words 2026-11-05 16:21:36 -08:00
Seb3thehacker 163e206715 The cockpit shows the navigation app's own map beside the turn card, for apps with a cluster session 2026-11-05 14:46:50 -08:00
Seb3thehacker 2a14c780fa Bug and car reports ask for the Gearslip version 2026-11-05 14:42:30 -08:00
Seb3thehacker 99407dfcb2 The cockpit turn card stays clear of the car's own dials and bars 2026-11-05 14:09:42 -08:00
Seb3thehacker 31d8232978 Stable updates install by themselves on Wi-Fi when no car is connected 2026-11-05 12:56:39 -08:00
Seb3thehacker 9d6d18bc47 Help says to connect Bluetooth before the cable when car music won't connect 2026-11-05 12:45:27 -08:00
Seb3thehacker 20cab97a55 Gearslip installs on 32-bit ARM and x86_64 phones too 2026-11-05 12:41:56 -08:00
Seb3thehacker e8025113e9 Video starts over when the car changes its screen layout mid-stream 2026-11-05 12:23:13 -08:00
Seb3thehacker 585b50ac72 Cars that turn Bluetooth music off get it through Gearslip, and Spotify's silence is explained
A Skoda, a VW, an Audi and a Bolt all dropped the phone's Bluetooth audio once Android Auto was
up. If Bluetooth audio to the car was connected when it showed Gearslip and drops after, that
car's music goes through Gearslip from then on, remembered per car, and the car screen says so.
Picking Bluetooth by hand clears what was learned. When capture stays silent while Spotify
plays, the car screen says Spotify doesn't let its sound be passed on.
2026-11-05 03:08:20 -08:00
Seb3thehacker 3e2c087483 The cluster screen goes back to the car when there's no route
Gearslip took an Audi's virtual cockpit and showed black there with nothing to navigate,
instead of the car's own map (#56). It now asks for the cluster when a route starts and hands
it back once there has been none for 10 seconds.
2026-11-05 02:55:26 -08:00
Seb3thehacker a186b0c7d0 Wireless connects again when the car asks again before it has spoken
A Bolt opened the TCP connection but never sent its first message, then sent a new
StartRequest over Bluetooth. Gearslip kept waiting on the dead connection for 88 seconds. Now a
repeat StartRequest while the session still waits for the car opens a fresh connection. A
session already under way is left alone.
2026-11-05 02:43:52 -08:00
Seb3thehacker e9aa6debb6 A paired car that doesn't know the phone's address gets it asked for on the phone
The Bolt in #18 stayed connected only once Gearslip passed it the phone's Bluetooth address,
which was hidden in Settings. After a paired car connects without it, Home asks once per car
and opens About phone. Coming back, Gearslip offers the address it finds on the clipboard. The
car screen says once to finish a step on the phone when parked.
2026-11-04 23:53:06 -08:00
Seb3thehacker 12baaca79a Settings and setup link to the privacy policy
The usage notes card, which setup and Settings share, ends with it, and Settings' About links
list it under the source code.
2026-11-04 23:21:43 -08:00
Seb3thehacker 8693e35873 README: the badge sits under the screenshot 2026-11-04 23:15:23 -08:00
Seb3thehacker bd189fea38 Weblate's own pull requests pass the translations check
The check fails any pull request that edits a translation, which included Weblate's when it
opens one instead of pushing.
2026-11-04 23:14:37 -08:00
Seb3thehacker 6c9ff6edd2 README leads with the car screen and a table of what works
The screenshot of Vela navigating comes first, then one warning and a short table. Install
steps, the GrapheneOS note and the download check sit together, and the longer notes move
lower.
2026-11-04 23:00:38 -08:00
Seb3thehacker 554bb4922f The knob zooms and pans a full-screen map, and starts on the last app in the grid
As in Android Auto, turning zooms the map, nudging pans it, and pressing brings up its buttons
for the knob to move between; Back puts them away. A card over the map with buttons of its own
keeps the knob. On the Apps screen the first knob move lands on the app opened last. Issue #74:
a Mazda 3 driver had to turn through every button to get anywhere.
2026-11-04 22:59:22 -08:00
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
Seb3thehacker f2344169af 0.3.12-alpha: version and release notes 2026-10-24 19:01:29 -07:00
Seb3thehacker dc287a7fa2 release announcement removed 2026-10-24 18:59:30 -07:00
Seb3thehacker 2fcd586e67 README: new screenshots of the player, messages, the assistant and the dashboard 2026-10-24 17:51:19 -07:00
Seb3thehacker 184e4b70c8 README: new launcher screenshot 2026-10-24 17:09:28 -07:00
Seb3thehacker 30b185e86a README: caption the route options screenshot 2026-10-24 16:47:56 -07:00
Seb3thehacker c888d41d51 media: opening another media app keeps the player on the one still playing
Opening AntennaPod while Metrolist played switched Gearslip's player to
AntennaPod, so the nav bar lost the music and its buttons drove the wrong app.
Leaving an app where nothing started goes back to the one that kept playing.
2026-10-24 16:47:45 -07:00
Seb3thehacker b0ffcf2b54 README: new screenshots of navigation, route options, the player and the browser 2026-10-24 16:36:23 -07:00
Seb3thehacker feab4b4e96 debug: a switch to hide the car screen's DEBUG BUILD badge, for screenshots 2026-10-24 16:36:23 -07:00
Seb3thehacker 88e871c65e Vela works: a green check in the launcher and on the README's list 2026-10-24 16:25:58 -07:00
Seb3thehacker dc7018f394 video: adaptive bitrate is an experimental feature, off until it's been tried in a car 2026-10-24 16:25:58 -07:00
Seb3thehacker e920093fa8 README: wireless that connects by itself, calls from the nav bar, the new navigation and look, settings on the phone 2026-10-24 16:18:11 -07:00
Seb3thehacker b71905c8c6 setup, settings: USB audio's prompt is Android asking to share the screen 2026-10-24 16:18:11 -07:00
Seb3thehacker 0a5acc012e setup: end on the app list, the button picker, then usage notes; intro keeps what's new
The intro drops directions, app picks and the button picker. The setup guide's
long pages fade out with a scroll hint, and the app list's names read on dark.
2026-10-24 16:12:51 -07:00
Seb3thehacker 8d673d855b intro: what's new in 0.3.12, and setup always ends on recommended apps
A fresh look, directions at a glance, connecting by itself, better calls and
settings up front, then app picks with an Other cool apps row and the button
picker. The intro is named after its release, so a patch can carry one.
2026-10-24 15:49:41 -07:00
Seb3thehacker 6751951308 wireless: ignore a head unit's repeated Wi-Fi details instead of rejoining
ZLink head units send InfoResponse again once the stream is up. Rejoining
released the network carrying the stream, aborting it a second in, and left the
unit stuck waiting for a version request until ZLink was force closed. A session
ending mid-teardown no longer crashes the app either.
2026-10-24 15:20:23 -07:00
Seb3thehacker 0f2582f638 phone: each car settings group opens its own screen, under a bigger Car heading 2026-10-24 14:49:54 -07:00
Seb3thehacker bb07b95a9f video: adaptive bitrate when the car or its Wi-Fi falls behind
A quarter comes off when the frame rate sags while the encoder waits on acks;
two easy windows put a tenth back. If a cut doesn't help, the car's decoder is
the limit, so the cut is undone and adapting stops for that drive.
2026-10-24 14:46:59 -07:00
Seb3thehacker 9796320b58 phone: the car's settings on the home screen, in groups that open in place
The same settings as the car's Settings app, so a change in either shows in both.
2026-10-24 14:37:00 -07:00
Seb3thehacker 3cea17d178 host: move to Car App Library 1.8
Apps built on 1.8 can now hand Gearslip their newer template pieces (chips,
progress bars, timers); drawing them comes next. Vela, Organic Maps and RF
Sentinel connect as before.
2026-10-24 14:31:18 -07:00
Seb3thehacker 795963f178 car: split the nav bar, template rows and media browsing into their own files 2026-10-24 14:26:12 -07:00
Seb3thehacker ca33b5d01c launcher: decide each app's tiles in one place 2026-10-24 14:24:28 -07:00
Seb3thehacker aaa8babd09 phone: the same Material You tint as the car screen 2026-10-24 14:24:28 -07:00
Seb3thehacker 636e40d88a car settings: grouped, with each setting's name beside its choices 2026-10-24 14:24:28 -07:00
Seb3thehacker 1481bd9c52 browser: page title, forward, reload, start page, and an address bar 2026-10-24 14:24:28 -07:00
Seb3thehacker b57bfb5324 phone: ask for call permissions on the phone from the car's Phone screen 2026-10-24 14:24:28 -07:00
Seb3thehacker e2e0d3f8bb media: a note or folder where an app sends no artwork, and a hint when nothing is playing 2026-10-24 14:24:28 -07:00
Seb3thehacker d90c39f50a car: each app screen uses its own connection, with a plain title bar and a close button
Back and the header icon in an app opened from the launcher went to the map app.
The bar above it showed the template class and draw state. Map buttons rest
faintly instead of vanishing, and place cards share the navigation width.
2026-10-24 14:24:28 -07:00
Seb3thehacker 8a6c358257 build: copy the APK to build/ instead of moving it, so incremental builds come out whole 2026-10-24 14:24:28 -07:00
Seb3thehacker de01448f1b car: the card column only moves the map's center, so Vela's speedometer keeps its size 2026-10-24 14:14:05 -07:00
Seb3thehacker f414a3c2c8 launcher: RF Sentinel keeps its car app tile even though it also posts car notifications 2026-10-24 14:14:04 -07:00
Seb3thehacker ea45bf994b car: setting to put the home screen's player left of the map 2026-10-24 13:53:05 -07:00
Seb3thehacker f8aa0a259a car: navigation cards down the left with a bigger turn card, map on the right
The turn card shows the arrow, distance, instruction, lanes and the next turn;
the trip card stacks time left, arrival and distance. The app centers its map
right of the cards.
2026-10-24 13:53:05 -07:00
Seb3thehacker 0cf28b875c car: tint the car UI with the phone's Material You color
Cards over the map use the nav bar's color instead of near-black.
2026-10-24 13:53:05 -07:00
Seb3thehacker 1c4b472ce0 car: right turn arrows and route times in map apps
Bitmap icons all shared one cache key, so Vela's turn arrows showed whatever
bitmap loaded first (a fuel pin). Route options now show the time and distance
apps send as spans instead of a lone dot.
2026-10-24 13:53:05 -07:00
Seb3thehacker e54fb08fc9 launcher: a POI or other non-media car app gets its own tile, not a Browse tile under Music
RF Sentinel ships a POI car screen plus a media service for its sound, and only
showed up as a music app.
2026-10-24 13:32:23 -07:00
Seb3thehacker 0afe857420 phone: let the screen sleep while Gearslip is open
The car screen is its own display, so the phone's screen doesn't need to stay on, and it no
longer stays on with no car connected.
2026-10-24 12:10:49 -07:00
Seb3thehacker 4b627bffdd 0.3.02-alpha: notes cover better calls, the Home row, and call audio over Bluetooth 2026-10-24 00:29:39 -07:00
Seb3thehacker fed652f9d3 settings: What's new opens this version's release notes on GitHub 2026-10-24 00:21:24 -07:00
Seb3thehacker ef006dc695 home: Help, Preview and Logs in one row at the bottom, and What's new tops Settings
Small labelled buttons pinned at the bottom of Home, so cards above can't push them out of sight.
Help and tools leaves Settings, Connection help (the same page as Help) goes, What's new moves to
the top of Settings, the intro replay goes, and the debug build's test message gets its own
section there.
2026-10-24 00:17:12 -07:00
Seb3thehacker 55c25fe967 build: shrink debug builds with R8 too, so they behave like what ships 2026-10-23 23:44:19 -07:00
Seb3thehacker 46302b7d60 0.3.02-alpha: wireless connects by itself, calls from the car, and fixes for #35 and #36 2026-10-23 23:41:08 -07:00
Seb3thehacker 2c1fad6f8c wireless: auto-connect after the car ended the last session
A session the car ended left Gearslip thinking one was still running, so the next time the car
came back nothing happened. Only a live session counts now, and each reason not to connect is
logged.
2026-10-23 23:16:11 -07:00
Seb3thehacker 75d0df19c3 wireless: count a car as asked about only once the driver answers Android's dialog
A dialog that never showed, or was swiped away, used to stop Gearslip from ever asking again.
2026-10-23 23:08:09 -07:00
Seb3thehacker 456dda897e car audio: send Start once, even when the car answers the focus request while it's starting
Starting the stream at once and the car's focus answer could both get through, so the car got
Start twice and two AAC encoders ran. A 2014 aftermarket head unit dropped the connection on the
first audio. Directions now also log why a turn went unspoken.
2026-10-23 13:21:33 -07:00
Seb3thehacker 405cecf2e9 car apps: say an app won't start when it never answers, instead of showing Starting for ever
Apps on Car App Library 1.9 and later (Yandex Maps and 2GIS among them) don't reply to a host
that isn't Google's, so a map app on Home sat on its loading message. After 10 seconds without
an answer it now counts as refused, and Home offers another app.
2026-10-23 13:01:42 -07:00
Seb3thehacker 8fbd1701b1 wireless: connect by itself when a car Gearslip has connected to before comes up over Bluetooth 2026-10-22 03:24:43 -07:00
Seb3thehacker 6945f6755e calls: end and mute from the nav bar, with the call's name and time in place of the top banner 2026-10-22 03:10:34 -07:00
Seb3thehacker a26605c0ab car preview: type with the phone's keyboard instead of drawing the car's 2026-10-22 03:10:34 -07:00
Seb3thehacker 068288a0e7 calls: place calls through Telecom, since the call screen can't open on the car's display 2026-10-22 02:59:50 -07:00
Seb3thehacker dc61607028 directions: ask the car for its speakers before each turn when the music isn't ours 2026-10-21 12:44:46 -07:00
Seb3thehacker ac8488200d keep homemade head units without a certificate working, and read wheel buttons and the knob as main does 2026-10-21 12:30:38 -07:00
Seb3thehacker c7077f2a57 version: answer 1.7 to head units offering 4.x and 6.x too, as they accept it 2026-10-21 12:30:38 -07:00
Iscle 6efdad5e53 Validate input and correct navigation and sensor services 2026-10-21 12:30:38 -07:00
Iscle c04c35b5cd Fix protocol framing, TLS, and connection negotiation 2026-10-21 12:30:03 -07:00
Seb3thehacker 75c11a92d9 browser: full screen, for the page or a video in it
A button in the header hides it and the nav bar; a page's own full screen,
such as a video's, fills the browser too. A swipe in from the top or bottom
edge brings the nav bar back for a few seconds, with a button to leave, and
Back leaves full screen first.

Other screens and pop-ups now sit in a layer over Home instead of inside
its area, so full screen grows over the nav bar's row while the map's
surface underneath keeps its size.
2026-10-19 23:56:06 -07:00
Seb3thehacker 99ad0d03d7 car settings: build rows as they scroll into view
Opening Settings built every row of buttons at once, which took a
noticeable moment. Only the ones on screen are built now.
2026-10-19 23:41:38 -07:00
Seb3thehacker e42cb767c9 stats: say who signed the build, so forks' notes can be kept apart 2026-10-19 23:41:38 -07:00
Seb3thehacker 19d3afb153 preview: keep the fullscreen close button off the car screen
With the preview filling the phone, the button sat on the car's nav bar and
swallowed taps there. It now goes only in the black beside the frame; with
no room, the back gesture leaves.
2026-10-19 23:32:38 -07:00
Seb3thehacker 32bef88a75 preview: draw the car screen straight into a SurfaceView
Each frame used to be copied into two new full-size bitmaps on the main
thread, so the bigger the preview, the further taps lagged. The display now
draws into the preview's own surface, the way the car's encoder gets it,
and turning the phone hands the surface over without restarting the car UI.
2026-10-19 23:15:53 -07:00
Seb3thehacker 18571622d8 screen size: fix a car that reports the wrong size, and size the preview
Some head units report a size their screen isn't, then crop or squash the
picture onto it. Settings, on the phone and the car, now take the screen's
real size and how the car fits it. Crop keeps the UI and taps inside the
part the car shows, like a car's own margins. Squash draws for the real
shape and stretches it over the frame on the GPU, map apps included, so
the car's squash puts it back.

The car preview can also draw at the last car's size, this device's whole
screen, or a size typed in, for head units running Gearslip on their own.
2026-10-19 22:19:18 -07:00
Seb3thehacker 86a1f968ec logs: share a log from an earlier launch
Opening Gearslip after a crash starts a new log, so the one that saw the
crash could only be pulled with a computer. Logs now has an Earlier button
listing past launches, newest first, marked when they crashed; tapping one
shares it. The newest 30 are kept.
2026-10-19 22:15:21 -07:00
Seb3thehacker 325e115752 nav bar: can hide while navigating, or until the screen is tapped
Set in car Settings. Off "Always show", the bar floats over the map, so
hiding it never resizes the map app's surface; the app is told the bar
covers the bottom while it shows. The arrival card no longer counts as
covering the map, so Waze's speedometer sits level with it.
2026-10-16 13:51:44 -07:00
Seb3thehacker 08ce09ad61 home: the player sits flush against the map, no gap between them 2026-10-16 13:45:14 -07:00
Seb3thehacker d78585fda1 navigation: directions in short bars, measured so apps keep their map clear of them
OsmAnd centred its arrow across the whole map because we only told it about
the arrival time, and Waze's speedometer sat under the map buttons. The turn
is now one short bar across the top and the arrival time one line along the
bottom; both bars and the button column are measured where they land and
left out of the area the app is told it can see.
2026-10-16 13:45:14 -07:00
Seb3thehacker 4e71689f8e audio: with music on Bluetooth, leave the car's media channel closed so the car keeps its Bluetooth source 2026-10-15 12:38:06 -07:00
Seb3thehacker e8f51f4947 0.3.01-alpha: wireless around VPNs and early Wi-Fi details, re-signed apps lose the red X 2026-10-15 12:38:06 -07:00
Seb3thehacker 2281347728 links: the repo now lives at twinksoftware/gearslip 2026-10-15 12:38:06 -07:00
Seb3thehacker 6d08fbd871 license: Gearslip now belongs to Twink Software LLC 2026-10-15 12:38:06 -07:00
Seb3thehacker 1322cbaf39 readme: package name and certificate hash for AppVerifier 2026-10-15 08:04:28 -07:00
Seb3thehacker db6f1b8e82 launcher: a patched Waze, signed with someone else's key, loses the red X and can start as the map app 2026-10-15 07:50:34 -07:00
Seb3thehacker 0177975bc8 wireless: wait for the address when the Wi-Fi details come first, and work around or name a VPN that blocks the car 2026-10-15 07:25:59 -07:00
Seb3thehacker 5f30e7b014 readme: an eighth screenshot, coffee nearby in Vela 2026-10-15 07:21:47 -07:00
Seb3thehacker c42406f353 support: Liberapay first again, then Buy Me a Coffee, Monero last 2026-10-14 23:41:34 -07:00
Seb3thehacker e8d025df47 support: say Buy Me a Coffee keeps 5% 2026-10-14 23:39:05 -07:00
Seb3thehacker c6d8d8e787 support: Buy Me a Coffee first in Settings, the popup, the thank-you page and the repo's Sponsor button; Monero last 2026-10-14 23:36:30 -07:00
Seb3thehacker 77b84212db readme: new screenshots for 0.3, with the assistant, a route in the bar, and the song title 2026-10-14 23:34:35 -07:00
Seb3thehacker b12e9651c1 wireless: when Android won't ask for the permissions, a button to enable Nearby devices in the app's settings 2026-10-14 22:06:24 -07:00
Seb3thehacker a5c46967b7 voice: drop the unused archive download and commons-compress with it, which CodeQL flagged 2026-10-14 21:11:40 -07:00
217 changed files with 167696 additions and 4258 deletions

No files matched your search

+1 -1
View File
@@ -10,6 +10,6 @@ liberapay: Seb3thehacker
issuehunt: # Replace with a single IssueHunt username issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username polar: # Replace with a single Polar username
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username buy_me_a_coffee: seb3thehacker
thanks_dev: # Replace with a single thanks.dev username thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
+10 -1
View File
@@ -5,7 +5,7 @@ body:
- type: markdown - type: markdown
attributes: attributes:
value: | value: |
Thanks for reporting. Check the [open issues](https://github.com/Seb3thehacker/gearslip/issues) first, in case someone has already reported it. Thanks for reporting. Check the [open issues](https://github.com/twinksoftware/gearslip/issues) first, in case someone has already reported it.
- type: input - type: input
id: car id: car
@@ -16,6 +16,15 @@ body:
validations: validations:
required: true required: true
- type: input
id: version
attributes:
label: Gearslip version
description: Shown under the Gearslip name on the app's home screen.
placeholder: v0.3.40-alpha
validations:
required: true
- type: dropdown - type: dropdown
id: head-unit id: head-unit
attributes: attributes:
+9
View File
@@ -16,6 +16,15 @@ body:
validations: validations:
required: true required: true
- type: input
id: version
attributes:
label: Gearslip version
description: Shown under the Gearslip name on the app's home screen.
placeholder: v0.3.40-alpha
validations:
required: true
- type: dropdown - type: dropdown
id: head-unit id: head-unit
attributes: attributes:
+8 -21
View File
@@ -1,26 +1,13 @@
Gearslip 0.3 goes wireless, takes voice commands, and works with the car's rotary knob. 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.
- **Wireless Android Auto.** Pair the phone with the car once, and leave the cable at home.
- **Voice control, on the phone.** Say "navigate to a gas station", "play" a song or artist, or open an app by name. Gearslip turns speech into text itself, so it needs no Google account. "Navigate to" picks the nearest place and stops at the map app's start screen.
- **The rotary knob.** The car's knob moves between buttons, so cars without a touchscreen work too.
- **Music over Bluetooth or USB.** Bluetooth plays as the phone normally would. USB has no delay but asks to share audio each drive. Gearslip offers to pair with the car the first time.
- **Directions on the car.** The car speaks each turn from your map app, and shows the directions on its own display where it can.
- **Updates in the app.** Gearslip finds new versions and installs them, with an optional beta channel.
- **Button styles** for the car screen, picked in the intro or the car's settings.
## Fixed ## Fixed
- More cars play music over USB, including ones that refused Gearslip's audio before. - 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.
- Cars that give no screen size get a sharper picture, up to 1080p, and taps land where you press on cars that crop the edges. - 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).
- A new destination mid-route replaces the old route, and switching map apps ends the old app's route. - 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.
- Tidal's like and shuffle buttons show their real state.
- MapQuest finds your location.
## Changed ## Testing
- The app is half the size: 30 MB instead of 65. - 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.
- On GrapheneOS, Gearslip no longer needs exploit protection compatibility mode.
- The car warns you when the phone's location is off.
- Support Gearslip through Liberapay or Monero, in Settings.
## Known issues Help translate Gearslip on [Weblate](https://hosted.weblate.org/engage/gearslip/).
- Phone calls are unstable right now and may drop or fail to connect.
+3
View File
@@ -12,6 +12,9 @@ jobs:
contents: write contents: write
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with:
# The build names itself after the newest tag, which a shallow clone doesn't have.
fetch-depth: 0
- uses: actions/setup-java@v4 - uses: actions/setup-java@v4
with: with:
+20
View File
@@ -0,0 +1,20 @@
name: Translations
# Weblate is the only thing that writes translated strings: it pushes straight to experimental,
# never through a pull request. A PR that edits them collides with Weblate's own copy, and
# Weblate locks itself until someone resets it. Edit the English strings.xml only.
# Weblate's own pull requests are the exception: they are the translations.
on:
pull_request:
paths:
- "gearslip/src/main/res/values-*/strings.xml"
jobs:
weblate-only:
if: github.event.pull_request.user.login != 'weblate'
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).
+19 -1
View File
@@ -5,7 +5,7 @@ start there if you aren't sure where to begin.
## Reporting a bug ## Reporting a bug
Open an issue on [GitHub](https://github.com/Seb3thehacker/gearslip/issues) and include: Open an issue on [GitHub](https://github.com/twinksoftware/gearslip/issues) and include:
- your car's make, model, and year, and whether the head unit is the original one; and - your car's make, model, and year, and whether the head unit is the original one; and
- the log from the drive. Open Logs on the phone app's home screen and tap Share log. - the log from the drive. Open Logs on the phone app's home screen and tap Share log.
@@ -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
+1 -1
View File
@@ -1,4 +1,4 @@
Copyright (C) 2026 seb Copyright (C) 2026 Twink Software LLC and contributors
GNU AFFERO GENERAL PUBLIC LICENSE GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007 Version 3, 19 November 2007
+1 -1
View File
@@ -6,4 +6,4 @@ You may use, run, and modify Gearslip freely. If you distribute a modified versi
## Commercial licensing ## Commercial licensing
If the AGPL's terms don't work for you, the author sells a separate commercial license. The usual case is a company that wants to use Gearslip privately without publishing its changes. Terms, including any limits on modification, are set case by case. Contact me on Matrix; the address is on my profile. If the AGPL's terms don't work for you, Twink Software LLC, which owns Gearslip, sells a separate commercial license. The usual case is a company that wants to use Gearslip privately without publishing its changes. Terms, including any limits on modification, are set case by case. Contact us on Matrix; the address is on the [Seb3thehacker GitHub profile](https://github.com/Seb3thehacker).
+117 -85
View File
@@ -4,96 +4,120 @@
An Android Auto alternative built for everyone. 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)
![Vela Maps navigating on the car screen, with the music player beside the map](docs/screenshots/navigation.png)
[![forthebadge](https://forthebadge.com/badges/compatibility-club-penguin.svg)](https://forthebadge.com) [![forthebadge](https://forthebadge.com/badges/compatibility-club-penguin.svg)](https://forthebadge.com)
[<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/Seb3thehacker/gearslip) Gearslip speaks the Android Auto protocol straight to your car, over USB or wireless, with no
Google software in the path. Map, music, and messaging apps draw their car screens through
Gearslip, and it adds a player, calls, voice, and a web browser of its own.
Questions, ideas? > [!WARNING]
[Join the discussion on Matrix](https://matrix.to/#/!mf0_mgBd5KqKpc9FfX_wHp1jFCcwAtPrndIn_oGIyMk?via=matrix.org). > 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.
## Which cars work Questions, ideas? [Join the discussion on Matrix](https://matrix.to/#/!mf0_mgBd5KqKpc9FfX_wHp1jFCcwAtPrndIn_oGIyMk?via=matrix.org).
Most cars work. Starting around 2020, some cars added newer security standards that reject ## What works
Gearslip's certificate, and Gearslip can't run in those cars. [CARS.md](CARS.md) lists the
cars drivers have confirmed.
## What Gearslip is
Gearslip is an Android Auto client for the phone. It speaks the Android Auto protocol
directly to a car head unit, over USB or wirelessly, with no Google software in the path.
Gearslip acts as a Car App Library host: an app that ships a car interface hands Gearslip
a template, and Gearslip renders that template natively on the car screen. Nothing is
mirrored from the phone. For apps without a car interface, Gearslip offers a separate
screen-sharing mode.
| | | | | |
|---|---| |---|---|
| ![The launcher, with apps grouped into phone, maps, music, and messaging](docs/screenshots/launcher.png) | ![Vela Maps giving turn-by-turn directions, with the music player beside the map](docs/screenshots/navigation.png) | | **Cars** | Most cars, over USB or wireless. Some cars from about 2020 on need a certificate of your own. [CARS.md](CARS.md) lists the cars drivers have confirmed. |
| Apps sorted by kind. A check marks the ones that work. | Vela Maps navigating, with the player beside it. | | **Maps** | Organic Maps, Vela, OsmAnd, Waze, Flitsmeister, MAPS.ME, and MapQuest on the car screen, alerts included. Turns from Google Maps or Waze on the phone show in the nav bar and on the instrument cluster. |
| ![ViVi Music's song, art, and controls in Gearslip's player](docs/screenshots/music.png) | ![A new message card over Organic Maps](docs/screenshots/message.png) | | **Music** | Any app built for car screens, with Now Playing, Up Next, search, and lyrics. Spotify plays through Gearslip's own player. |
| ViVi Music in Gearslip's player. | A new message, read aloud at a tap, over the map. | | **Calls** | Dial or search contacts, and answer or decline on the car screen. Calls use the car's Bluetooth. |
| ![The dashboard, with the weather, the next calendar event, and recent notifications](docs/screenshots/dashboard.png) | ![DuckDuckGo open in Gearslip's web browser](docs/screenshots/web.png) | | **Messages** | Read aloud on arrival, with a reply by voice or a quick reply. |
| The weather, your next event, and what you missed. | Browse the web while parked. | | **Voice** | "Navigate to the nearest gas station", "play Daft Punk", "call Mom". Speech stays on the phone. Commands are in English for now. |
| **Controls** | Touch, steering wheel buttons, control knobs, and D-pads. |
| **Languages** | English and 19 more, some only partly translated. |
| **Head units with Android** | Gearslip can be the home screen of an Android head unit, or of a spare phone or tablet on the dash. Turn on Use as launcher in Settings > Connection. |
| **Doesn't work** | Spotify's and Google Messages' own car screens, which accept only Android Auto. TIDAL lets only approved apps browse its library. |
## See it
| | |
|---|---|
| ![The launcher, with every car app on the phone](docs/screenshots/launcher.png) | ![Metrolist's song, art, and controls in Gearslip's player](docs/screenshots/music.png) |
| Your car apps in one place. | Metrolist in Gearslip's player. |
| ![A new message card over Vela Maps](docs/screenshots/message.png) | ![Synced lyrics beside the player, lit up to the word being sung](docs/screenshots/lyrics.png) |
| A new message, read aloud at a tap, over the map. | Lyrics light up word by word as the song plays. |
| ![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. Each one shows its time and distance. |
| ![DuckDuckGo open in Gearslip's web browser](docs/screenshots/web.png) | |
| Browse the web while parked. | |
## Features ## Features
- **Phone calls.** Dial from a keypad, or search your contacts, then confirm before the - **Navigation.** Gearslip hosts real map apps on the car screen. While you navigate, a big
call goes out. When a call comes in, answer or decline it on the car screen. turn card shows the arrow, the distance, and your lane, and the map keeps the rest.
- **Navigation.** Gearslip hosts real Car App Library map apps on the car screen. - **Music.** Browse, play, and queue from any car-ready media app. Type a search, and apps
Organic Maps, OsmAnd, and Flitsmeister work today. Vela runs, with rough that accept one play the best match.
edges. - **Phone calls.** During a call, the nav bar shows who you're talking to, and you mute or
- **Music.** Browse, play, and queue tracks from a car-enabled media app, with a Now hang up from there.
Playing view, an Up Next queue, and lyrics. Type a search, and apps that accept one - **Messages and notifications.** Only messages, missed calls, alarms, and reminders
play the best match. interrupt you. The rest wait on the dashboard. Music pauses while Gearslip speaks or
- **The web.** A browser template with its own on-screen keyboard, for anything that listens.
doesn't ship a car interface of its own. - **Voice.** Tap the mic or press the talk button on the wheel. Say "help" for the full list.
- **Weather and the day ahead.** A dashboard screen shows the week's calendar next to - **The web.** A browser with an address bar, back and forward, and its own keyboard.
the current and coming weather. - **Weather and the day ahead.** A dashboard shows the week's calendar beside the weather.
- **Voice.** Tap the mic, or press the talk button on the wheel, and say "navigate to the - **Your apps, your order.** Apps group by kind. Press and hold one to move it, and hide
nearest gas station", "play Daft Punk", or "call Mom". Say "help" for the full list. the ones you don't use from the Apps card on the phone.
Speech turns into text on the phone, and nothing you say leaves it. - **Your look.** The car screen takes your phone's Material You colors. Choose flat
- **Messages.** New messages appear as a small card on the car screen. Gearslip reads buttons or ones that look like real keys, and make text bigger on small screens.
each one aloud, and you reply by voice or with a quick reply. Music pauses while - **Settings on the phone.** Set things up before you drive. A change on either screen
Gearslip speaks or listens. Speech runs on the phone, so it needs no Google account. shows on both.
- **Notifications.** Only messages, missed calls, alarms, and reminders interrupt you.
The rest wait on the dashboard.
- **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.
- **Car controls.** Steering wheel buttons, control knobs, and D-pads all work.
- **Themes.** Choose flat buttons or skeuomorphic ones that look like real keys.
## Status ## Install
- **Wired or wireless.** Plug the phone into the car's USB port, or, in a car with wireless 1. Install Gearslip from [the releases page](https://github.com/twinksoftware/Gearslip/releases)
Android Auto, tap Connect wirelessly and pick the car. Pair the phone with the car over or through Obtainium.
Bluetooth first. 2. Open it. Setup asks where your Android Auto certificate comes from, then for one
- **Setup walks you through it.** First launch asks for the Android Auto certificate, permission at a time, each with a plain reason. A help screen in the car answers the rest.
then one permission at a time, each with a plain reason and an Allow or Skip. A help 3. Plug the phone into the car's USB port. In a car with wireless Android Auto, pair the
screen inside the car answers anything setup didn't cover. phone with the car over Bluetooth, then tap Connect wirelessly and pick the car.
- **Templated apps work.** Gearslip renders all 16 template types that the Car App
Library defines, and it draws its own keyboard for search, sign-in, and text fields. After the first wireless drive, Android asks once whether Gearslip may watch for that car.
It has been tested against several real navigation and media apps. From then on, Gearslip connects whenever the car's Bluetooth does.
> [!NOTE]
> 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.
Gearslip updates itself from this repository. Once a day it checks the latest release,
downloads the APK, checks it against GitHub's SHA-256, and installs it through Android's
own installer. Turn off automatic checks in Settings.
### Verify your download
Check that your copy of Gearslip is genuine with
[AppVerifier](https://github.com/soupslurpr/AppVerifier): share this block to it, or compare it
with the output of `apksigner verify --print-certs`.
```
app.seb3thehacker.gearslip
4C:C5:6E:BD:D1:3A:54:D2:60:EC:A9:B7:E2:B2:AE:6B:30:E2:F2:25:BF:DA:8F:50:8F:07:09:ED:F0:84:94:8B
```
## Good to know
- **Music reaches the car over Bluetooth or through Gearslip.** Setup asks which.
Bluetooth is the default. Android never asks for permission, and your map app's voice
reaches the car too. Through Gearslip, the sound travels over the car connection, cable
or wireless. It sounds a little clearer, with no delay, but each drive Android asks you
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
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 notifications visible while audio plays.
- **Apps without a car screen** can still reach the car through a separate screen-sharing
mode.
- **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
Google's car library that accepts only Android Auto itself. Spotify still plays through
Gearslip's own player.
- **Calls use the car's Bluetooth,** as they do in Android Auto. Pair the phone with the
car to hear calls through its speakers.
- **Music reaches the car over Bluetooth or USB.** Setup asks which. Bluetooth is the
default: Android never asks for permission, and your map app's voice reaches the car
too. USB sounds a little clearer, with no delay, but Android shows a screen-share
consent dialog once per drive. 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
Android permission or an on-device consent dialog. For USB audio, setup offers two
optional adb commands: one removes the audio dialog for good, and the other keeps
notifications visible while audio plays.
- **Updates come from GitHub.** Once a day, Gearslip checks this repository's latest
release. When a new version is out, it downloads the APK, checks it against GitHub's
SHA-256, and installs it through Android's own installer. Turn off automatic checks in
Settings.
## Usage notes ## Usage notes
@@ -106,9 +130,9 @@ 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
the connection got, how long it lasted, and how it ended. Settings shows the exact note. Untick a phone detail, and went: how far the connection got, how long it lasted, and how it ended. Settings shows
the server drops the copy it kept. the exact note. Untick a phone detail, and 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
anything you type or play. anything you type or play.
- **How phones are counted.** A random number counts each phone once. The server keeps - **How phones are counted.** A random number counts each phone once. The server keeps
@@ -118,6 +142,19 @@ turn them on, in setup or in Settings.
Turn the notes off, and they stop at once. Turn the notes off, and they stop at once.
## Help out
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
on, and the one-time agreement you sign before your first pull request merges.
To translate Gearslip into your language, join
[Gearslip on Weblate](https://hosted.weblate.org/engage/gearslip/). No coding needed.
Translations go through Weblate only, since it holds its own copy of every language and
changes made in git collide with it.
[![Translation status by language](https://hosted.weblate.org/widget/gearslip/app/multi-auto.svg)](https://hosted.weblate.org/engage/gearslip/)
## How Gearslip is made ## How Gearslip is made
Gearslip is my beloved child. I decide what it does and how it looks, and Claude writes nearly Gearslip is my beloved child. I decide what it does and how it looks, and Claude writes nearly
@@ -127,12 +164,6 @@ open-source head unit software. I watch the phone's temperature too, so a new fe
cook it. Then I beg my friends to try it in their cars, so it works in as many makes, models, cook it. Then I beg my friends to try it in their cars, so it works in as many makes, models,
and years as possible. If something breaks, that's on me, so please open an issue. and years as possible. If something breaks, that's on me, so please open an issue.
## Contributing
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
on, and the one-time agreement you sign before your first pull request merges.
## Building ## Building
``` ```
@@ -170,5 +201,6 @@ any of these companies. All other names belong to their owners.
## License ## License
Gearslip is licensed under AGPL-3.0; see [LICENSE](LICENSE). The author offers a separate Gearslip belongs to Twink Software LLC and is licensed under AGPL-3.0; see [LICENSE](LICENSE).
commercial license for private use outside the AGPL's terms; see [NOTICE.md](NOTICE.md). The LLC also sells a separate commercial license for private use outside the AGPL's terms; see
[NOTICE.md](NOTICE.md).
+38
View File
@@ -469,6 +469,44 @@ home-screen pane comes later. Anything that needs its own rendering belongs in a
--- ---
## Works with Gearslip
Once your app runs well on Gearslip, say so in two places: your manifest and your README.
### In your manifest
Add these lines to `<application>`:
```xml
<meta-data
android:name="app.seb3thehacker.gearslip.works_with"
android:value="true" />
<meta-data
android:name="app.seb3thehacker.gearslip.about"
android:resource="@string/gearslip_about" />
```
Gearslip then marks your app "Works with Gearslip" in its Recommended apps list and on your app's
page in Gearslip. An installed app with the flag gets its own place at the top of the list.
The second entry is optional. It gives your page one sentence on what your app does in the car,
such as "Spots trackers and cameras nearby." Write it in place with `android:value` if you have no
string resource for it.
The flag is a label. Gearslip finds and runs your app the same way with or without it.
### In your README
Add the badge:
```markdown
[![Works with Gearslip](https://raw.githubusercontent.com/twinksoftware/gearslip/main/docs/badge/works-with-gearslip.svg)](https://github.com/twinksoftware/gearslip)
```
It matches the flat badges most READMEs already show.
---
## Further reading ## Further reading
- [README.md](../README.md) - what Gearslip is. - [README.md](../README.md) - what Gearslip is.
+16
View File
@@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" width="147" height="20" role="img" aria-label="Works with Gearslip">
<title>Works with Gearslip</title>
<clipPath id="r"><rect width="147" height="20" rx="3" fill="#fff"/></clipPath>
<g clip-path="url(#r)">
<rect width="88" height="20" fill="#555"/>
<rect x="88" width="59" height="20" fill="#8C1F1F"/>
</g>
<g transform="translate(5 3.5) scale(0.228) translate(-29.5 -25.5)" fill="#fff">
<path fill-rule="evenodd" d="M54 36.18 58.64 30.7 67.2 34.24 66.6 41.4 71.82 54 77.3 58.64 73.76 67.2 66.6 66.6 67.2 73.76 58.64 77.3 54 71.82 49.36 77.3 40.8 73.76 41.4 66.6 34.24 67.2 30.7 58.64 36.18 54 30.7 49.36 34.24 40.8 41.4 41.4 40.8 34.24 49.36 30.7ZM54 46.08A7.92 7.92 0 1 1 53.99 46.08Z"/>
<path d="M78.61 40.7 85.31 43.29 84.74 52.55 77.78 54.31Z"/>
</g>
<g fill="#fff" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11" text-rendering="geometricPrecision">
<text x="22" y="14" textLength="60">works with</text>
<text x="94" y="14" textLength="47">Gearslip</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 402 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 211 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: 390 KiB

After

Width:  |  Height:  |  Size: 313 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 161 KiB

After

Width:  |  Height:  |  Size: 449 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 219 KiB

After

Width:  |  Height:  |  Size: 420 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 392 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 107 KiB

+73 -12
View File
@@ -24,6 +24,36 @@ val keystoreProperties = Properties().apply {
).firstOrNull { it.exists() }?.inputStream()?.use { load(it) } ).firstOrNull { it.exists() }?.inputStream()?.use { load(it) }
} }
// The version comes from the newest v* git tag, so a build can never disagree with its tag. A
// build of the tagged commit is named after it ("0.4.0-beta"); anything later adds the commits
// since and the commit ("0.4.0-beta+3.ba4374e"), so a test build can't pass for a release.
// The version code is worked out from the numbers, ((major*100 + minor)*100 + patch)*10 + stage,
// where stage is 1 for alpha, 2 for beta and 9 for a finished release. It only ever goes up as
// long as minor and patch stay under 100. Changelog entries use the same number.
class GitVersion(val name: String, val code: Int)
val gitVersion: GitVersion = run {
val described = runCatching {
providers.exec {
commandLine("git", "describe", "--tags", "--match", "v*", "--abbrev=7")
isIgnoreExitValue = true
}.standardOutput.asText.get().trim()
}.getOrNull().orEmpty()
val match = Regex("""^v((\d+)\.(\d+)\.(\d+)(?:-([A-Za-z]+))?)(?:-(\d+)-g([0-9a-f]+))?$""").find(described)
?: return@run GitVersion("0.0.0-dev", 1) // no git or no tags, e.g. a source download
val (base, major, minor, patch, suffix, since, commit) = match.destructured
val stage = when (suffix.lowercase()) {
"" -> 9
"beta" -> 2
"alpha" -> 1
else -> 0
}
GitVersion(
name = if (since.isEmpty()) base else "$base+$since.$commit",
code = ((major.toInt() * 100 + minor.toInt()) * 100 + patch.toInt()) * 10 + stage,
)
}
// Keeps build output out of the module folder: <root>/build/gearslip/... instead of // Keeps build output out of the module folder: <root>/build/gearslip/... instead of
// <root>/gearslip/build/..., so everything this project produces lands under one // <root>/gearslip/build/..., so everything this project produces lands under one
// root-level build/ directory. Set before android {}, which works out the path to R8's default // root-level build/ directory. Set before android {}, which works out the path to R8's default
@@ -38,9 +68,9 @@ android {
applicationId = "app.seb3thehacker.gearslip" applicationId = "app.seb3thehacker.gearslip"
minSdk = 31 minSdk = 31
targetSdk = 37 targetSdk = 37
versionCode = 9 versionCode = gitVersion.code
// -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.00-alpha" versionName = project.findProperty("gearslipVersionName")?.toString() ?: gitVersion.name
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,8 +104,12 @@ 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"
// -Pr8 shrinks a debug build like a release one, to test R8 in the debug install. // English (XA) and Arabic (XB) under the phone's languages: stretched, accented and
if (project.hasProperty("r8")) { // 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
// rule missing for release shows up here first). -PnoR8 skips it for a quicker build.
if (!project.hasProperty("noR8")) {
isMinifyEnabled = true isMinifyEnabled = true
isShrinkResources = true isShrinkResources = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
@@ -87,17 +121,41 @@ 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).
//
// Only languages that are mostly translated ship, so the app never comes up half in English.
// Danish and Estonian are still early on Weblate; add them here once they're close to done.
androidResources {
generateLocaleConfig = true
localeFilters += listOf(
"en", "ar", "cs", "de", "es", "fr", "it", "ja", "ko", "nl", "pl", "pt-rBR", "ro", "ru",
"sv", "tr", "uk", "zh-rCN",
)
}
lint {
// 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
} }
defaultConfig { defaultConfig {
// Speech-to-text (sherpa-onnx, in jniLibs) ships for 64-bit ARM only: every phone // Speech-to-text (sherpa-onnx 1.13.8, static-link-onnxruntime build, in jniLibs) is the
// Gearslip runs on, and one copy of the native library instead of four. // only native code. One APK for all three, since the updater takes the release's only APK;
ndk { abiFilters += "arm64-v8a" } // 32-bit x86 is left out, nothing that drives a car runs it.
ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64") }
} }
// Compressed, the three copies cost a 33 MB download instead of 75 MB; the phone unpacks
// only its own at install.
packaging { jniLibs { useLegacyPackaging = true } }
testOptions { testOptions {
// GearslipLog calls android.util.Log, which is a stub on the local JVM. // GearslipLog calls android.util.Log, which is a stub on the local JVM.
unitTests.isReturnDefaultValues = true unitTests.isReturnDefaultValues = true
@@ -105,9 +163,10 @@ android {
} }
// Renames the produced APK and moves it to the root build/ folder (<root>/build/Gearslip-debug.apk) // Copies the produced APK to the root build/ folder (<root>/build/Gearslip-debug.apk) after the
// after the fact rather than fighting AGP's variant-output API across versions - simpler and // fact rather than fighting AGP's variant-output API across versions - simpler and doesn't break
// doesn't break when that API moves again. // when that API moves again. A copy, not a move: AGP packages incrementally into the APK it left
// last time, and with that file gone a build came out holding nothing but classes.dex.
tasks.register("renameApk") { tasks.register("renameApk") {
doLast { doLast {
val rootBuildDir = rootProject.layout.buildDirectory.get().asFile val rootBuildDir = rootProject.layout.buildDirectory.get().asFile
@@ -117,7 +176,6 @@ tasks.register("renameApk") {
.forEach { apk -> .forEach { apk ->
val renamed = File(rootBuildDir, "Gearslip-${apk.parentFile.name}.apk") val renamed = File(rootBuildDir, "Gearslip-${apk.parentFile.name}.apk")
apk.copyTo(renamed, overwrite = true) apk.copyTo(renamed, overwrite = true)
apk.delete()
} }
} }
} }
@@ -129,7 +187,6 @@ dependencies {
implementation(platform(libs.androidx.compose.bom)) implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.material3) implementation(libs.androidx.compose.material3)
implementation(libs.commons.compress)
implementation(libs.androidx.compose.material.icons.core) implementation(libs.androidx.compose.material.icons.core)
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.androidx.lifecycle.runtime.compose)
@@ -139,6 +196,10 @@ dependencies {
implementation(libs.androidx.car.app) implementation(libs.androidx.car.app)
implementation(libs.androidx.car.app.projected) implementation(libs.androidx.car.app.projected)
implementation(libs.androidx.media) implementation(libs.androidx.media)
// Document-start scripts, so the ad blocker hides ad slots before a page first paints.
implementation(libs.androidx.webkit)
// The map behind a place list (PlaceListMapTemplate), which the host draws, not the app.
implementation(libs.maplibre.android)
testImplementation(libs.junit) testImplementation(libs.junit)
} }
-7
View File
@@ -11,10 +11,3 @@
# Speech-to-text: the native library calls back into these classes by name. # Speech-to-text: the native library calls back into these classes by name.
-keep class com.k2fsa.sherpa.onnx.** { *; } -keep class com.k2fsa.sherpa.onnx.** { *; }
# commons-compress refers to optional codecs Gearslip doesn't ship.
-dontwarn org.tukaani.xz.**
-dontwarn com.github.luben.zstd.**
-dontwarn org.brotli.dec.**
-dontwarn org.objectweb.asm.**
-dontwarn org.osgi.**
+80 -1
View File
@@ -5,6 +5,8 @@
<!-- The projected WebView loads real pages; without this every load fails silently. --> <!-- The projected WebView loads real pages; without this every load fails silently. -->
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" /> <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<!-- Lets an update Gearslip installed itself skip Android's prompt. -->
<uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" />
<!-- <!--
Phone mirroring. Android refuses to hand over a MediaProjection unless a foreground Phone mirroring. Android refuses to hand over a MediaProjection unless a foreground
@@ -19,12 +21,17 @@
<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
hands over its Wi-Fi details; Gearslip then joins that network to reach the car. hands over its Wi-Fi details; Gearslip then joins that network to reach the car.
--> -->
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" /> <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<!-- Wireless auto-connect: Android tells Gearslip when a car it manages connects. -->
<uses-permission android:name="android.permission.REQUEST_OBSERVE_COMPANION_DEVICE_PRESENCE" />
<uses-feature android:name="android.software.companion_device_setup" android:required="false" />
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" /> <uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission <uses-permission
android:name="android.permission.NEARBY_WIFI_DEVICES" android:name="android.permission.NEARBY_WIFI_DEVICES"
@@ -59,7 +66,8 @@
<!-- <!--
Phone calls on the car screen. READ_PHONE_STATE and READ_CALL_LOG together are what a Phone calls on the car screen. READ_PHONE_STATE and READ_CALL_LOG together are what a
call's own state (ringing, in progress) and the incoming number need; READ_CONTACTS call's own state (ringing, in progress) and the incoming number need, and READ_CALL_LOG
also lists recent calls on the car's Phone screen, read on the phone only; READ_CONTACTS
turns that number into a name; ANSWER_PHONE_CALLS lets the driver pick up from here. turns that number into a name; ANSWER_PHONE_CALLS lets the driver pick up from here.
Declining a call, and the caller ID itself, need Gearslip set as the phone's caller ID Declining a call, and the caller ID itself, need Gearslip set as the phone's caller ID
and spam app too - an on-device consent, not a permission declared here. and spam app too - an on-device consent, not a permission declared here.
@@ -123,6 +131,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
@@ -154,11 +163,58 @@
</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. -->
<!-- "Share" a page from any browser to add it to the car browser's start page (issue #85). -->
<activity
android:name=".browser.ShareToBrowserActivity"
android:exported="true"
android:excludeFromRecents="true"
android:label="@string/browser_share_label"
android:noHistory="true"
android:theme="@android:style/Theme.Translucent.NoTitleBar">
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
</activity>
<activity
android:name=".ui.ManageSpaceActivity"
android:exported="true"
android:label="Gearslip storage" />
<activity
android:name=".PermissionActivity"
android:exported="false"
android:excludeFromRecents="true"
android:theme="@style/Theme.Gearslip.Invisible" />
<activity-alias <activity-alias
android:name=".GearslipUsbAccessory" android:name=".GearslipUsbAccessory"
android:targetActivity=".GearslipActivity" android:targetActivity=".GearslipActivity"
@@ -178,6 +234,14 @@
android:name=".notify.TestMessageReceiver" android:name=".notify.TestMessageReceiver"
android:exported="false" /> android:exported="false" />
<receiver
android:name=".update.UpdateReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
<service <service
android:name=".mirror.MirrorService" android:name=".mirror.MirrorService"
android:exported="false" android:exported="false"
@@ -188,6 +252,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.
@@ -244,5 +313,15 @@
<action android:name="android.telecom.CallScreeningService" /> <action android:name="android.telecom.CallScreeningService" />
</intent-filter> </intent-filter>
</service> </service>
<!-- Told when a car approved for wireless auto-connect comes up over Bluetooth. -->
<service
android:name=".wireless.CarPresenceService"
android:exported="true"
android:permission="android.permission.BIND_COMPANION_DEVICE_SERVICE">
<intent-filter>
<action android:name="android.companion.CompanionDeviceService" />
</intent-filter>
</service>
</application> </application>
</manifest> </manifest>
File diff suppressed because it is too large. Load diff
@@ -4,12 +4,36 @@ package app.seb3thehacker.gearslip
* What changed in each release, newest first, shown on the car screen after an update (and on * What changed in each release, newest first, shown on the car screen after an update (and on
* the phone). Only the changes a driver would notice. Plain words, short lines: this is read in * the phone). Only the changes a driver would notice. Plain words, short lines: this is read in
* a parked car. * a parked car.
*
* After 0.3.40, the version code is the one the build works out from the tag (see gitVersion in
* build.gradle.kts): ((major*100 + minor)*100 + patch)*10 + stage, so 0.4.0-beta is 4002. Older
* entries keep the hand-set codes they shipped with, which are all lower.
*/ */
object Changelog { 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,226 @@
package app.seb3thehacker.gearslip
import android.content.Context
import android.os.Handler
import android.os.Looper
import app.seb3thehacker.gearslip.car.ClusterScreen
import kotlinx.coroutines.flow.MutableStateFlow
/**
* Gearslip's picture on the car's instrument-cluster screen: a second video sink, for a car that
* 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].
*
* Gearslip holds the cluster only while there's a route, as the card has nothing else to show.
* Otherwise the car keeps its own picture there: an Audi's virtual cockpit went black instead of
* showing its map (issue #56). [watchRoute] asks for the screen when a route starts and hands it
* back once there has been none for [NO_ROUTE_MS].
*/
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
/** Configured and ready for focus requests. */
@Volatile private var ready = false
/** The car shows Gearslip's picture now; frames aren't sent while it shows its own. */
@Volatile private var projected = false
/** Gearslip has asked for the screen and not handed it back since. */
private var wanted = false
private var noRouteSince = 0L
@Volatile private var running = true
@Volatile private var source: VideoSource? = null
private var projector: ScreenProjector? = null
private val ackLock = Object()
@Volatile private var inFlight = 0
@Volatile private var maxUnacked = 1
@Volatile private var lastAckAt = 0L
private var frames = 0
/** The part of the picture the car's own gauges and bars leave clear, as the car last said. */
private val clearArea = MutableStateFlow<Insets?>(null)
fun onMessage(messageId: Int, body: ByteArray) {
when (messageId) {
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")
ready = true
main.post(watchRoute)
}
GearslipRunner.MSG_VIDEO_FOCUS_NOTIFICATION -> {
val mode = Wire.varint(Wire.fields(body), 1)?.toInt() ?: GearslipRunner.VIDEO_FOCUS_PROJECTED
log.i("<- VideoFocusNotification: mode=$mode")
val now = mode == GearslipRunner.VIDEO_FOCUS_PROJECTED || mode == GearslipRunner.VIDEO_FOCUS_PROJECTED_NO_INPUT
val back = now && !projected
projected = now
if (now && !started) startStream()
// Back from the car's own picture: a fresh key frame, so it doesn't start on a partial one.
else if (back) source?.requestSyncFrame()
}
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()
}
}
// UpdateUiConfigRequest { AdditionalVideoConfig { display_insets = 1 } }: an Audi's
// virtual cockpit sends one as the picture starts, and draws its dials over the frame.
GearslipRunner.MSG_VIDEO_UI_CONFIG -> {
log.i("<- UpdateUiConfigRequest (${body.size} bytes)\n${Protobuf.describe(body)}")
clearArea.value = insetsFrom(body)
}
else -> log.i("<- unhandled message id=$messageId (${body.size} bytes)")
}
}
/** Once a second: the cluster follows whether there's a route. */
private val watchRoute = object : Runnable {
override fun run() {
if (!running || !ready) return
val route = app.seb3thehacker.gearslip.car.NavTurn.current() != null
val now = System.currentTimeMillis()
if (route) noRouteSince = 0L else if (noRouteSince == 0L) noRouteSince = now
when {
route && !wanted -> requestFocus(GearslipRunner.VIDEO_FOCUS_PROJECTED, "a route started")
!route && wanted && now - noRouteSince >= NO_ROUTE_MS -> requestFocus(GearslipRunner.VIDEO_FOCUS_NATIVE, "no route")
}
main.postDelayed(this, WATCH_MS)
}
}
private fun requestFocus(mode: Int, why: String) {
wanted = mode == GearslipRunner.VIDEO_FOCUS_PROJECTED
// VideoFocusRequestNotification { disp_channel_id = 1; mode = 2; reason = 3 }, as the main
// screen asks for its own picture back.
val reason = if (wanted) 1L else GearslipRunner.VIDEO_FOCUS_REASON_LAUNCH_NATIVE.toLong()
val body = Protobuf.varintField(1, channelId.toLong()) + Protobuf.varintField(2, mode.toLong()) + Protobuf.varintField(3, reason)
runCatching { send(GearslipRunner.MSG_VIDEO_FOCUS_REQUEST, body) }
log.i("-> VideoFocusRequest(mode=${if (wanted) "PROJECTED" else "NATIVE"}): $why")
}
private fun startStream() {
val config = service.configs.getOrNull(configIndex) ?: service.configs.firstOrNull() ?: return
val (width, height) = config.pixelSize() ?: run {
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, density, clearArea)
}
}
}
}
/** 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 || !projected) 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)")
}
/** VideoInsets { top = 1; bottom = 2; left = 3; right = 4 }, or null when the car sent none. */
private fun insetsFrom(body: ByteArray): Insets? {
val config = Wire.bytes(Wire.fields(body), 1) ?: return null
val insets = Wire.fields(Wire.bytes(Wire.fields(config), 1) ?: return null)
fun side(n: Int) = Wire.varint(insets, n)?.toInt() ?: 0
return Insets(top = side(1), bottom = side(2), left = side(3), right = side(4))
.also { log.i("the car keeps clear: $it") }
}
fun stop() {
running = false
main.removeCallbacks(watchRoute)
synchronized(ackLock) { ackLock.notifyAll() }
main.post {
projector?.stop()
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
const val WATCH_MS = 1_000L
/** A gap in the turns this long is the route over, not a reroute or a moment between steps. */
const val NO_ROUTE_MS = 10_000L
}
}
@@ -0,0 +1,190 @@
package app.seb3thehacker.gearslip
import android.graphics.SurfaceTexture
import android.opengl.EGL14
import android.opengl.EGLConfig
import android.opengl.EGLContext
import android.opengl.EGLDisplay
import android.opengl.EGLExt
import android.opengl.EGLSurface
import android.opengl.GLES11Ext
import android.opengl.GLES20
import android.os.Handler
import android.os.HandlerThread
import android.view.Surface
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
/**
* Stretches a [width] x [height] picture over the whole of an [outWidth] x [outHeight] encoder
* frame.
*
* For a head unit that squashes the frame onto a screen of another shape: Gearslip draws for the
* screen's real shape into [input], this stretches it the other way, and the car's squash brings
* it back. It works on the composited picture, so a map app's own surface is stretched with
* everything else - scaling Gearslip's UI alone would leave the map squashed.
*/
class FrameStretcher(
private val output: Surface,
val width: Int,
val height: Int,
private val outWidth: Int,
private val outHeight: Int,
) {
private val thread = HandlerThread("gearslip-stretch").apply { start() }
private val handler = Handler(thread.looper)
private var display: EGLDisplay = EGL14.EGL_NO_DISPLAY
private var context: EGLContext = EGL14.EGL_NO_CONTEXT
private var eglSurface: EGLSurface = EGL14.EGL_NO_SURFACE
private var program = 0
private var texture: SurfaceTexture? = null
private val transform = FloatArray(16)
/** Where to draw. Valid once [start] returns true. */
lateinit var input: Surface
private set
/** Sets up on its own thread and waits for it; false if the GPU wouldn't play along. */
fun start(): Boolean {
val done = CountDownLatch(1)
var ok = false
handler.post {
ok = runCatching { setUp() }
.onFailure { GearslipLog.e("stretch: could not set up", it) }
.isSuccess
done.countDown()
}
done.await(2, TimeUnit.SECONDS)
if (!ok) release()
return ok
}
fun release() {
handler.post {
runCatching { if (::input.isInitialized) input.release() }
runCatching { texture?.release() }
texture = null
if (display != EGL14.EGL_NO_DISPLAY) {
EGL14.eglMakeCurrent(display, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_CONTEXT)
if (eglSurface != EGL14.EGL_NO_SURFACE) EGL14.eglDestroySurface(display, eglSurface)
if (context != EGL14.EGL_NO_CONTEXT) EGL14.eglDestroyContext(display, context)
EGL14.eglTerminate(display)
}
display = EGL14.EGL_NO_DISPLAY
thread.quitSafely()
}
}
private fun setUp() {
display = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY)
val version = IntArray(2)
check(EGL14.eglInitialize(display, version, 0, version, 1)) { "eglInitialize failed" }
val configs = arrayOfNulls<EGLConfig>(1)
val count = IntArray(1)
val attributes = intArrayOf(
EGL14.EGL_RED_SIZE, 8, EGL14.EGL_GREEN_SIZE, 8, EGL14.EGL_BLUE_SIZE, 8,
EGL14.EGL_RENDERABLE_TYPE, EGL14.EGL_OPENGL_ES2_BIT,
EGL_RECORDABLE_ANDROID, 1,
EGL14.EGL_NONE,
)
check(EGL14.eglChooseConfig(display, attributes, 0, configs, 0, 1, count, 0) && count[0] > 0) {
"no EGL config"
}
context = EGL14.eglCreateContext(
display, configs[0], EGL14.EGL_NO_CONTEXT,
intArrayOf(EGL14.EGL_CONTEXT_CLIENT_VERSION, 2, EGL14.EGL_NONE), 0,
)
eglSurface = EGL14.eglCreateWindowSurface(display, configs[0], output, intArrayOf(EGL14.EGL_NONE), 0)
check(EGL14.eglMakeCurrent(display, eglSurface, eglSurface, context)) { "eglMakeCurrent failed" }
program = link(VERTEX, FRAGMENT)
val ids = IntArray(1)
GLES20.glGenTextures(1, ids, 0)
GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, ids[0])
GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR)
GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR)
GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE)
GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE)
texture = SurfaceTexture(ids[0]).apply {
setDefaultBufferSize(width, height)
setOnFrameAvailableListener({ draw() }, handler)
}
input = Surface(texture)
GearslipLog.i("stretch: drawing ${width}x$height, stretched to fill ${outWidth}x$outHeight")
}
private fun draw() {
val tex = texture ?: return
runCatching {
tex.updateTexImage()
tex.getTransformMatrix(transform)
GLES20.glViewport(0, 0, outWidth, outHeight)
GLES20.glUseProgram(program)
val position = GLES20.glGetAttribLocation(program, "aPosition")
val coord = GLES20.glGetAttribLocation(program, "aCoord")
GLES20.glEnableVertexAttribArray(position)
GLES20.glVertexAttribPointer(position, 2, GLES20.GL_FLOAT, false, 0, QUAD)
GLES20.glEnableVertexAttribArray(coord)
GLES20.glVertexAttribPointer(coord, 2, GLES20.GL_FLOAT, false, 0, COORDS)
GLES20.glUniformMatrix4fv(GLES20.glGetUniformLocation(program, "uTransform"), 1, false, transform, 0)
GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4)
EGLExt.eglPresentationTimeANDROID(display, eglSurface, tex.timestamp)
EGL14.eglSwapBuffers(display, eglSurface)
}.onFailure { GearslipLog.e("stretch: draw failed", it) }
}
private fun link(vertex: String, fragment: String): Int {
fun compile(type: Int, source: String): Int = GLES20.glCreateShader(type).also {
GLES20.glShaderSource(it, source)
GLES20.glCompileShader(it)
val status = IntArray(1)
GLES20.glGetShaderiv(it, GLES20.GL_COMPILE_STATUS, status, 0)
check(status[0] != 0) { "shader: ${GLES20.glGetShaderInfoLog(it)}" }
}
return GLES20.glCreateProgram().also {
GLES20.glAttachShader(it, compile(GLES20.GL_VERTEX_SHADER, vertex))
GLES20.glAttachShader(it, compile(GLES20.GL_FRAGMENT_SHADER, fragment))
GLES20.glLinkProgram(it)
val status = IntArray(1)
GLES20.glGetProgramiv(it, GLES20.GL_LINK_STATUS, status, 0)
check(status[0] != 0) { "program: ${GLES20.glGetProgramInfoLog(it)}" }
}
}
private companion object {
/** EGL_RECORDABLE_ANDROID: the config must be able to feed a video encoder. */
const val EGL_RECORDABLE_ANDROID = 0x3142
val QUAD = floats(-1f, -1f, 1f, -1f, -1f, 1f, 1f, 1f)
val COORDS = floats(0f, 0f, 1f, 0f, 0f, 1f, 1f, 1f)
fun floats(vararg values: Float) = ByteBuffer.allocateDirect(values.size * 4)
.order(ByteOrder.nativeOrder()).asFloatBuffer().apply { put(values); position(0) }
const val VERTEX = """
attribute vec4 aPosition;
attribute vec4 aCoord;
uniform mat4 uTransform;
varying vec2 vCoord;
void main() {
gl_Position = aPosition;
vCoord = (uTransform * aCoord).xy;
}
"""
const val FRAGMENT = """
#extension GL_OES_EGL_image_external : require
precision mediump float;
varying vec2 vCoord;
uniform samplerExternalOES sTexture;
void main() {
gl_FragColor = texture2D(sTexture, vCoord);
}
"""
}
}
@@ -1,20 +1,10 @@
package app.seb3thehacker.gearslip package app.seb3thehacker.gearslip
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.io.IOException
import java.io.OutputStream
/** /** Android Auto framing with plaintext fragmentation before per-frame encryption. */
* Android Auto frame layout, transcribed from aasdk.
*
* FrameHeader::getData (src/Messenger/FrameHeader.cpp:47-55),
* FrameSize::getData (src/Messenger/FrameSize.cpp:49-62) and
* MessageOutStream::compoundFrame (src/Messenger/MessageOutStream.cpp:100-120):
*
* byte 0 channel id
* byte 1 frameType | encryption | messageType
* bytes 2-3 uint16 BE payload length of THIS frame
* bytes 4-7 uint32 BE total message length - present ONLY when frameType == FIRST
* payload uint16 BE message id, then the body
*/
object Frames { object Frames {
const val CHANNEL_CONTROL = 0 const val CHANNEL_CONTROL = 0
@@ -36,8 +26,11 @@ object Frames {
const val MESSAGE_SPECIFIC = 0 const val MESSAGE_SPECIFIC = 0
const val MESSAGE_CONTROL = 1 shl 2 const val MESSAGE_CONTROL = 1 shl 2
/** aasdk splits at this size (MessageOutStream.hpp:62). Nothing the spike sends is close. */ // Android Auto's default fragment budget includes the framing header. TLS adds overhead.
const val MAX_FRAME_PAYLOAD = 0x4000 private const val FRAGMENT_SIZE = 0x3f00
private const val HEADER_SIZE = 4
private const val FIRST_HEADER_SIZE = 8
const val MAX_FRAME_PAYLOAD = FRAGMENT_SIZE - HEADER_SIZE
class Frame( class Frame(
val channel: Int, val channel: Int,
@@ -45,6 +38,7 @@ object Frames {
val encrypted: Boolean, val encrypted: Boolean,
val messageType: Int, val messageType: Int,
val payload: ByteArray, val payload: ByteArray,
val totalSize: Int? = null,
) )
/** /**
@@ -60,6 +54,11 @@ object Frames {
frameType: Int = TYPE_BULK, frameType: Int = TYPE_BULK,
totalSize: Int? = null, totalSize: Int? = null,
): ByteArray { ): ByteArray {
require(channel in 0..255 && payload.size <= 0xffff)
require(frameType in TYPE_MIDDLE..TYPE_BULK)
require(messageType == MESSAGE_SPECIFIC || messageType == MESSAGE_CONTROL)
require((frameType == TYPE_FIRST) == (totalSize != null))
require(totalSize == null || totalSize in 2..MAX_MESSAGE_SIZE)
val flags = frameType or messageType or (if (encrypted) ENCRYPTED else 0) val flags = frameType or messageType or (if (encrypted) ENCRYPTED else 0)
val header = byteArrayOf( val header = byteArrayOf(
channel.toByte(), channel.toByte(),
@@ -67,7 +66,7 @@ object Frames {
((payload.size shr 8) and 0xFF).toByte(), ((payload.size shr 8) and 0xFF).toByte(),
(payload.size and 0xFF).toByte(), (payload.size and 0xFF).toByte(),
) )
if (frameType != TYPE_FIRST || totalSize == null) return header + payload if (totalSize == null) return header + payload
val extended = byteArrayOf( val extended = byteArrayOf(
((totalSize shr 24) and 0xFF).toByte(), ((totalSize shr 24) and 0xFF).toByte(),
((totalSize shr 16) and 0xFF).toByte(), ((totalSize shr 16) and 0xFF).toByte(),
@@ -77,6 +76,43 @@ object Frames {
return header + extended + payload return header + extended + payload
} }
/** Splits plaintext before encryption; FIRST carries the complete plaintext length. */
fun writeMessage(
output: OutputStream,
channel: Int,
encrypted: Boolean,
messageType: Int,
message: ByteArray,
encrypt: (ByteArray) -> ByteArray,
): Int {
require(message.size in 2..MAX_MESSAGE_SIZE)
val fragmented = message.size > FRAGMENT_SIZE - HEADER_SIZE
require(messageType != MESSAGE_CONTROL || !fragmented) { "Channel control messages cannot be fragmented" }
var offset = 0
var frames = 0
while (offset < message.size) {
val first = fragmented && offset == 0
val headerSize = if (first) FIRST_HEADER_SIZE else HEADER_SIZE
val end = minOf(offset + FRAGMENT_SIZE - headerSize, message.size)
val type = when {
!fragmented -> TYPE_BULK
first -> TYPE_FIRST
end == message.size -> TYPE_LAST
else -> TYPE_MIDDLE
}
val chunk = message.copyOfRange(offset, end)
output.write(build(channel, encrypted, if (encrypted) encrypt(chunk) else chunk,
messageType, type, if (type == TYPE_FIRST) message.size else null))
offset = end
frames++
}
return frames
}
// Local resource limits, not protocol constants. No supported media packet needs more.
private const val MAX_MESSAGE_SIZE = 16 * 1024 * 1024
private const val MAX_BUFFERED_SIZE = 32 * 1024 * 1024
/** /**
* Pulls whole frames out of an accumulating buffer. * Pulls whole frames out of an accumulating buffer.
* *
@@ -87,6 +123,8 @@ object Frames {
private var pending = ByteArray(0) private var pending = ByteArray(0)
fun append(data: ByteArray, length: Int) { fun append(data: ByteArray, length: Int) {
require(length in 0..data.size)
if (length > MAX_BUFFERED_SIZE - pending.size) throw IOException("Frame buffer limit exceeded")
val grown = ByteArray(pending.size + length) val grown = ByteArray(pending.size + length)
System.arraycopy(pending, 0, grown, 0, pending.size) System.arraycopy(pending, 0, grown, 0, pending.size)
System.arraycopy(data, 0, grown, pending.size, length) System.arraycopy(data, 0, grown, pending.size, length)
@@ -106,30 +144,53 @@ object Frames {
val total = headerLength + frameSize val total = headerLength + frameSize
if (pending.size < total) return null if (pending.size < total) return null
val messageSize = if (frameType == TYPE_FIRST) {
var size = 0L
for (i in 4..7) size = (size shl 8) or (pending[i].toLong() and 0xff)
if (size !in 2..MAX_MESSAGE_SIZE.toLong()) throw IOException("Invalid fragmented message length: $size")
size.toInt()
} else null
val payload = pending.copyOfRange(headerLength, total) val payload = pending.copyOfRange(headerLength, total)
pending = pending.copyOfRange(total, pending.size) pending = pending.copyOfRange(total, pending.size)
return Frame( return Frame(
channel, frameType, (flags and ENCRYPTED) != 0, flags and MESSAGE_CONTROL, payload, channel, frameType, (flags and ENCRYPTED) != 0, flags and MESSAGE_CONTROL, payload, messageSize,
) )
} }
} }
/** Rejoins FIRST/MIDDLE/LAST runs. Payloads arrive already decrypted. */ /** Rejoins messages on independent channels, validating their plaintext length and flags. */
class Assembler { class Assembler {
private val partial = HashMap<Int, ByteArrayOutputStream>() private class Partial(val first: Frame, val data: ByteArrayOutputStream = ByteArrayOutputStream())
private val partial = HashMap<Int, Partial>()
private var buffered = 0
fun offer(frame: Frame, payload: ByteArray): ByteArray? = when (frame.frameType) { fun offer(frame: Frame, payload: ByteArray): ByteArray? {
TYPE_BULK -> payload if (frame.frameType == TYPE_BULK) {
TYPE_FIRST -> { if (partial.containsKey(frame.channel)) fail("Complete message interrupted fragments")
partial[frame.channel] = ByteArrayOutputStream().apply { write(payload) } return payload
null
} }
TYPE_MIDDLE -> { val message = if (frame.frameType == TYPE_FIRST) {
partial[frame.channel]?.write(payload) if (frame.totalSize == null || partial.containsKey(frame.channel)) fail("Invalid first fragment")
null Partial(frame).also { partial[frame.channel] = it }
} } else partial[frame.channel] ?: fail("Fragment without a first frame")
TYPE_LAST -> partial.remove(frame.channel)?.apply { write(payload) }?.toByteArray() val first = message.first
else -> null if (first.encrypted != frame.encrypted || first.messageType != frame.messageType) fail("Fragment flags changed")
val expected = first.totalSize ?: fail("Missing total size")
if (expected !in 2..MAX_MESSAGE_SIZE || payload.size > expected - message.data.size()) fail("Fragment exceeds message length")
if (payload.size > MAX_BUFFERED_SIZE - buffered) fail("Fragment buffer limit exceeded")
message.data.write(payload)
buffered += payload.size
if (frame.frameType != TYPE_LAST) return null
if (message.data.size() != expected) fail("Truncated fragmented message")
partial.remove(frame.channel)
buffered -= message.data.size()
return message.data.toByteArray()
}
private fun fail(message: String): Nothing {
partial.clear()
buffered = 0
throw IOException(message)
} }
} }
} }
@@ -14,7 +14,6 @@ import android.os.ParcelFileDescriptor
import android.provider.Settings import android.provider.Settings
import android.view.MotionEvent import android.view.MotionEvent
import android.view.Surface import android.view.Surface
import android.view.WindowManager
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@@ -117,6 +116,21 @@ class GearslipActivity : ComponentActivity(), Projection {
} }
} }
/** Android's one-time "Allow" for a car to connect by itself; see [WirelessAuto]. */
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(
androidx.activity.result.contract.ActivityResultContracts.StartIntentSenderForResult(),
) { result ->
val address = approvingCar ?: return@registerForActivityResult
approvingCar = null
if (result.resultCode == RESULT_OK) app.seb3thehacker.gearslip.wireless.WirelessAuto.approved(this, address)
else app.seb3thehacker.gearslip.wireless.WirelessAuto.declined(this, address)
}
/** Setting Gearslip as the phone's caller ID and spam app - what lets it decline a ringing call. */ /** Setting Gearslip as the phone's caller ID and spam app - what lets it decline a ringing call. */
private val callScreeningRole = registerForActivityResult(StartActivityForResult()) { result -> private val callScreeningRole = registerForActivityResult(StartActivityForResult()) { result ->
if (result.resultCode != RESULT_OK) GearslipLog.w("calls: call screening role declined") if (result.resultCode != RESULT_OK) GearslipLog.w("calls: call screening role declined")
@@ -130,7 +144,6 @@ class GearslipActivity : ComponentActivity(), Projection {
// is both a bad first impression and unreliable: Android does not consistently show more // is both a bad first impression and unreliable: Android does not consistently show more
// than one system permission dialog fired back-to-back, which was silently dropping some. // than one system permission dialog fired back-to-back, which was silently dropping some.
enableEdgeToEdge() enableEdgeToEdge()
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
GearslipLog.init(this) GearslipLog.init(this)
GearslipLog.installCrashHandler() GearslipLog.installCrashHandler()
@@ -138,7 +151,18 @@ class GearslipActivity : ComponentActivity(), Projection {
app.seb3thehacker.gearslip.wireless.WirelessLink.onConnected = { socket -> app.seb3thehacker.gearslip.wireless.WirelessLink.onConnected = { socket ->
runOnUiThread { startWireless(socket) } runOnUiThread { startWireless(socket) }
} }
app.seb3thehacker.gearslip.wireless.WirelessLink.sessionWaiting = { runner?.waitingForVersion == true }
app.seb3thehacker.gearslip.wireless.WirelessAuto.showApproval = { chooser, address ->
runOnUiThread {
approvingCar = address
carApproval.launch(androidx.activity.result.IntentSenderRequest.Builder(chooser).build())
}
}
CarSettings.init(this) CarSettings.init(this)
// Before any car connects: CarServices only starts once the car shows Gearslip's screen,
// and a car that waits to know the phone before it shows it would never hear (issue #43).
app.seb3thehacker.gearslip.car.CarBluetooth.init(this)
app.seb3thehacker.gearslip.browser.BrowserSettings.init(this)
AppSettings.applyGearslipEnabled(this) AppSettings.applyGearslipEnabled(this)
// A capture that died without tidying up would leave notifications exposed to every // A capture that died without tidying up would leave notifications exposed to every
// later screen share. Nothing is capturing if the service is not running, so undo it. // later screen share. Nothing is capturing if the service is not running, so undo it.
@@ -208,6 +232,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)
@@ -216,6 +250,8 @@ class GearslipActivity : ComponentActivity(), Projection {
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
app.seb3thehacker.gearslip.wireless.WirelessLink.onConnected = null app.seb3thehacker.gearslip.wireless.WirelessLink.onConnected = null
app.seb3thehacker.gearslip.wireless.WirelessLink.sessionWaiting = null
app.seb3thehacker.gearslip.wireless.WirelessAuto.showApproval = null
runCatching { unregisterReceiver(detachReceiver) } runCatching { unregisterReceiver(detachReceiver) }
PhoneMirror.onStartRequested = null PhoneMirror.onStartRequested = null
PhoneMirror.onTokenReady = null PhoneMirror.onTokenReady = null
@@ -280,7 +316,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() }
} }
/** /**
@@ -304,6 +341,13 @@ 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) {
stepAsideOnVideo = !shownOnPhone
connectToNearbyCar(intent.getStringExtra(app.seb3thehacker.gearslip.wireless.WirelessAuto.EXTRA_ADDRESS))
return
}
if (intent?.action != UsbManager.ACTION_USB_ACCESSORY_ATTACHED) return if (intent?.action != UsbManager.ACTION_USB_ACCESSORY_ATTACHED) return
// Switched off in Settings: the system shouldn't route the car here at all, but if it // Switched off in Settings: the system shouldn't route the car here at all, but if it
// does, leave the accessory alone. // does, leave the accessory alone.
@@ -318,9 +362,43 @@ 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)
} }
/** A known car came up over Bluetooth: connect wirelessly, unless USB or another try is already on it. */
private fun connectToNearbyCar(address: String?) {
if (address == null) return
if (!AppSettings.gearslipEnabled(this)) {
GearslipLog.i("auto-connect: Gearslip is switched off - not connecting")
return
}
if (descriptor != null) {
GearslipLog.i("auto-connect: the car is on USB already - staying on USB")
return
}
// Only a session that's still running counts. One the car ended (a ByeBye, a dropped
// network) leaves its runner behind, since only Gearslip ending a session clears it, and
// that used to make every later auto-connect quietly do nothing.
if (worker?.isAlive == true || app.seb3thehacker.gearslip.wireless.WirelessLink.busy) {
GearslipLog.i("auto-connect: a session is already running")
return
}
if (runner != null) closeAccessory(stopWireless = false)
if (checkSelfPermission(android.Manifest.permission.BLUETOOTH_CONNECT) != android.content.pm.PackageManager.PERMISSION_GRANTED) {
GearslipLog.w("auto-connect: needs the Nearby devices permission")
return
}
val adapter = getSystemService(android.bluetooth.BluetoothManager::class.java)?.adapter
val device = adapter?.let { runCatching { it.getRemoteDevice(address) }.getOrNull() }
if (device == null) {
GearslipLog.w("auto-connect: Bluetooth couldn't hand over the car")
return
}
GearslipLog.i("auto-connect: connecting")
app.seb3thehacker.gearslip.wireless.WirelessLink.start(this, device)
}
private fun start(accessory: UsbAccessory) { private fun start(accessory: UsbAccessory) {
// A fresh ATTACHED intent means the system wants a new session, whatever state the old // A fresh ATTACHED intent means the system wants a new session, whatever state the old
// one is in - closing first guarantees openAccessory() below never fights a descriptor // one is in - closing first guarantees openAccessory() below never fights a descriptor
@@ -346,9 +424,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. */
@@ -368,9 +448,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) {
@@ -390,10 +486,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
@@ -403,6 +516,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) }
} }
@@ -419,6 +533,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,11 +47,109 @@ 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")
i("log file: ${file?.absolutePath}") i("log file: ${file?.absolutePath}")
SessionReport.init(context) SessionReport.init(context)
// One file per launch adds up; the newest are the ones anyone asks for.
io.execute { logFiles(dir).drop(KEEP_FILES).forEach { it.delete() } }
lastExit(context, dir)
}
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> =
dir.listFiles { f -> f.isFile && f.name.startsWith("gearslip-") && f.name.endsWith(".log") }
?.sortedByDescending { it.name }.orEmpty()
/** A log from an earlier launch: when it started, and what a glance at it tells. */
class PastLog(val file: File, val started: Date?, val crashed: Boolean, val car: String?)
/**
* Earlier launches' logs, newest first. Each launch starts a fresh file, so after a crash the
* one that saw it is here rather than on the live screen.
*/
fun pastLogs(context: Context): List<PastLog> {
val dir = context.getExternalFilesDir(null) ?: return emptyList()
val parse = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US)
return logFiles(dir).filter { it != file }.map { f ->
val text = runCatching { f.readText() }.getOrDefault("")
PastLog(
file = f,
started = runCatching { parse.parse(f.name.removePrefix("gearslip-").removeSuffix(".log")) }.getOrNull(),
crashed = "FATAL uncaught" in text || EXIT_MARK in text,
car = Regex("""car="([^"]+)"""").findAll(text).lastOrNull()?.groupValues?.get(1),
)
}
}
/** An earlier launch's log, copied to the cache dir for sharing like [shareableFile]. */
fun shareableFile(context: Context, past: File): android.net.Uri {
val dir = File(context.cacheDir, "shared").apply { mkdirs() }
dir.listFiles()?.forEach { it.delete() }
val out = File(dir, "gearslip-log-${past.name.removePrefix("gearslip-").removeSuffix(".log")}.txt")
past.copyTo(out, overwrite = true)
return androidx.core.content.FileProvider.getUriForFile(
context, "${context.packageName}.logshare", out,
)
} }
fun snapshot(): List<String> = synchronized(live) { live.toList() } fun snapshot(): List<String> = synchronized(live) { live.toList() }
@@ -133,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 {
File diff suppressed because it is too large. Load diff
@@ -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,49 @@
package app.seb3thehacker.gearslip
import android.app.Activity
import android.app.ActivityOptions
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.Display
/**
* Asks for runtime permissions on the phone's own screen, for a car screen that can't show
* Android's dialog itself. It has no look of its own: the dialog appears, the answer is given,
* and it finishes.
*/
class PermissionActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val wanted = intent.getStringArrayExtra(EXTRA_PERMISSIONS)
?.filter { checkSelfPermission(it) != android.content.pm.PackageManager.PERMISSION_GRANTED }
.orEmpty()
if (wanted.isEmpty() || savedInstanceState != null) {
if (wanted.isEmpty()) finish()
return
}
requestPermissions(wanted.toTypedArray(), REQUEST)
}
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
GearslipLog.i("permissions: ${permissions.zip(grantResults.toList()).joinToString { (p, r) -> "${p.substringAfterLast('.')}=${r == 0}" }}")
finish()
}
companion object {
private const val EXTRA_PERMISSIONS = "permissions"
private const val REQUEST = 1
/** Shows the permission dialog on the phone, whichever screen [context] belongs to. */
fun ask(context: Context, permissions: Array<String>) {
val intent = Intent(context, PermissionActivity::class.java)
.putExtra(EXTRA_PERMISSIONS, permissions)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
val options = ActivityOptions.makeBasic().setLaunchDisplayId(Display.DEFAULT_DISPLAY)
runCatching { context.startActivity(intent, options.toBundle()) }
.onFailure { GearslipLog.w("permissions: could not ask on the phone: ${it.message}") }
}
}
}
@@ -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 }
}
@@ -1,5 +1,6 @@
package app.seb3thehacker.gearslip package app.seb3thehacker.gearslip
import java.io.ByteArrayOutputStream
import java.net.Socket import java.net.Socket
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.security.KeyStore import java.security.KeyStore
@@ -11,23 +12,10 @@ import javax.net.ssl.SSLEngine
import javax.net.ssl.SSLEngineResult import javax.net.ssl.SSLEngineResult
import javax.net.ssl.SSLEngineResult.HandshakeStatus import javax.net.ssl.SSLEngineResult.HandshakeStatus
import javax.net.ssl.SSLPeerUnverifiedException import javax.net.ssl.SSLPeerUnverifiedException
import javax.net.ssl.SSLException
import javax.net.ssl.X509ExtendedTrustManager import javax.net.ssl.X509ExtendedTrustManager
/** /** TLS server for projection. Handshake records travel in control message 3, not a TLS socket. */
* The phone end of the Android Auto TLS session.
*
* Two things here are the opposite of what aasdk does, because aasdk implements the head
* unit and the head unit is the TLS *client*: it calls TLS_client_method() and
* SSL_set_connect_state (src/Transport/SSLWrapper.cpp:97-140, src/Messenger/Cryptor.cpp:87),
* and SSL_set_accept_state appears nowhere in that codebase. So the phone is the TLS
* **server** and its certificate goes out as the server certificate - sent unconditionally
* on every handshake, which is precisely what we are testing.
*
* TLS here is not layered over the socket: handshake bytes travel inside
* MESSAGE_ENCAPSULATED_SSL control messages (id 3). That rules out SSLSocket and makes
* SSLEngine - a buffer-to-buffer engine, the JSSE counterpart of aasdk's memory BIO pair -
* the right tool.
*/
class PhoneTls(keyStore: KeyStore, password: CharArray) { class PhoneTls(keyStore: KeyStore, password: CharArray) {
private val log = GearslipLog.tagged("TLS") private val log = GearslipLog.tagged("TLS")
@@ -36,7 +24,7 @@ class PhoneTls(keyStore: KeyStore, password: CharArray) {
private var appBuffer = ByteBuffer.allocate(32 * 1024) private var appBuffer = ByteBuffer.allocate(32 * 1024)
private var netBuffer = ByteBuffer.allocate(32 * 1024) private var netBuffer = ByteBuffer.allocate(32 * 1024)
var handshakeComplete: Boolean = false @Volatile var handshakeComplete: Boolean = false
private set private set
init { init {
@@ -52,6 +40,7 @@ class PhoneTls(keyStore: KeyStore, password: CharArray) {
useClientMode = false useClientMode = false
// want, not need: we ask the head unit for its certificate so the run also tells // want, not need: we ask the head unit for its certificate so the run also tells
// us what real units present, but we never make the session depend on getting one. // us what real units present, but we never make the session depend on getting one.
// Homemade head units may have none.
wantClientAuth = true wantClientAuth = true
enabledProtocols = arrayOf("TLSv1.2") // aasdk pins TLS 1.2 enabledProtocols = arrayOf("TLSv1.2") // aasdk pins TLS 1.2
} }
@@ -88,7 +77,7 @@ class PhoneTls(keyStore: KeyStore, password: CharArray) {
if (netBuffer.hasRemaining()) { if (netBuffer.hasRemaining()) {
outgoing += ByteArray(netBuffer.remaining()).also { netBuffer.get(it) } outgoing += ByteArray(netBuffer.remaining()).also { netBuffer.get(it) }
} }
if (result.status == SSLEngineResult.Status.CLOSED) break@loop if (result.status == SSLEngineResult.Status.CLOSED) throw SSLException("TLS closed during handshake")
} }
HandshakeStatus.NEED_UNWRAP -> { HandshakeStatus.NEED_UNWRAP -> {
@@ -100,7 +89,7 @@ class PhoneTls(keyStore: KeyStore, password: CharArray) {
appBuffer = ByteBuffer.allocate(appBuffer.capacity() * 2) appBuffer = ByteBuffer.allocate(appBuffer.capacity() * 2)
continue@loop continue@loop
} }
SSLEngineResult.Status.CLOSED -> break@loop SSLEngineResult.Status.CLOSED -> throw SSLException("TLS closed during handshake")
else -> Unit else -> Unit
} }
} }
@@ -124,7 +113,7 @@ class PhoneTls(keyStore: KeyStore, password: CharArray) {
/** Wraps a control-channel payload for an ENCRYPTED frame. */ /** Wraps a control-channel payload for an ENCRYPTED frame. */
fun encrypt(plain: ByteArray): ByteArray { fun encrypt(plain: ByteArray): ByteArray {
val source = ByteBuffer.wrap(plain) val source = ByteBuffer.wrap(plain)
val out = java.io.ByteArrayOutputStream() val out = ByteArrayOutputStream()
while (source.hasRemaining()) { while (source.hasRemaining()) {
netBuffer.clear() netBuffer.clear()
val result = engine.wrap(source, netBuffer) val result = engine.wrap(source, netBuffer)
@@ -132,6 +121,9 @@ class PhoneTls(keyStore: KeyStore, password: CharArray) {
netBuffer = ByteBuffer.allocate(netBuffer.capacity() * 2) netBuffer = ByteBuffer.allocate(netBuffer.capacity() * 2)
continue continue
} }
if (result.status != SSLEngineResult.Status.OK || result.bytesConsumed() == 0) {
throw SSLException("TLS could not encrypt application data: ${result.status}")
}
netBuffer.flip() netBuffer.flip()
val chunk = ByteArray(netBuffer.remaining()) val chunk = ByteArray(netBuffer.remaining())
netBuffer.get(chunk) netBuffer.get(chunk)
@@ -140,14 +132,10 @@ class PhoneTls(keyStore: KeyStore, password: CharArray) {
return out.toByteArray() return out.toByteArray()
} }
/** /** Each encrypted projection frame contains complete TLS records. */
* Unwraps one ENCRYPTED frame's payload. aasdk decrypts per frame
* (MessageOutStream::compoundFrame encrypts per frame), so each frame's payload is
* self-contained ciphertext.
*/
fun decrypt(cipher: ByteArray): ByteArray { fun decrypt(cipher: ByteArray): ByteArray {
val source = ByteBuffer.wrap(cipher) val source = ByteBuffer.wrap(cipher)
val out = java.io.ByteArrayOutputStream() val out = ByteArrayOutputStream()
while (source.hasRemaining()) { while (source.hasRemaining()) {
appBuffer.clear() appBuffer.clear()
val result = engine.unwrap(source, appBuffer) val result = engine.unwrap(source, appBuffer)
@@ -157,10 +145,9 @@ class PhoneTls(keyStore: KeyStore, password: CharArray) {
continue continue
} }
SSLEngineResult.Status.BUFFER_UNDERFLOW -> { SSLEngineResult.Status.BUFFER_UNDERFLOW -> {
log.w("decrypt: record split across frames (${source.remaining()} bytes left over)") throw SSLException("Incomplete TLS record in an encrypted frame")
break
} }
SSLEngineResult.Status.CLOSED -> break SSLEngineResult.Status.CLOSED -> throw SSLException("TLS peer closed the connection")
else -> Unit else -> Unit
} }
appBuffer.flip() appBuffer.flip()
@@ -8,7 +8,8 @@ object ProjectedContent {
/** /**
* Precedence: the URL saved in Settings, then a `url.txt` in the external files dir (the * Precedence: the URL saved in Settings, then a `url.txt` in the external files dir (the
* pre-Settings way, still honoured so existing setups keep working), then [DEFAULT_URL]. * pre-Settings way, still honoured so existing setups keep working). Blank opens the
* browser's own start page.
*/ */
fun resolve(context: Context): String { fun resolve(context: Context): String {
val saved = AppSettings.startupUrl(context) val saved = AppSettings.startupUrl(context)
@@ -25,12 +26,9 @@ object ProjectedContent {
return url return url
} }
} }
return DEFAULT_URL return ""
} }
/** What the Web app opens until a page is chosen in Settings. */
const val DEFAULT_URL = "https://duckduckgo.com"
/** A bare "example.com" is a URL the user meant; raw HTML is passed through untouched. */ /** A bare "example.com" is a URL the user meant; raw HTML is passed through untouched. */
private fun normalize(value: String): String = private fun normalize(value: String): String =
if (value.startsWith("<") || "://" in value) value else "https://$value" if (value.startsWith("<") || "://" in value) value else "https://$value"
@@ -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. */
@@ -2,14 +2,7 @@ package app.seb3thehacker.gearslip
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
/** /** Protobuf field encoding and diagnostic rendering for the projection protocol. */
* Just enough protobuf to run the spike, hand-rolled so there is no protoc step.
*
* Only three messages are ever written (AuthResponse is only read):
* ServiceDiscoveryRequest { optional string label_text = 4; optional string device_name = 5; }
* PingResponse { required int64 timestamp = 1; }
* AuthResponse { required int32 status = 1; }
*/
object Protobuf { object Protobuf {
fun varint(value: Long): ByteArray { fun varint(value: Long): ByteArray {
@@ -41,29 +34,7 @@ object Protobuf {
fun readInt32Field(data: ByteArray, field: Int): Int? = readInt64Field(data, field)?.toInt() fun readInt32Field(data: ByteArray, field: Int): Int? = readInt64Field(data, field)?.toInt()
/** Reads a varint field whole, for int64s such as a ping's timestamp. */ /** Reads a varint field whole, for int64s such as a ping's timestamp. */
fun readInt64Field(data: ByteArray, field: Int): Long? { fun readInt64Field(data: ByteArray, field: Int): Long? = Wire.varint(Wire.fields(data), field)
var pos = 0
while (pos < data.size) {
val (tag, afterTag) = readVarint(data, pos) ?: return null
pos = afterTag
val number = (tag ushr 3).toInt()
when ((tag and 7L).toInt()) {
0 -> {
val (value, afterValue) = readVarint(data, pos) ?: return null
pos = afterValue
if (number == field) return value
}
2 -> {
val (len, afterLen) = readVarint(data, pos) ?: return null
pos = afterLen + len.toInt()
}
5 -> pos += 4
1 -> pos += 8
else -> return null
}
}
return null
}
/** /**
* Shallow field dump. Used on ServiceDiscoveryResponse: its make/model/year strings * Shallow field dump. Used on ServiceDiscoveryResponse: its make/model/year strings
@@ -71,6 +42,7 @@ object Protobuf {
* proof that the session is live and we are decrypting correctly. * proof that the session is live and we are decrypting correctly.
*/ */
fun describe(data: ByteArray, indent: String = " "): String { fun describe(data: ByteArray, indent: String = " "): String {
if (indent.length > 32) return "$indent(nested message)\n"
val sb = StringBuilder() val sb = StringBuilder()
var pos = 0 var pos = 0
while (pos < data.size) { while (pos < data.size) {
@@ -89,6 +61,7 @@ object Protobuf {
} }
2 -> { 2 -> {
val (len, afterLen) = readVarint(data, pos) ?: break val (len, afterLen) = readVarint(data, pos) ?: break
if (len < 0 || len > data.size - afterLen) break
val end = afterLen + len.toInt() val end = afterLen + len.toInt()
if (end > data.size) break if (end > data.size) break
val body = data.copyOfRange(afterLen, end) val body = data.copyOfRange(afterLen, end)
@@ -117,6 +90,7 @@ object Protobuf {
var pos = start var pos = start
while (pos < data.size && shift <= 63) { while (pos < data.size && shift <= 63) {
val b = data[pos].toInt() and 0xFF val b = data[pos].toInt() and 0xFF
if (shift == 63 && b > 1) return null
result = result or ((b and 0x7F).toLong() shl shift) result = result or ((b and 0x7F).toLong() shl shift)
pos++ pos++
if (b and 0x80 == 0) return result to pos if (b and 0x80 == 0) return result to pos
@@ -135,45 +109,68 @@ object Wire {
val out = mutableListOf<Field>() val out = mutableListOf<Field>()
var pos = 0 var pos = 0
while (pos < data.size) { while (pos < data.size) {
val tag = readVarint(data, pos) ?: break val tag = readVarint(data, pos) ?: return emptyList()
pos = tag.second pos = tag.second
val number = (tag.first ushr 3).toInt() val number = (tag.first ushr 3).toInt()
if (tag.first ushr 3 !in 1..0x1fff_ffffL) return emptyList()
when ((tag.first and 7L).toInt()) { when ((tag.first and 7L).toInt()) {
0 -> { 0 -> {
val v = readVarint(data, pos) ?: break val v = readVarint(data, pos) ?: return emptyList()
pos = v.second pos = v.second
out += Field(number, 0, v.first, null) out += Field(number, 0, v.first, null)
} }
1 -> { if (pos + 8 > data.size) break; pos += 8 } 1 -> { if (data.size - pos < 8) return emptyList(); pos += 8 }
2 -> { 2 -> {
val len = readVarint(data, pos) ?: break val len = readVarint(data, pos) ?: return emptyList()
if (len.first < 0 || len.first > data.size - len.second) return emptyList()
val end = len.second + len.first.toInt() val end = len.second + len.first.toInt()
if (end > data.size || end < len.second) break if (end > data.size || end < len.second) return emptyList()
out += Field(number, 2, 0, data.copyOfRange(len.second, end)) out += Field(number, 2, 0, data.copyOfRange(len.second, end))
pos = end pos = end
} }
5 -> { if (pos + 4 > data.size) break; pos += 4 } 5 -> { if (data.size - pos < 4) return emptyList(); pos += 4 }
else -> break else -> return emptyList()
} }
} }
return out return out
} }
fun varint(fields: List<Field>, number: Int): Long? = fun varint(fields: List<Field>, number: Int): Long? =
fields.firstOrNull { it.number == number && it.wireType == 0 }?.varint fields.lastOrNull { it.number == number && it.wireType == 0 }?.varint
fun bytes(fields: List<Field>, number: Int): ByteArray? = fun bytes(fields: List<Field>, number: Int): ByteArray? =
fields.firstOrNull { it.number == number && it.wireType == 2 }?.bytes fields.lastOrNull { it.number == number && it.wireType == 2 }?.bytes
fun allBytes(fields: List<Field>, number: Int): List<ByteArray> = fun allBytes(fields: List<Field>, number: Int): List<ByteArray> =
fields.filter { it.number == number && it.wireType == 2 }.mapNotNull { it.bytes } fields.filter { it.number == number && it.wireType == 2 }.mapNotNull { it.bytes }
/** Protobuf readers accept both packed and unpacked repeated numeric fields. */
fun repeatedInts(fields: List<Field>, number: Int): List<Int> =
repeatedLongs(fields, number)?.map(Long::toInt).orEmpty()
/** Null means a packed field contained an incomplete or overflowing varint. */
fun repeatedLongs(fields: List<Field>, number: Int): List<Long>? = buildList {
for (field in fields.filter { it.number == number }) {
if (field.wireType == 0) add(field.varint)
else if (field.wireType == 2) {
val bytes = field.bytes ?: continue
var position = 0
while (position < bytes.size) {
val (value, next) = readVarint(bytes, position) ?: return null
add(value)
position = next
}
}
}
}
private fun readVarint(data: ByteArray, start: Int): Pair<Long, Int>? { private fun readVarint(data: ByteArray, start: Int): Pair<Long, Int>? {
var result = 0L var result = 0L
var shift = 0 var shift = 0
var pos = start var pos = start
while (pos < data.size && shift <= 63) { while (pos < data.size && shift <= 63) {
val b = data[pos].toInt() and 0xFF val b = data[pos].toInt() and 0xFF
if (shift == 63 && b > 1) return null
result = result or ((b and 0x7F).toLong() shl shift) result = result or ((b and 0x7F).toLong() shl shift)
pos++ pos++
if (b and 0x80 == 0) return result to pos if (b and 0x80 == 0) return result to pos
@@ -0,0 +1,17 @@
package app.seb3thehacker.gearslip
/**
* Advertises our supported 1.x version, as Android Auto does for older head units. Head units that
* offer a newer major version (LIVI asks for 6.1, a RAV4 and the DHU for 4.2) still accept a 1.x
* answer, so only a major version of 0 is refused.
*/
internal object ProtocolVersion {
data class Response(val major: Int, val minor: Int, val supported: Boolean) {
val bytes: ByteArray get() = byteArrayOf(
(major ushr 8).toByte(), major.toByte(), (minor ushr 8).toByte(), minor.toByte(),
if (supported) 0 else -1, if (supported) 0 else -1,
)
}
fun respond(major: Int, minor: Int): Response = Response(1, 7, major >= 1 && minor in 0..0xffff)
}
@@ -1,15 +1,18 @@
package app.seb3thehacker.gearslip package app.seb3thehacker.gearslip
import android.app.ActivityOptions import android.app.ActivityOptions
import android.app.LocaleManager
import android.app.Presentation import android.app.Presentation
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.res.Configuration
import android.graphics.Color import android.graphics.Color
import android.hardware.display.DisplayManager import android.hardware.display.DisplayManager
import android.hardware.display.VirtualDisplay import android.hardware.display.VirtualDisplay
import android.os.SystemClock import android.os.SystemClock
import android.view.MotionEvent import android.view.MotionEvent
import android.view.Surface import android.view.Surface
import android.view.ContextThemeWrapper
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -51,7 +54,7 @@ class ScreenProjector(private val context: Context) {
* preview passes "gearslip-preview". * preview passes "gearslip-preview".
*/ */
fun start( fun start(
surface: Surface, surface: Surface?,
width: Int, width: Int,
height: Int, height: Int,
densityDpi: Int, densityDpi: Int,
@@ -96,7 +99,7 @@ class ScreenProjector(private val context: Context) {
decor.setViewTreeViewModelStoreOwner(treeOwner) decor.setViewTreeViewModelStoreOwner(treeOwner)
decor.setBackgroundColor(Color.BLACK) decor.setBackgroundColor(Color.BLACK)
show.setContentView(ComposeView(show.context).apply { setContent(content) }) show.setContentView(ComposeView(withAppLanguage(show.context)).apply { setContent(content) })
show.show() show.show()
presentation = show presentation = show
@@ -106,6 +109,18 @@ class ScreenProjector(private val context: Context) {
GearslipLog.i("projecting ${width}x$height @ ${densityDpi}dpi") GearslipLog.i("projecting ${width}x$height @ ${densityDpi}dpi")
} }
/**
* [context] in the language the driver picked for Gearslip, when it differs from the phone's
* (issue #80). Android builds a virtual display's context from the system locale and leaves
* the per-app one out, so the car screen stayed in the phone's language.
*/
private fun withAppLanguage(context: Context): Context {
val locales = this.context.getSystemService(LocaleManager::class.java)?.applicationLocales
if (locales == null || locales.isEmpty) return context
val config = Configuration(context.resources.configuration).apply { setLocales(locales) }
return ContextThemeWrapper(context, context.theme).apply { applyOverrideConfiguration(config) }
}
/** The projected display's id, for placing other apps' activities on it. */ /** The projected display's id, for placing other apps' activities on it. */
val displayId: Int? get() = virtualDisplay?.display?.displayId val displayId: Int? get() = virtualDisplay?.display?.displayId
@@ -219,6 +234,19 @@ class ScreenProjector(private val context: Context) {
target.dispatchKeyEvent(event) target.dispatchKeyEvent(event)
} }
/**
* 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.
*/
/** 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?) {
virtualDisplay?.surface = surface
}
fun stop() { fun stop() {
runCatching { presentation?.dismiss() } runCatching { presentation?.dismiss() }
runCatching { owner?.destroy() } runCatching { owner?.destroy() }
@@ -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)"
} }
@@ -76,7 +85,7 @@ object ServiceDiscovery {
fun findAudioServices(response: ByteArray): List<AudioService> = buildList { fun findAudioServices(response: ByteArray): List<AudioService> = 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)
val id = Wire.varint(service, 1)?.toInt() ?: continue val id = serviceId(service) ?: continue
val sink = Wire.bytes(service, 3) ?: continue val sink = Wire.bytes(service, 3) ?: continue
val sinkFields = Wire.fields(sink) val sinkFields = Wire.fields(sink)
val audioConfigs = Wire.allBytes(sinkFields, 3) val audioConfigs = Wire.allBytes(sinkFields, 3)
@@ -116,13 +125,32 @@ object ServiceDiscovery {
*/ */
fun findMediaStatusChannel(response: ByteArray): Int? = findChannelWith(response, 9) fun findMediaStatusChannel(response: ByteArray): Int? = findChannelWith(response, 9)
/** The instrument cluster's turn-by-turn channel (NavigationStatusService, Service field 8). */ /**
fun findNavStatusChannel(response: ByteArray): Int? = findChannelWith(response, 8) * 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)
/** The cluster advertises its minimum update interval in NavigationStatusService field 1. */
fun findNavStatusService(response: ByteArray): NavStatusService? =
Wire.allBytes(Wire.fields(response), 1).firstNotNullOfOrNull { channel ->
val service = Wire.fields(channel)
val id = serviceId(service) ?: return@firstNotNullOfOrNull null
val navigation = Wire.bytes(service, 8) ?: return@firstNotNullOfOrNull null
val interval = Wire.varint(Wire.fields(navigation), 1) ?: return@firstNotNullOfOrNull null
if (interval !in 0L..Int.MAX_VALUE.toLong()) return@firstNotNullOfOrNull null
NavStatusService(id, interval.toInt())
}
private fun serviceId(fields: List<Wire.Field>): Int? =
Wire.varint(fields, 1)?.takeIf { it in 1L..255L }?.toInt()
private fun findChannelWith(response: ByteArray, serviceField: Int): Int? = private fun findChannelWith(response: ByteArray, serviceField: Int): Int? =
Wire.allBytes(Wire.fields(response), 1).firstNotNullOfOrNull { channel -> Wire.allBytes(Wire.fields(response), 1).firstNotNullOfOrNull { channel ->
val service = Wire.fields(channel) val service = Wire.fields(channel)
if (service.any { it.number == serviceField }) Wire.varint(service, 1)?.toInt() else null if (Wire.bytes(service, serviceField) != null) serviceId(service) else null
} }
/** /**
@@ -134,7 +162,7 @@ object ServiceDiscovery {
fun findMicService(response: ByteArray): AudioService? { fun findMicService(response: ByteArray): AudioService? {
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)
val id = Wire.varint(service, 1)?.toInt() ?: continue val id = serviceId(service) ?: continue
val source = Wire.fields(Wire.bytes(service, 5) ?: continue) val source = Wire.fields(Wire.bytes(service, 5) ?: continue)
val f = Wire.fields(Wire.bytes(source, 2) ?: continue) val f = Wire.fields(Wire.bytes(source, 2) ?: continue)
return AudioService( return AudioService(
@@ -172,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)
@@ -179,9 +214,7 @@ object ServiceDiscovery {
val fields = Wire.fields(Wire.bytes(service, 6) ?: continue) val fields = Wire.fields(Wire.bytes(service, 6) ?: continue)
val address = Wire.bytes(fields, 1)?.let { String(it, Charsets.UTF_8) } ?: continue val address = Wire.bytes(fields, 1)?.let { String(it, Charsets.UTF_8) } ?: continue
// Packed as declared, though a car may send each method as its own varint. // Packed as declared, though a car may send each method as its own varint.
val methods = fields.filter { it.number == 2 }.flatMap { f -> val methods = Wire.repeatedInts(fields, 2)
f.bytes?.let(::packedVarints) ?: listOf(f.varint.toInt())
}
return BluetoothService(id, address, methods) return BluetoothService(id, address, methods)
} }
return null return null
@@ -215,8 +248,8 @@ object ServiceDiscovery {
* repeated TouchScreen touchscreen = 2; ... } * repeated TouchScreen touchscreen = 2; ... }
* TouchScreen { required int32 width = 1; required int32 height = 2; type = 3; } * TouchScreen { required int32 width = 1; required int32 height = 2; type = 3; }
* *
* Touch coordinates arrive in this space (1258x708 on the 2018 Uconnect) and must be * Touch reports use projected video coordinates; these dimensions describe the input
* scaled to the projected video size before injection. [keycodesSupported] is what the * hardware and help select a video configuration. [keycodesSupported] is what the
* head unit says it will actually send over the Input channel as key events (steering-wheel * head unit says it will actually send over the Input channel as key events (steering-wheel
* buttons, etc.) - not every unit reports the same set, so it's kept for diagnostics and to * buttons, etc.) - not every unit reports the same set, so it's kept for diagnostics and to
* know ahead of time whether a button like voice/PTT is even wired up on this hardware. * know ahead of time whether a button like voice/PTT is even wired up on this hardware.
@@ -245,38 +278,18 @@ object ServiceDiscovery {
private fun inputServices(response: ByteArray): List<InputService> = buildList { private fun inputServices(response: ByteArray): List<InputService> = 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)
val id = Wire.varint(service, 1)?.toInt() ?: continue val id = serviceId(service) ?: continue
val input = Wire.bytes(service, 4) ?: continue val input = Wire.bytes(service, 4) ?: continue
val inputFields = Wire.fields(input) val inputFields = Wire.fields(input)
// A unit with only a knob and buttons has no touchscreen entry; it still has keys. // A unit with only a knob and buttons has no touchscreen entry; it still has keys.
val touchFields = Wire.bytes(inputFields, 2)?.let { Wire.fields(it) }.orEmpty() val touchFields = Wire.bytes(inputFields, 2)?.let { Wire.fields(it) }.orEmpty()
val width = Wire.varint(touchFields, 1)?.toInt() ?: 0 val width = Wire.varint(touchFields, 1)?.toInt() ?: 0
val height = Wire.varint(touchFields, 2)?.toInt() ?: 0 val height = Wire.varint(touchFields, 2)?.toInt() ?: 0
// Declared packed, but some units send each keycode as its own field. val keycodes = Wire.repeatedInts(inputFields, 1)
val keycodes = inputFields.filter { it.number == 1 }.flatMap { f ->
f.bytes?.let(::packedVarints) ?: listOf(f.varint.toInt())
}
add(InputService(id, width, height, keycodes, Wire.varint(inputFields, 5)?.toInt() ?: 0)) add(InputService(id, width, height, keycodes, Wire.varint(inputFields, 5)?.toInt() ?: 0))
} }
} }
/** Decodes a `[packed=true]` repeated varint field's raw bytes into individual values. */
private fun packedVarints(data: ByteArray): List<Int> = buildList {
var pos = 0
while (pos < data.size) {
var result = 0L
var shift = 0
while (pos < data.size) {
val b = data[pos].toInt() and 0xFF
result = result or ((b and 0x7F).toLong() shl shift)
pos++
if (b and 0x80 == 0) break
shift += 7
}
add(result.toInt())
}
}
/** /**
* The main screen's video sink. A head unit with a second screen, such as LIVI with a * The main screen's video sink. A head unit with a second screen, such as LIVI with a
* cluster display, offers a sink for each; the one without a display id (0) is the main one. * cluster display, offers a sink for each; the one without a display id (0) is the main one.
@@ -286,10 +299,23 @@ 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)
val id = Wire.varint(service, 1)?.toInt() ?: continue val id = serviceId(service) ?: continue
val sink = Wire.bytes(service, 3) ?: continue val sink = Wire.bytes(service, 3) ?: continue
val sinkFields = Wire.fields(sink) val sinkFields = Wire.fields(sink)
@@ -307,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()))
} }
} }
@@ -316,16 +342,15 @@ object ServiceDiscovery {
* SensorSourceService { repeated Sensor sensors = 1; } * SensorSourceService { repeated Sensor sensors = 1; }
* Sensor { required SensorType sensor_type = 1; } * Sensor { required SensorType sensor_type = 1; }
* *
* Field numbers for what actually rides inside each SensorEvent once subscribed are not * SensorBatch contains repeated messages in fields 1 through 22 matching these type ids.
* pinned down here - unlike video/audio/input, there's no independent source to check them * The vehicle data screen keeps their raw contents for diagnostics.
* against, so [CarSensors] shows the raw decode instead of trusting a guessed field map.
*/ */
class SensorService(val serviceId: Int, val types: List<Int>) class SensorService(val serviceId: Int, val types: List<Int>)
fun findSensorService(response: ByteArray): SensorService? { fun findSensorService(response: ByteArray): SensorService? {
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)
val id = Wire.varint(service, 1)?.toInt() ?: continue val id = serviceId(service) ?: continue
val sensorSource = Wire.bytes(service, 2) ?: continue val sensorSource = Wire.bytes(service, 2) ?: continue
val types = Wire.allBytes(Wire.fields(sensorSource), 1).mapNotNull { val types = Wire.allBytes(Wire.fields(sensorSource), 1).mapNotNull {
Wire.varint(Wire.fields(it), 1)?.toInt() Wire.varint(Wire.fields(it), 1)?.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")
@@ -42,6 +48,14 @@ class VideoSource(
private set private set
private var codec: MediaCodec? = null private var codec: MediaCodec? = null
/** The bitrate the encoder started at, the most [setBitRate] ever goes back up to. */
var fullBitRate = 0
private set
/** What the encoder is asked for right now. */
@Volatile var bitRate = 0
private set
private var worker: Thread? = null private var worker: Thread? = null
@Volatile private var running = false @Volatile private var running = false
private var frameIndex = 0L private var frameIndex = 0L
@@ -51,6 +65,8 @@ class VideoSource(
val bitRate = defaultBitRate(width, height, frameRate, level).let { val bitRate = defaultBitRate(width, height, frameRate, level).let {
if (wireless) (it * WIRELESS_SHARE).toInt().coerceAtMost(WIRELESS_MAX_BIT_RATE) else it if (wireless) (it * WIRELESS_SHARE).toInt().coerceAtMost(WIRELESS_MAX_BIT_RATE) else it
} }
fullBitRate = bitRate
this.bitRate = bitRate
fun format(tuned: Boolean) = MediaFormat.createVideoFormat(MIME, width, height).apply { fun format(tuned: Boolean) = MediaFormat.createVideoFormat(MIME, width, height).apply {
setInteger( setInteger(
MediaFormat.KEY_COLOR_FORMAT, MediaFormat.KEY_COLOR_FORMAT,
@@ -128,9 +144,28 @@ class VideoSource(
}.onFailure { log.w("requestSyncFrame failed: ${it.message}") } }.onFailure { log.w("requestSyncFrame failed: ${it.message}") }
} }
/**
* Changes the bitrate while streaming, without a new keyframe or a restart. Used to back off
* when the car (or the Wi-Fi to it) can't keep up, and to climb back once it can.
*/
fun setBitRate(bps: Int) {
val target = bps.coerceIn(fullBitRate / 4, fullBitRate)
if (target == bitRate) return
runCatching {
codec?.setParameters(Bundle().apply { putInt(MediaCodec.PARAMETER_KEY_VIDEO_BITRATE, target) })
bitRate = target
}.onFailure { log.w("setBitRate failed: ${it.message}") }
}
fun stop() { fun stop() {
running = false running = false
worker?.join(1_000) // Stop is called from threads that are themselves being shut down. An interrupt here
// used to escape as an uncaught exception and take the whole app down with the session.
try {
worker?.join(1_000)
} catch (e: InterruptedException) {
Thread.currentThread().interrupt()
}
runCatching { inputSurface?.release() } runCatching { inputSurface?.release() }
inputSurface = null inputSurface = null
runCatching { codec?.stop() } runCatching { codec?.stop() }
@@ -141,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.
@@ -179,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))
} }
@@ -227,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(),
)
}
} }
} }
@@ -157,6 +157,8 @@ class AudioCaptureService : Service() {
val startedAt = heardAt val startedAt = heardAt
var everHeard = false var everHeard = false
var warnedSilent = false var warnedSilent = false
var lastSound = startedAt
var silenceReported = false
try { try {
record.startRecording() record.startRecording()
GearslipLog.i("audio: capturing media playback at ${rate}Hz x$channels") GearslipLog.i("audio: capturing media playback at ${rate}Hz x$channels")
@@ -174,6 +176,14 @@ class AudioCaptureService : Service() {
} }
if (peak > 0) everHeard = true if (peak > 0) everHeard = true
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
// A stretch of silence: CarAudio says why on the car if a blocking app is playing.
if (peak > 0) {
lastSound = now
silenceReported = false
} else if (!silenceReported && now - lastSound >= SILENCE_MS) {
silenceReported = true
CarAudio.silent(this@AudioCaptureService)
}
val level = peak / 32768f val level = peak / 32768f
if (now - heardAt >= LEVEL_LOG_MS) { if (now - heardAt >= LEVEL_LOG_MS) {
GearslipLog.i("audio: peak %.3f".format(level)) GearslipLog.i("audio: peak %.3f".format(level))
@@ -4,6 +4,8 @@ import app.seb3thehacker.gearslip.GearslipLog
import app.seb3thehacker.gearslip.Protobuf import app.seb3thehacker.gearslip.Protobuf
import app.seb3thehacker.gearslip.ServiceDiscovery import app.seb3thehacker.gearslip.ServiceDiscovery
import app.seb3thehacker.gearslip.Wire import app.seb3thehacker.gearslip.Wire
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
/** /**
* The phone end of the head unit's media audio sink, the audio twin of the video channel: * The phone end of the head unit's media audio sink, the audio twin of the video channel:
@@ -25,12 +27,18 @@ class AudioLink(
/** Log name: "audio" for the media sink, "guidance" for the directions sink. */ /** Log name: "audio" for the media sink, "guidance" for the directions sink. */
private val tag: String = "audio", private val tag: String = "audio",
private val sessionId: Int = SESSION_ID, private val sessionId: Int = SESSION_ID,
/** Media asks the car for focus; directions just start, and the car ducks music under them. */ /**
* Media asks the car for focus when it starts. Directions ask only around each announcement,
* through [askForSpeakers], and only when the music isn't already ours.
*/
private val asksFocus: Boolean = true, private val asksFocus: Boolean = true,
private val onReady: (AudioLink) -> Unit = { CarAudio.attach(it) }, private val onReady: (AudioLink) -> Unit = { CarAudio.attach(it) },
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
@@ -38,15 +46,36 @@ 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
@Volatile private var lastAckAt = 0L @Volatile private var lastAckAt = 0L
/**
* Bytes of PCM per Data message, or 0 to send each capture read as it comes. Raised by
* [adaptChunkSize] for a car that acks fewer chunks than it plays.
*/
@Volatile private var chunkBytes = 0
/** Capture reads gathered until they fill [chunkBytes]. Only the capture thread touches it. */
private val pending = java.io.ByteArrayOutputStream()
/** Set by a new stream, so the capture thread drops what an older one left in [pending]. */
@Volatile private var pendingStale = false
/** The size of the last chunk sent, for [adaptChunkSize] to know what each one holds. */
@Volatile private var lastPayloadBytes = 0
/** Acks that came while the window was full, and the time they took: the car's own pace. */
private var slowAcks = 0
private var slowAckMs = 0L
private var lastAckFull = false
private var stalls = 0 private var stalls = 0
private var chunks = 0 private var chunks = 0
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
@@ -58,6 +87,65 @@ class AudioLink(
val channelId get() = service.serviceId val channelId get() = service.serviceId
/** Sending audio now, which for media means the car has handed us the speakers. */
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 focusAnswer: CountDownLatch? = null
@Volatile private var answerDueBy = 0L
/** True just after [askForSpeakers] or [handBackSpeakers], when the car's next focus answer is ours. */
fun expectsFocusAnswer() = System.currentTimeMillis() < answerDueBy
/**
* For directions when nothing of ours is playing, so the car's speakers belong to the radio
* or a Bluetooth phone. Android Auto asks for them before each announcement, letting the
* other source duck, and hands them back afterwards. Speaking without asking left a 2025
* Nissan's Bosch head unit rebooting mid-drive (issue #35).
*
* Waits briefly for the car's answer and speaks anyway without one, as some cars never answer.
*/
fun askForSpeakers() {
if (holdsSpeakers) return
val answer = CountDownLatch(1)
focusAnswer = answer
answerDueBy = System.currentTimeMillis() + FOCUS_WAIT_MS
holdsSpeakers = true
sendControl(MSG_FOCUS_REQUEST, Protobuf.varintField(1, FOCUS_GAIN_TRANSIENT_MAY_DUCK.toLong()))
GearslipLog.i("$tag: -> AudioFocusRequest(GAIN_TRANSIENT_MAY_DUCK)")
if (!answer.await(FOCUS_WAIT_MS, TimeUnit.MILLISECONDS)) {
GearslipLog.i("$tag: no focus answer after ${FOCUS_WAIT_MS}ms - speaking anyway")
}
focusAnswer = null
}
/** Gives back what [askForSpeakers] took, so the other source comes back to full volume. */
fun handBackSpeakers() {
if (!holdsSpeakers) return
holdsSpeakers = false
answerDueBy = System.currentTimeMillis() + FOCUS_WAIT_MS
runCatching { sendControl(MSG_FOCUS_REQUEST, Protobuf.varintField(1, FOCUS_RELEASE.toLong())) }
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) {
@@ -134,13 +222,25 @@ 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
val now = System.currentTimeMillis()
synchronized(ackLock) { synchronized(ackLock) {
// A full window means the sender is waiting on this ack. When it was waiting on
// the one before too, the gap between them is the car's own pace.
val full = inFlight >= maxUnacked
if (full && lastAckFull) {
slowAcks++
slowAckMs += now - lastAckAt
}
lastAckFull = full
inFlight = maxOf(0, inFlight - maxOf(1, acked)) inFlight = maxOf(0, inFlight - maxOf(1, acked))
lastAckAt = System.currentTimeMillis() lastAckAt = now
ackLock.notifyAll() ackLock.notifyAll()
} }
adaptChunkSize()
} }
// Whatever else the head unit answers Setup with, we need its bytes to identify it. // Whatever else the head unit answers Setup with, we need its bytes to identify it.
@@ -161,14 +261,97 @@ 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 || expectsFocusAnswer()) {
// Directions speak whatever the answer: a lost turn is worse than a loud one.
answerDueBy = 0L
focusAnswer?.countDown()
return
}
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")
}
}
/**
* A 2020 Civic acks a chunk about every 83 ms whatever the chunk holds, so 21 ms chunks
* reached it at a quarter of real time: a second of music, then silence, over and over
* (issue #43). When the car keeps the sender waiting and its acks come much further apart
* than the audio in each chunk, gather the capture into chunks that last at least that gap,
* rounded up to a power of two in frames: 83 ms is about 4096 frames at 48 kHz, which looks
* like the car's own buffer. A car that keeps up never fills the window, so it never gets here.
*/
private fun adaptChunkSize() {
if (aac != null) return
val interval = synchronized(ackLock) {
if (slowAcks < ADAPT_ACKS) return
(slowAckMs.toDouble() / slowAcks).also {
slowAcks = 0
slowAckMs = 0
}
}
val frame = channels * 2
val bytesPerMs = sampleRate * frame / 1000.0
val current = if (chunkBytes > 0) chunkBytes else lastPayloadBytes
if (current <= 0 || current >= MAX_CHUNK_BYTES) return
val heldMs = current / bytesPerMs
if (interval < heldMs * SLOW_ACK_RATIO) return
val gapFrames = Math.ceil(interval * sampleRate / 1000.0).toInt().coerceAtLeast(1)
val frames = Integer.highestOneBit(gapFrames).let { if (it < gapFrames) it shl 1 else it }
val target = minOf(MAX_CHUNK_BYTES / frame * frame, frames * frame)
if (target <= current) return
chunkBytes = target
GearslipLog.i(
"$tag: the car acks a chunk every ${interval.toInt()}ms, but each holds ${heldMs.toInt()}ms - " +
"sending $target-byte chunks (${(target / bytesPerMs).toInt()}ms) from now on",
)
}
/** Silence for [ms], whole frames of this sink's format. */
private fun silence(ms: Int): ByteArray = ByteArray(sampleRate * ms / 1000 * channels * 2)
override fun start() { override fun start() {
if (!ready || started) return if (!ready || started) return
if (asksFocus && !focusRequested) { if (asksFocus && !focusRequested) {
@@ -181,14 +364,38 @@ class AudioLink(
startStream() startStream()
} }
/**
* Guards starting and stopping. [start] starts the stream at once for head units that never
* answer the focus request, and the car's answer arrives on another thread and starts it
* too: without this both got through, so the car got Start twice and two AAC encoders, and a
* 2014 head unit dropped the connection on the first audio.
*/
private val startLock = Any()
private fun startStream() { private fun startStream() {
synchronized(startLock) { startStreamLocked() }
}
private fun startStreamLocked() {
if (!ready || started) return if (!ready || started) return
sendOnChannel( sendOnChannel(
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()
pendingStale = true
synchronized(ackLock) {
slowAcks = 0
slowAckMs = 0
lastAckFull = false
}
if (aacSink && aac == null) { if (aacSink && aac == null) {
aac = runCatching { AacEncoder(sampleRate, channels, adts = service.codecType == ServiceDiscovery.CODEC_AAC_LC_ADTS) } aac = runCatching { AacEncoder(sampleRate, channels, adts = service.codecType == ServiceDiscovery.CODEC_AAC_LC_ADTS) }
.onSuccess { GearslipLog.i("$tag: the car takes ${service.codecName} only - encoding to AAC") } .onSuccess { GearslipLog.i("$tag: the car takes ${service.codecName} only - encoding to AAC") }
@@ -200,27 +407,58 @@ class AudioLink(
} }
override fun stop() { override fun stop() {
synchronized(startLock) { 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)) if (pendingStale) {
pendingStale = false
pending.reset()
}
val want = chunkBytes
if (want <= 0 && pending.size() == 0) {
send(pcm.copyOf(size))
return
}
pending.write(pcm, 0, size)
if (pending.size() >= want) {
send(pending.toByteArray())
pending.reset()
}
return return
} }
val frames = try { val frames = try {
@@ -270,6 +508,7 @@ class AudioLink(
return return
} }
synchronized(ackLock) { inFlight++ } synchronized(ackLock) { inFlight++ }
lastPayloadBytes = payload.size
chunks++ chunks++
bytes += payload.size bytes += payload.size
// The first chunk proves the whole path; after that, an occasional line is enough to show // The first chunk proves the whole path; after that, an occasional line is enough to show
@@ -286,6 +525,7 @@ class AudioLink(
configTimer?.interrupt() configTimer?.interrupt()
configTimer = null configTimer = null
stop() stop()
handBackSpeakers()
ready = false ready = false
onClose(this) onClose(this)
} }
@@ -295,15 +535,21 @@ 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_RELEASE = 4 private const val FOCUS_RELEASE = 4
private const val FOCUS_WAIT_MS = 500L
private const val FOCUS_STATE_GAIN = 1 private const val FOCUS_STATE_GAIN = 1
private const val FOCUS_STATE_GAIN_TRANSIENT = 2 private const val FOCUS_STATE_GAIN_TRANSIENT = 2
private const val FOCUS_STATE_GAIN_MEDIA_ONLY = 6 private const val FOCUS_STATE_GAIN_MEDIA_ONLY = 6
@@ -311,6 +557,12 @@ class AudioLink(
private const val FOCUS_STATE_LOSS_TRANSIENT = 5 private const val FOCUS_STATE_LOSS_TRANSIENT = 5
private const val SESSION_ID = 2 private const val SESSION_ID = 2
private const val STALL_MS = 2_000L private const val STALL_MS = 2_000L
/** Acks measured with the window full before the chunk size is judged. */
private const val ADAPT_ACKS = 20
/** Acks this much further apart than a chunk lasts mean the car is the one holding back. */
private const val SLOW_ACK_RATIO = 1.5
/** About 170 ms at 48 kHz stereo: the most delay a bigger chunk may add. */
private const val MAX_CHUNK_BYTES = 32_768
private const val CHUNK_LOG_EVERY = 200 // about every four seconds private const val CHUNK_LOG_EVERY = 200 // about every four seconds
private const val CONFIG_WAIT_MS = 1_000L private const val CONFIG_WAIT_MS = 1_000L
@@ -44,11 +44,31 @@ object CarAudio {
@Volatile private var link: Link? = null @Volatile private var link: Link? = null
/** Opens the car's media channel, left closed while music went over Bluetooth; null once open. */
@Volatile var openSink: (() -> Unit)? = null
/** The car offers a music channel, open or waiting to be. */
val sinkOffered: Boolean get() = link != null || openSink != null
/** Rate and channel count the capture should use: the car's, or a sensible default. */ /** Rate and channel count the capture should use: the car's, or a sensible default. */
val sampleRate get() = link?.sampleRate ?: 48_000 val sampleRate get() = link?.sampleRate ?: 48_000
val channels get() = link?.channels ?: 2 val channels get() = link?.channels ?: 2
private val main by lazy { android.os.Handler(android.os.Looper.getMainLooper()) }
/**
* The car left and none came back: stop capturing, which ends the screen sharing Android
* shows for it and gives the phone its speaker back. Nothing else stopped it once the drive
* was over; with a wireless dongle Gearslip's screen stays open, so it ran on (issue #43).
*/
private val releaseWhenGone = Runnable {
if (link != null || _capture.value == Capture.OFF) return@Runnable
GearslipLog.i("audio: no car for ${CAR_GONE_MS / 1000}s - stopping the capture and screen sharing")
release()
}
fun attach(link: Link) { fun attach(link: Link) {
main.removeCallbacks(releaseWhenGone)
this.link = link this.link = link
GearslipLog.i("audio: the car offered a sink at ${link.sampleRate}Hz x${link.channels}") GearslipLog.i("audio: the car offered a sink at ${link.sampleRate}Hz x${link.channels}")
when { when {
@@ -57,10 +77,24 @@ object CarAudio {
} }
} }
/**
* The car's music channel closed. A car that comes straight back (a cable that slips) keeps
* the capture it had, so the driver isn't asked again mid-drive.
*/
fun detach(link: Link) { fun detach(link: Link) {
if (this.link === link) this.link = null if (this.link !== link) return
this.link = null
main.removeCallbacks(releaseWhenGone)
main.postDelayed(releaseWhenGone, CAR_GONE_MS)
} }
/**
* How long after the car leaves the capture waits for it to come back: a cable that slips, or a
* wireless link that drops and reconnects, which can take well over 3 seconds. Stopping sooner
* would have Android ask the driver to share the screen again in the middle of a drive.
*/
private const val CAR_GONE_MS = 30_000L
/** /**
* The car wants sound. Safe to call as often as the session likes: the consent dialog appears * The car wants sound. Safe to call as often as the session likes: the consent dialog appears
* at most once, and only once there is a sink to play through - asking on the bench, where the * at most once, and only once there is a sink to play through - asking on the bench, where the
@@ -71,6 +105,7 @@ object CarAudio {
when (_capture.value) { when (_capture.value) {
Capture.ON, Capture.ASKING, Capture.DENIED -> return Capture.ON, Capture.ASKING, Capture.DENIED -> return
Capture.OFF -> if (link == null) { Capture.OFF -> if (link == null) {
openSink?.invoke()
GearslipLog.i("audio: waiting for the car to offer an audio sink") GearslipLog.i("audio: waiting for the car to offer an audio sink")
} else { } else {
ask() ask()
@@ -89,9 +124,30 @@ object CarAudio {
} }
} }
/**
* Capture has heard nothing for a while. If a media app that blocks capture is the one
* playing, the car screen says why the music is silent, once a drive: Spotify does this.
*/
internal fun silent(context: android.content.Context) = android.os.Handler(android.os.Looper.getMainLooper()).post {
if (toldBlocked) return@post
val media = app.seb3thehacker.gearslip.car.CarServices.mediaOrNull() ?: return@post
if (!media.now.value.playing) return@post
val name = BLOCKS_CAPTURE[media.packageName] ?: return@post
toldBlocked = true
GearslipLog.i("audio: $name is playing and capture hears nothing: it blocks other apps from playing its sound")
app.seb3thehacker.gearslip.car.CarToasts.show(this, context.getString(app.seb3thehacker.gearslip.R.string.car_audio_blocked, name), androidx.car.app.CarToast.LENGTH_LONG)
}
@Volatile private var toldBlocked = false
/** Media apps known to keep other apps from capturing their sound, by package. */
private val BLOCKS_CAPTURE = mapOf("com.spotify.music" to "Spotify")
/** The car went away. Everything is torn down; the next connection asks again. */ /** The car went away. Everything is torn down; the next connection asks again. */
fun release() { fun release() {
main.removeCallbacks(releaseWhenGone)
wanted = false wanted = false
toldBlocked = false
AudioCaptureService.stop() AudioCaptureService.stop()
_capture.value = Capture.OFF _capture.value = Capture.OFF
} }
@@ -133,6 +189,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,212 @@
package app.seb3thehacker.gearslip.browser
import android.content.Context
import android.content.SharedPreferences
import java.net.URLEncoder
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.json.JSONArray
import org.json.JSONObject
/** A page saved to the browser's start page. */
data class Bookmark(val title: String, val url: String) {
/** The host without "www.", for labels and icons. */
val host: String get() = runCatching { android.net.Uri.parse(url).host.orEmpty() }.getOrDefault("").removePrefix("www.")
}
/** Where typed searches go. [query] is the address with the search terms appended, encoded. */
enum class SearchEngine(val id: String, val label: String, private val query: String) {
DUCKDUCKGO("duckduckgo", "DuckDuckGo", "https://duckduckgo.com/?q="),
STARTPAGE("startpage", "Startpage", "https://www.startpage.com/do/search?q="),
BRAVE("brave", "Brave Search", "https://search.brave.com/search?q="),
ECOSIA("ecosia", "Ecosia", "https://www.ecosia.org/search?q="),
GOOGLE("google", "Google", "https://www.google.com/search?q="),
BING("bing", "Bing", "https://www.bing.com/search?q=");
fun search(text: String): String = query + URLEncoder.encode(text, "UTF-8")
}
/**
* A filter list the ad blocker can use: one of the two it ships with, or one the driver added by
* its address. [id] names the cached copy on disk.
*/
data class FilterList(val id: String, val title: String, val url: String, val builtIn: Boolean, val enabled: Boolean)
/**
* The browser's own settings: bookmarks, the search engine, and the ad blocker's switches and
* lists. Kept in a preferences file of their own and mirrored into flows, so the car's browser
* and the phone's settings show the same thing. The ad blocker reads only the flows' values,
* never the file, since it answers on WebView's request thread (see [adblock.AdBlockManager]).
*/
object BrowserSettings {
private const val FILE = "browser"
private const val KEY_BOOKMARKS = "bookmarks"
private const val KEY_SEARCH = "search_engine"
private const val KEY_ADBLOCK = "adblock"
private const val KEY_YOUTUBE = "adblock_youtube"
private const val KEY_LISTS_OFF = "adblock_lists_off"
private const val KEY_CUSTOM_LISTS = "adblock_custom_lists"
private const val KEY_LAST_CHECK = "adblock_last_check"
/** The lists Gearslip ships a snapshot of, on unless turned off. */
val BUILT_IN_LISTS = listOf(
FilterList("easylist", "EasyList", "https://easylist.to/easylist/easylist.txt", builtIn = true, enabled = true),
FilterList("easyprivacy", "EasyPrivacy", "https://easylist.to/easylist/easyprivacy.txt", builtIn = true, enabled = true),
)
/** What a new install's start page offers, until the driver changes it. */
private val DEFAULT_BOOKMARKS = listOf(
Bookmark("YouTube", "https://m.youtube.com"),
Bookmark("Wikipedia", "https://en.m.wikipedia.org"),
)
private val bookmarksFlow = MutableStateFlow<List<Bookmark>>(emptyList())
private val searchFlow = MutableStateFlow(SearchEngine.DUCKDUCKGO)
private val adBlockFlow = MutableStateFlow(true)
private val youTubeFlow = MutableStateFlow(true)
private val listsFlow = MutableStateFlow(BUILT_IN_LISTS)
val bookmarks: StateFlow<List<Bookmark>> = bookmarksFlow
val searchEngine: StateFlow<SearchEngine> = searchFlow
val adBlock: StateFlow<Boolean> = adBlockFlow
val youTubeAdBlock: StateFlow<Boolean> = youTubeFlow
val filterLists: StateFlow<List<FilterList>> = listsFlow
@Volatile private var prefs: SharedPreferences? = null
/** Loads everything once per process. Safe to call from anywhere that has a context. */
fun init(context: Context) {
if (prefs != null) return
synchronized(this) {
if (prefs != null) return
val p = context.applicationContext.getSharedPreferences(FILE, Context.MODE_PRIVATE)
bookmarksFlow.value = if (p.contains(KEY_BOOKMARKS)) readBookmarks(p.getString(KEY_BOOKMARKS, "[]").orEmpty()) else DEFAULT_BOOKMARKS
searchFlow.value = SearchEngine.entries.firstOrNull { it.id == p.getString(KEY_SEARCH, null) } ?: SearchEngine.DUCKDUCKGO
adBlockFlow.value = p.getBoolean(KEY_ADBLOCK, true)
youTubeFlow.value = p.getBoolean(KEY_YOUTUBE, true)
listsFlow.value = readLists(p)
prefs = p
}
}
// Bookmarks
fun isBookmarked(url: String?): Boolean = url != null && bookmarksFlow.value.any { same(it.url, url) }
/** Adds [url], or renames it when it's already saved. Returns false for something that isn't a web address. */
fun addBookmark(title: String, url: String): Boolean {
val clean = url.trim()
if (!clean.startsWith("http://") && !clean.startsWith("https://")) return false
val name = title.trim().ifEmpty { Bookmark("", clean).host.ifEmpty { clean } }
val list = bookmarksFlow.value
val existing = list.indexOfFirst { same(it.url, clean) }
saveBookmarks(if (existing >= 0) list.toMutableList().also { it[existing] = Bookmark(name, list[existing].url) } else list + Bookmark(name, clean))
return true
}
fun removeBookmark(url: String) = saveBookmarks(bookmarksFlow.value.filterNot { same(it.url, url) })
/** Moves the bookmark at [from] to [to], for reordering from the phone. */
fun moveBookmark(from: Int, to: Int) {
val list = bookmarksFlow.value.toMutableList()
if (from !in list.indices || to !in list.indices) return
list.add(to, list.removeAt(from))
saveBookmarks(list)
}
/** The same page whatever the trailing slash or scheme. */
private fun same(a: String, b: String): Boolean {
fun key(u: String) = u.trim().removePrefix("https://").removePrefix("http://").removePrefix("www.").trimEnd('/')
return key(a).equals(key(b), ignoreCase = true)
}
private fun saveBookmarks(list: List<Bookmark>) {
bookmarksFlow.value = list
val array = JSONArray()
list.forEach { array.put(JSONObject().put("title", it.title).put("url", it.url)) }
prefs?.edit()?.putString(KEY_BOOKMARKS, array.toString())?.apply()
}
private fun readBookmarks(json: String): List<Bookmark> = runCatching {
val array = JSONArray(json)
(0 until array.length()).map { array.getJSONObject(it) }.map { Bookmark(it.optString("title"), it.optString("url")) }
.filter { it.url.isNotEmpty() }
}.getOrDefault(emptyList())
// Search
fun setSearchEngine(engine: SearchEngine) {
searchFlow.value = engine
prefs?.edit()?.putString(KEY_SEARCH, engine.id)?.apply()
}
/** Something that looks like an address opens as one; anything else is searched for. */
fun addressToUrl(typed: String): String {
val text = typed.trim()
return when {
text.startsWith("http://") || text.startsWith("https://") -> text
' ' !in text && '.' in text -> "https://$text"
else -> searchFlow.value.search(text)
}
}
// Ad blocking
fun setAdBlock(on: Boolean) {
adBlockFlow.value = on
prefs?.edit()?.putBoolean(KEY_ADBLOCK, on)?.apply()
}
fun setYouTubeAdBlock(on: Boolean) {
youTubeFlow.value = on
prefs?.edit()?.putBoolean(KEY_YOUTUBE, on)?.apply()
}
fun setListEnabled(id: String, on: Boolean) {
val p = prefs ?: return
val off = p.getStringSet(KEY_LISTS_OFF, emptySet()).orEmpty().toMutableSet()
if (on) off.remove(id) else off.add(id)
p.edit().putStringSet(KEY_LISTS_OFF, off).apply()
listsFlow.value = readLists(p)
}
/** Adds a list by its address. Returns false for something that isn't one, or one already there. */
fun addCustomList(url: String): Boolean {
val p = prefs ?: return false
val clean = url.trim()
if (!clean.startsWith("https://") && !clean.startsWith("http://")) return false
if (listsFlow.value.any { it.url.equals(clean, ignoreCase = true) }) return false
p.edit().putString(KEY_CUSTOM_LISTS, (customUrls(p) + clean).joinToString("\n")).apply()
listsFlow.value = readLists(p)
return true
}
fun removeCustomList(id: String) {
val p = prefs ?: return
val list = listsFlow.value.firstOrNull { it.id == id && !it.builtIn } ?: return
p.edit().putString(KEY_CUSTOM_LISTS, customUrls(p).filterNot { it == list.url }.joinToString("\n")).apply()
listsFlow.value = readLists(p)
}
var lastUpdateCheck: Long
get() = prefs?.getLong(KEY_LAST_CHECK, 0L) ?: 0L
set(value) { prefs?.edit()?.putLong(KEY_LAST_CHECK, value)?.apply() }
private fun customUrls(p: SharedPreferences) = p.getString(KEY_CUSTOM_LISTS, "").orEmpty().lines().filter { it.isNotBlank() }
private fun readLists(p: SharedPreferences): List<FilterList> {
val off = p.getStringSet(KEY_LISTS_OFF, emptySet()).orEmpty()
val custom = customUrls(p).map { url ->
val name = runCatching { android.net.Uri.parse(url).lastPathSegment }.getOrNull()?.takeIf { it.isNotBlank() } ?: url
FilterList(customId(url), name, url, builtIn = false, enabled = true)
}
return (BUILT_IN_LISTS + custom).map { it.copy(enabled = it.id !in off) }
}
/** A stable file name for a list known only by its address. */
private fun customId(url: String): String {
val digest = java.security.MessageDigest.getInstance("SHA-256").digest(url.toByteArray())
return "custom-" + digest.take(8).joinToString("") { "%02x".format(it) }
}
}
@@ -0,0 +1,31 @@
package app.seb3thehacker.gearslip.browser
import android.app.Activity
import android.content.Intent
import android.os.Bundle
import android.widget.Toast
import app.seb3thehacker.gearslip.R
/**
* The "Add to Gearslip" entry in Android's share sheet. A page shared from any browser becomes a
* tile on the car browser's start page, so nobody has to type an address on the car's keyboard.
* It shows nothing of its own: a toast says whether it worked, and it closes.
*/
class ShareToBrowserActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
BrowserSettings.init(this)
val text = intent?.takeIf { it.action == Intent.ACTION_SEND }?.getStringExtra(Intent.EXTRA_TEXT).orEmpty()
// Browsers share the bare address; some apps wrap it in a sentence, so take the first link.
val url = URL_PATTERN.find(text)?.value
val title = intent?.getStringExtra(Intent.EXTRA_SUBJECT) ?: intent?.getStringExtra(Intent.EXTRA_TITLE) ?: ""
val added = url != null && BrowserSettings.addBookmark(title, url)
Toast.makeText(this, getString(if (added) R.string.browser_shared_added else R.string.browser_shared_not_url), Toast.LENGTH_SHORT).show()
finish()
}
private companion object {
val URL_PATTERN = Regex("""https?://\S+""")
}
}
@@ -0,0 +1,91 @@
package app.seb3thehacker.gearslip.browser
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import app.seb3thehacker.gearslip.GearslipLog
import java.io.File
import java.net.HttpURLConnection
import java.net.URL
import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* Each site's icon for the start page's tiles, kept on disk by host. The browser saves the icon
* a page hands it while loading ([save]); a bookmark that hasn't been opened yet asks its own
* site once ([fetch]), for its touch icon and then its favicon. Nothing goes to a third-party
* icon service, so no list of the driver's bookmarks leaves the phone.
*/
object SiteIcons {
private const val DIRECTORY = "site-icons"
/** Smaller than this is a 16px favicon that would look blurry on a tile; a bigger one wins. */
private const val GOOD_ENOUGH_PX = 96
private val memory = ConcurrentHashMap<String, Bitmap>()
private val asked = ConcurrentHashMap.newKeySet<String>()
private val changed = MutableStateFlow(0)
/** Bumps whenever an icon is saved, so tiles look again. */
val version: StateFlow<Int> = changed
fun icon(context: Context, host: String): Bitmap? {
if (host.isEmpty()) return null
memory[host]?.let { return it }
val file = file(context, host)
if (!file.isFile) return null
return runCatching { BitmapFactory.decodeFile(file.path) }.getOrNull()?.also { memory[host] = it }
}
/** An icon the page gave the browser. Kept when it's bigger than the one already saved. */
fun save(context: Context, host: String, bitmap: Bitmap) {
if (host.isEmpty()) return
val current = icon(context, host)
if (current != null && current.width >= bitmap.width) return
store(context, host, bitmap)
}
/** Asks [host] for its icon in the background, once per run, when none is saved or it's small. */
fun fetch(context: Context, host: String) {
if (host.isEmpty() || !asked.add(host)) return
val current = icon(context, host)
if (current != null && current.width >= GOOD_ENOUGH_PX) return
val app = context.applicationContext
Thread({
for (path in listOf("/apple-touch-icon.png", "/favicon.ico")) {
val bitmap = runCatching { download("https://$host$path") }.getOrNull() ?: continue
if (current == null || bitmap.width > current.width) store(app, host, bitmap)
return@Thread
}
}, "site-icon").apply { isDaemon = true }.start()
}
private fun store(context: Context, host: String, bitmap: Bitmap) {
memory[host] = bitmap
runCatching {
val file = file(context, host)
file.parentFile?.mkdirs()
file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) }
}.onFailure { GearslipLog.w("browser: couldn't save the icon for $host: ${it.message}") }
changed.value++
}
private fun download(url: String): Bitmap? {
val connection = (URL(url).openConnection() as HttpURLConnection).apply {
connectTimeout = 10_000
readTimeout = 10_000
instanceFollowRedirects = true
}
return try {
if (connection.responseCode !in 200..299) return null
val bytes = connection.inputStream.use { it.readBytes() }
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
} finally {
connection.disconnect()
}
}
private fun file(context: Context, host: String) =
File(File(context.filesDir, DIRECTORY), host.replace(Regex("[^A-Za-z0-9.-]"), "_") + ".png")
}
@@ -0,0 +1,417 @@
package app.seb3thehacker.gearslip.browser.adblock
import android.content.Context
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.util.Base64
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import app.seb3thehacker.gearslip.GearslipLog
import app.seb3thehacker.gearslip.browser.BrowserSettings
import app.seb3thehacker.gearslip.browser.FilterList
import java.io.ByteArrayInputStream
import java.io.File
import java.net.HttpURLConnection
import java.net.URL
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong
/**
* Owns the filter lists: downloading them, keeping a parsed [FilterEngine] in memory, and
* answering the two questions the WebView asks: block this request, and what to hide on
* this page.
*
* Everything the request path touches is cached in memory. `shouldInterceptRequest` runs on a
* background thread for every subresource on the page, so it must never read preferences or
* touch disk. The switches come from [BrowserSettings]'s flows, which are plain memory reads.
*
* Downloading a list contacts the server it comes from (easylist.to for the two built in), and
* nothing else: no page the driver visits is ever sent anywhere.
*/
object AdBlockManager {
sealed class RefreshResult {
data class Success(val networkRules: Int, val cosmeticRules: Int) : RefreshResult()
data class Failure(val message: String) : RefreshResult()
}
private const val DIRECTORY = "adblock"
private const val BUNDLED_ASSET = "adblock/bundled.txt"
private const val MAX_LIST_BYTES = 12L * 1024 * 1024
/**
* How stale the lists may get before the browser refreshes them on its own. EasyList
* publishes several times a day, but the parts that matter, the domains ads are served from,
* turn over slowly, and each update costs a few megabytes.
*/
private const val UPDATE_INTERVAL_MS = 7L * 24 * 60 * 60 * 1000
// 1x1 transparent GIF, returned for blocked images so pages show a gap rather than a
// broken-image icon.
private const val TRANSPARENT_GIF_BASE64 =
"R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
private val EMPTY = ByteArray(0)
private val EMPTY_JSON = "{}".toByteArray()
val transparentGifBytes: ByteArray by lazy {
Base64.decode(TRANSPARENT_GIF_BASE64, Base64.DEFAULT)
}
@Volatile
private var engine: FilterEngine? = null
/** No per-site allowlist yet; kept so the request path reads the same as AABrowser's. */
private val allowlist: Set<String> = emptySet()
private val enabled: Boolean get() = BrowserSettings.adBlock.value
private val youTubeEnabled: Boolean get() = BrowserSettings.youTubeAdBlock.value
private val loadStarted = AtomicBoolean(false)
private val refreshRunning = AtomicBoolean(false)
private val autoUpdateAttempted = AtomicBoolean(false)
private val blockedCount = AtomicLong(0)
// Time spent deciding requests since the last page load, for the log line that tells whether
// the blocker is what makes a page or a video slow to start.
private val checkedCount = AtomicLong(0)
private val checkedNanos = AtomicLong(0)
private val blockedSincePage = AtomicLong(0)
val isEngineReady: Boolean get() = engine != null
val blockedRequestCount: Long get() = blockedCount.get()
val networkRuleCount: Int get() = engine?.networkRuleCount ?: 0
val cosmeticRuleCount: Int get() = engine?.cosmeticRuleCount ?: 0
/** Loads the cached lists from disk once per process, on a background thread. */
fun ensureInitialized(context: Context) {
BrowserSettings.init(context)
if (!enabled) return
if (!loadStarted.compareAndSet(false, true)) return
val appContext = context.applicationContext
Thread({
runCatching { loadFromDisk(appContext) }
.onFailure { GearslipLog.w("adblock: could not load the filter lists: ${it.message}") }
}, "adblock-load").apply {
// Blocking does nothing until this finishes, so it gets a normal share of the CPU.
// On a Pixel 6 a release build parses both lists in about 250ms.
priority = Thread.NORM_PRIORITY
start()
}
}
/** Rebuilds the engine from what's on disk after a list was turned on or off. */
fun reload(context: Context) {
val appContext = context.applicationContext
loadStarted.set(true)
Thread({ runCatching { loadFromDisk(appContext) } }, "adblock-load").start()
}
fun lastUpdatedAt(context: Context): Long =
BrowserSettings.filterLists.value.mapNotNull { cacheFile(context, it).takeIf { file -> file.length() > 0 } }
.maxOfOrNull { it.lastModified() } ?: 0L
private fun cacheFile(context: Context, source: FilterList): File =
File(File(context.filesDir, DIRECTORY), "${source.id}.txt")
private fun loadFromDisk(context: Context) {
val lists = BrowserSettings.filterLists.value.filter { it.enabled }
val files = lists.map { cacheFile(context, it) }.filter { it.length() > 0 }
val builder = FilterEngine.Builder()
// A built-in list that hasn't downloaded yet comes from the snapshot in the APK, so
// blocking works on the very first page rather than only after a download.
val needsBundled = lists.any { it.builtIn && cacheFile(context, it).length() == 0L }
if (needsBundled) {
context.assets.open(BUNDLED_ASSET).bufferedReader(Charsets.UTF_8).use { reader ->
while (true) {
val line = reader.readLine() ?: break
builder.addLine(line)
}
}
}
for (file in files) {
file.bufferedReader(bufferSize = 64 * 1024).use { reader ->
while (true) {
val line = reader.readLine() ?: break
builder.addLine(line)
}
}
}
engine = builder.finish()
GearslipLog.i("adblock: ${networkRuleCount} network and ${cosmeticRuleCount} hiding rules from ${lists.size} lists")
}
/** Downloads every list that's on, replaces the cached copies, and rebuilds the engine. */
fun refreshLists(context: Context): RefreshResult {
// The automatic refresh and the button in settings can both land here; two downloads
// writing the same files would leave one of them truncated.
val appContext = context.applicationContext
if (!refreshRunning.compareAndSet(false, true)) {
return RefreshResult.Failure("An update is already running.")
}
return try {
refresh(appContext).also {
// A manual update counts as a check, so the automatic one waits a full interval.
if (it is RefreshResult.Success) BrowserSettings.lastUpdateCheck = System.currentTimeMillis()
}
} finally {
refreshRunning.set(false)
}
}
/**
* Updates the lists when they have gone stale and the phone is on Wi-Fi, on a background
* thread. Runs when the car's browser opens; there is no scheduled job. Only one attempt per
* process, and the time is written on success alone, so a phone that was offline tries again
* next time rather than in a week.
*/
fun refreshIfDue(context: Context) {
val appContext = context.applicationContext
BrowserSettings.init(appContext)
if (!enabled || !isUpdateDue(appContext) || !onUnmeteredNetwork(appContext)) return
if (!autoUpdateAttempted.compareAndSet(false, true)) return
Thread({
val result = refreshLists(appContext)
GearslipLog.i("adblock: weekly list update - $result")
}, "adblock-update").start()
}
private fun isUpdateDue(context: Context): Boolean {
val now = System.currentTimeMillis()
val lastCheck = BrowserSettings.lastUpdateCheck
if (lastCheck > 0 && now - lastCheck < UPDATE_INTERVAL_MS) return false
val baseline = lastUpdatedAt(context)
// Nothing downloaded yet: the snapshot in the APK may be months old, so fetch now.
if (baseline <= 0L) return true
// A clock that has jumped backwards would otherwise freeze the lists indefinitely.
if (baseline > now) return true
return now - baseline >= UPDATE_INTERVAL_MS
}
private fun onUnmeteredNetwork(context: Context): Boolean {
val manager = context.getSystemService(ConnectivityManager::class.java) ?: return false
val caps = manager.getNetworkCapabilities(manager.activeNetwork) ?: return false
return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
}
private fun refresh(context: Context): RefreshResult {
val directory = File(context.filesDir, DIRECTORY)
if (!directory.exists() && !directory.mkdirs()) {
return RefreshResult.Failure("Could not create the filter list folder.")
}
for (source in BrowserSettings.filterLists.value.filter { it.enabled }) {
val result = runCatching { download(source, cacheFile(context, source)) }
val error = result.exceptionOrNull()
if (error != null) return RefreshResult.Failure("${source.title}: ${error.message ?: "download failed"}")
val failure = result.getOrNull()
if (failure != null) return RefreshResult.Failure("${source.title}: $failure")
}
loadStarted.set(true)
val loadError = runCatching { loadFromDisk(context) }.exceptionOrNull()
if (loadError != null) return RefreshResult.Failure(loadError.message ?: "Could not read the lists.")
val current = engine ?: return RefreshResult.Failure("The lists downloaded but contained no rules.")
return RefreshResult.Success(current.networkRuleCount, current.cosmeticRuleCount)
}
/** Returns null on success, or a human-readable reason for failure. */
private fun download(source: FilterList, destination: File): String? {
val connection = (URL(source.url).openConnection() as HttpURLConnection).apply {
connectTimeout = 15_000
readTimeout = 30_000
setRequestProperty("Accept", "text/plain")
}
try {
if (connection.responseCode !in 200..299) return "HTTP ${connection.responseCode}"
val temporary = File(destination.parentFile, destination.name + ".tmp")
var total = 0L
temporary.outputStream().use { output ->
connection.inputStream.use { input ->
val buffer = ByteArray(16 * 1024)
while (true) {
val read = input.read(buffer)
if (read < 0) break
total += read
if (total > MAX_LIST_BYTES) {
temporary.delete()
return "list is unexpectedly large"
}
output.write(buffer, 0, read)
}
}
}
if (total == 0L) {
temporary.delete()
return "empty response"
}
if (!temporary.renameTo(destination)) {
temporary.delete()
return "could not save the list"
}
} finally {
connection.disconnect()
}
return null
}
/** Deletes the cached copy of a list the driver removed. */
fun forget(context: Context, list: FilterList) {
cacheFile(context, list).delete()
}
fun isAllowlisted(host: String?): Boolean {
if (host.isNullOrEmpty()) return false
val current = allowlist
if (current.isEmpty()) return false
val normalized = host.lowercase()
return current.any { DomainUtils.hostMatchesDomain(normalized, it) }
}
/**
* Decides a single subresource request and, if it should be blocked, builds the response for
* it. [pageUrl] is the document the request belongs to, tracked by the WebView client because
* a WebView's own url can only be read on the UI thread. Null means let the request through.
*/
fun response(request: WebResourceRequest, pageUrl: String?): WebResourceResponse? {
if (!enabled) return null
// A ranged request is a video or audio stream fetching its next piece: ads never come
// that way, and a stream makes hundreds of them, so they skip the lists entirely.
if (request.requestHeaders?.keys?.any { it.equals("Range", ignoreCase = true) } == true) return null
val started = System.nanoTime()
try {
return decide(request, pageUrl)
} finally {
checkedCount.incrementAndGet()
checkedNanos.addAndGet(System.nanoTime() - started)
}
}
/** Logs what the blocker cost the page that just finished loading, and starts counting afresh. */
fun logPageCost(url: String?) {
val count = checkedCount.getAndSet(0)
val ms = checkedNanos.getAndSet(0) / 1_000_000
val blocked = blockedSincePage.getAndSet(0)
if (count > 0) GearslipLog.i("adblock: $count requests checked in ${ms}ms, $blocked blocked, for ${url?.substringBefore('?')}")
}
private fun decide(request: WebResourceRequest, pageUrl: String?): WebResourceResponse? {
val current = engine ?: return null
// Never interfere with top-level navigation: a mistake there costs the whole page.
if (request.isForMainFrame) return null
val url = request.url ?: return null
val scheme = url.scheme?.lowercase()
if (scheme != "http" && scheme != "https") return null
val documentHost = pageUrl?.let { runCatching { android.net.Uri.parse(it).host }.getOrNull() }
if (isAllowlisted(documentHost)) return null
val requestHost = url.host?.lowercase()
val type = ResourceType.infer(request)
val isThirdParty = DomainUtils.isThirdParty(requestHost, documentHost)
val match = runCatching {
current.matchBlockingRule(url.toString(), requestHost, documentHost, type, isThirdParty)
}.getOrNull() ?: return null
blockedCount.incrementAndGet()
blockedSincePage.incrementAndGet()
return blockedResponse(request, match.redirectResource)
}
/**
* The stand-in served for a blocked request.
*
* The content type matters as much as the emptiness. A page that asked for a script and
* receives `text/plain` does not get a script that does nothing — it gets a resource the
* browser refuses to execute, and any code waiting on that script waits until it times
* out. YouTube's player does exactly this with its ad-status check, which showed up as a
* five to ten second stall before playback. So each blocked request is answered with a
* valid, empty thing of the kind it asked for, which is what uBlock Origin's `noop`
* redirect resources do.
*
* When the matched rule named a specific `$redirect=` resource we recognise, that exact
* stub is served instead of the generic empty one -- see [RedirectResources] for why that
* sometimes matters.
*/
private fun blockedResponse(request: WebResourceRequest, redirectResource: String?): WebResourceResponse {
val headers = mapOf(
"Access-Control-Allow-Origin" to "*",
"Cache-Control" to "no-store"
)
if (redirectResource != null) {
RedirectResources.response(redirectResource, headers)?.let { return it }
}
fun response(mimeType: String, encoding: String?, body: ByteArray) =
WebResourceResponse(mimeType, encoding, 200, "OK", headers, ByteArrayInputStream(body))
return when (ResourceType.infer(request)) {
ResourceType.IMAGE -> response("image/gif", null, transparentGifBytes)
ResourceType.SCRIPT -> response("application/javascript", "utf-8", EMPTY)
ResourceType.STYLESHEET -> response("text/css", "utf-8", EMPTY)
ResourceType.SUBDOCUMENT, ResourceType.DOCUMENT -> response("text/html", "utf-8", EMPTY)
// Callers parse this; an empty body would throw where "{}" quietly yields nothing.
ResourceType.XHR -> response("application/json", "utf-8", EMPTY_JSON)
ResourceType.MEDIA -> response("video/mp4", null, EMPTY)
ResourceType.FONT -> response("font/woff2", null, EMPTY)
else -> response("text/plain", "utf-8", EMPTY)
}
}
/** CSS injected at document start, or an empty string when nothing applies. */
fun cssForHost(host: String?): String {
if (!enabled) return ""
if (isAllowlisted(host)) return ""
return engine?.cssForHost(host).orEmpty()
}
/** Whether the YouTube-specific ad-stripping scripts should run for [host]. */
fun isYouTubeBlockingEnabled(host: String?): Boolean {
if (!enabled || !youTubeEnabled) return false
return !isAllowlisted(host)
}
/**
* Whether non-ad cosmetic site tweaks (hiding a redundant footer nav, a Shorts shelf, ...)
* should run. Independent of ad blocking: a user may want one without the other, and unlike
* ad blocking there is no per-host allowlist for it, since it isn't blocking anything.
*/
fun isCustomSiteTweaksEnabled(): Boolean = false
/** Whether a page's own fixed bottom bar should be hidden, per the in-drawer toggle. */
fun isHideSiteFootersEnabled(): Boolean = false
/** CSS for generic rules whose class or id turned up in the page's DOM. */
fun cssForTokens(host: String?, classNames: String, ids: String): String {
if (!enabled) return ""
if (isAllowlisted(host)) return ""
return engine?.cssForTokens(host, classNames, ids).orEmpty()
}
/**
* The `:has-text()` rules for [host], as a JSON array of `[selector, regexSource]` pairs, for
* the page's own JavaScript to apply -- there is no CSS equivalent for a text match, unlike
* every other cosmetic rule this engine serves.
*/
fun hasTextRulesForHost(host: String?): String {
if (!enabled) return "[]"
if (isAllowlisted(host)) return "[]"
val rules = engine?.hasTextRulesForHost(host) ?: return "[]"
if (rules.isEmpty()) return "[]"
val array = org.json.JSONArray()
for ((selector, pattern) in rules) {
array.put(org.json.JSONArray().put(selector).put(pattern))
}
return array.toString()
}
}
@@ -0,0 +1,170 @@
package app.seb3thehacker.gearslip.browser.adblock
import android.webkit.WebResourceRequest
/**
* Resource types used by filter list options ($script, $image, ...).
*
* WebResourceRequest does not expose the resource type directly, so [infer] reconstructs it
* from the Sec-Fetch-Dest request header where WebView sends one, falling back to the file
* extension and then the Accept header.
*/
internal object ResourceType {
const val NONE = 0
const val DOCUMENT = 1 shl 0
const val SUBDOCUMENT = 1 shl 1
const val SCRIPT = 1 shl 2
const val IMAGE = 1 shl 3
const val STYLESHEET = 1 shl 4
const val XHR = 1 shl 5
const val FONT = 1 shl 6
const val MEDIA = 1 shl 7
const val WEBSOCKET = 1 shl 8
const val PING = 1 shl 9
const val OBJECT = 1 shl 10
const val OTHER = 1 shl 11
fun fromOptionName(name: String): Int = when (name) {
"document" -> DOCUMENT
"subdocument", "frame" -> SUBDOCUMENT
"script" -> SCRIPT
"image" -> IMAGE
"stylesheet", "css" -> STYLESHEET
"xmlhttprequest", "xhr" -> XHR
"font" -> FONT
"media" -> MEDIA
"websocket" -> WEBSOCKET
"ping", "beacon" -> PING
"object", "object-subrequest" -> OBJECT
"other" -> OTHER
else -> NONE
}
fun infer(request: WebResourceRequest): Int {
if (request.isForMainFrame) return DOCUMENT
val headers = request.requestHeaders
val dest = headers?.entries
?.firstOrNull { it.key.equals("Sec-Fetch-Dest", ignoreCase = true) }
?.value
?.lowercase()
if (!dest.isNullOrEmpty() && dest != "empty") {
val fromDest = when (dest) {
"document" -> DOCUMENT
"iframe", "frame", "embed" -> SUBDOCUMENT
"script", "worker", "sharedworker", "serviceworker" -> SCRIPT
"image" -> IMAGE
"style" -> STYLESHEET
"font" -> FONT
"audio", "video", "track", "audioworklet" -> MEDIA
"object" -> OBJECT
"report" -> PING
"manifest" -> OTHER
else -> NONE
}
if (fromDest != NONE) return fromDest
}
val fromExtension = extensionType(request.url?.path)
if (fromExtension != NONE) return fromExtension
val accept = headers?.entries
?.firstOrNull { it.key.equals("Accept", ignoreCase = true) }
?.value
?.lowercase()
if (accept != null) {
when {
accept.startsWith("image/") || accept.contains("image/avif") -> return IMAGE
accept.startsWith("text/css") -> return STYLESHEET
accept.startsWith("video/") || accept.startsWith("audio/") -> return MEDIA
accept.contains("application/javascript") -> return SCRIPT
}
}
// "empty" is what fetch()/XMLHttpRequest report, and is also the fallback when
// WebView sends no Sec-Fetch-Dest at all.
return if (dest == "empty") XHR else OTHER
}
private fun extensionType(path: String?): Int {
if (path.isNullOrEmpty()) return NONE
val dot = path.lastIndexOf('.')
if (dot < 0 || dot == path.length - 1) return NONE
return when (path.substring(dot + 1).lowercase()) {
"js", "mjs" -> SCRIPT
"css" -> STYLESHEET
"png", "jpg", "jpeg", "gif", "webp", "svg", "ico", "bmp", "avif" -> IMAGE
"woff", "woff2", "ttf", "otf", "eot" -> FONT
"mp4", "webm", "ogg", "mp3", "m4a", "m4s", "ts", "m3u8", "mpd" -> MEDIA
"json" -> XHR
"swf" -> OBJECT
else -> NONE
}
}
}
/** Helpers for comparing hosts without pulling in a full public suffix list. */
internal object DomainUtils {
/**
* Common multi-label public suffixes. This is deliberately a short list rather than the
* full PSL: it is only used to decide first-party vs third-party, where being wrong means
* a $third-party rule misfires on an uncommon TLD, not a broken page.
*/
private val MULTI_LABEL_SUFFIXES = hashSetOf(
"co.uk", "org.uk", "me.uk", "ac.uk", "gov.uk", "net.uk", "sch.uk",
"co.jp", "ne.jp", "or.jp", "ac.jp", "go.jp",
"com.au", "net.au", "org.au", "edu.au", "gov.au",
"co.nz", "net.nz", "org.nz",
"com.br", "net.br", "org.br",
"co.in", "net.in", "org.in",
"com.cn", "net.cn", "org.cn",
"co.kr", "or.kr",
"com.mx", "com.ar", "com.tr", "com.tw", "com.hk", "com.sg", "com.my",
"co.za", "com.pl", "com.ua", "co.il", "com.vn", "com.ph", "co.id",
"com.es", "com.pt", "com.gr", "com.ro", "com.ru", "org.ru", "net.ru"
)
fun registrableDomain(host: String?): String? {
if (host.isNullOrEmpty()) return null
val normalized = host.trimEnd('.').lowercase()
if (normalized.isEmpty()) return null
if (normalized.indexOf(':') >= 0) return normalized
val labels = normalized.split('.')
if (labels.size <= 2) return normalized
if (labels.all { label -> label.isNotEmpty() && label.all { it in '0'..'9' } }) return normalized
val lastTwo = labels[labels.size - 2] + "." + labels[labels.size - 1]
if (lastTwo in MULTI_LABEL_SUFFIXES && labels.size >= 3) {
return labels[labels.size - 3] + "." + lastTwo
}
return lastTwo
}
fun isThirdParty(requestHost: String?, documentHost: String?): Boolean {
if (requestHost.isNullOrEmpty() || documentHost.isNullOrEmpty()) return false
val request = registrableDomain(requestHost) ?: return false
val document = registrableDomain(documentHost) ?: return false
return !request.equals(document, ignoreCase = true)
}
/** True when [host] is [domain] itself or a subdomain of it. */
fun hostMatchesDomain(host: String, domain: String): Boolean {
if (host == domain) return true
return host.length > domain.length &&
host.endsWith(domain) &&
host[host.length - domain.length - 1] == '.'
}
/** Emits `a.b.example.com`, `b.example.com`, `example.com`, `com`. */
inline fun forEachHostSuffix(host: String, action: (String) -> Unit) {
var index = 0
while (true) {
action(if (index == 0) host else host.substring(index))
val dot = host.indexOf('.', index)
if (dot < 0) return
index = dot + 1
}
}
}
@@ -0,0 +1,336 @@
package app.seb3thehacker.gearslip.browser.adblock
import android.content.Context
import android.webkit.JavascriptInterface
import android.webkit.WebView
import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature
/**
* Applies element-hiding rules to a page.
*
* Which rules apply depends on the page's hostname and on what is actually in its DOM, neither
* of which is known until the document starts loading, so the page fetches its own stylesheet
* through a JavaScript bridge. The bridge exposes two pure functions that return CSS: they read
* no page state and perform no action, so handing them to untrusted page content costs nothing.
*
* Hiding happens in two passes. At document start the page gets the rules written for its own
* host, which must land before anything renders — injecting on page finish would let ads appear
* and then vanish, which is worse than not hiding them. The generic rules follow once the DOM
* exists, keyed on the classes and ids actually present, because shipping all of them up front
* would mean roughly 400 KB of CSS per page.
*/
object CosmeticFilterBridge {
const val BRIDGE_OBJECT_NAME = "GearslipContentBlocker"
/** Stop watching for new elements after this long, to spare the CPU on a head unit. */
private const val OBSERVE_DURATION_MS = 30000
/** Upper bound on how many new class/id names are sent to the bridge in one pass. */
private const val MAX_TOKENS_PER_PASS = 4000
/** True when document-start injection is unavailable and the client must inject later. */
val needsPageStartFallback: Boolean
get() = !WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)
val INJECTION_JS: String = """
(function () {
try {
if (window.__gearslipCosmetic) { return; }
var bridge = window.$BRIDGE_OBJECT_NAME;
if (!bridge || typeof bridge.cssForHost !== 'function') { return; }
var host = location.hostname;
var seenClass = Object.create(null);
var seenId = Object.create(null);
window.__gearslipCosmetic = true;
function addStyle(css) {
if (!css) { return; }
var style = document.createElement('style');
style.setAttribute('data-gearslip', 'content-blocker');
style.textContent = css;
var root = document.head || document.documentElement;
if (root) { root.appendChild(style); }
}
// The engine loads from disk in the background and may not be ready when the
// first page starts. Keep asking until it answers, so a page that loses the race
// still gets its hiding rules rather than none at all.
var hostCssApplied = false;
function applyHostCss() {
if (hostCssApplied) { return true; }
var css = bridge.cssForHost(host);
if (!css) { return false; }
hostCssApplied = true;
addStyle(css);
return true;
}
applyHostCss();
var retries = [250, 750, 1500, 3000, 5000, 8000, 12000];
for (var r = 0; r < retries.length; r++) {
setTimeout(function () {
try {
if (!hostCssApplied && applyHostCss()) { collect(); }
} catch (e) {}
}, retries[r]);
}
var classes = [];
var ids = [];
function scan(root, budget) {
if (!root || root.nodeType !== 1) { return budget; }
var elements = root.getElementsByTagName('*');
var total = elements.length + 1;
for (var i = 0; i < total && budget > 0; i++) {
var element = (i === 0) ? root : elements[i - 1];
var id = element.id;
if (id && !seenId[id]) { seenId[id] = true; ids.push(id); budget--; }
// SVG elements expose className as an SVGAnimatedString, not a string.
var names = element.className;
if (names && typeof names === 'string') {
var parts = names.split(/\s+/);
for (var j = 0; j < parts.length && budget > 0; j++) {
var name = parts[j];
if (name && !seenClass[name]) {
seenClass[name] = true;
classes.push(name);
budget--;
}
}
}
}
return budget;
}
function collect() {
classes.length = 0;
ids.length = 0;
var budget = $MAX_TOKENS_PER_PASS;
if (!scannedOnce) {
scannedOnce = true;
budget = scan(document.documentElement, budget);
} else {
// Only look at what the page actually added. Re-walking the whole document
// on every mutation costs far more than it finds: on a large single-page app
// it means a full DOM traversal every 50ms while the page is starting up.
for (var i = 0; i < pending.length && budget > 0; i++) {
budget = scan(pending[i], budget);
}
}
pending.length = 0;
if (classes.length || ids.length) {
addStyle(bridge.cssForTokens(host, classes.join(' '), ids.join(' ')));
}
}
var scannedOnce = false;
var pending = [];
var scheduled = false;
function schedule() {
if (scheduled) { return; }
scheduled = true;
setTimeout(function () {
scheduled = false;
try { collect(); } catch (e) {}
}, 50);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', schedule);
} else {
schedule();
}
if (typeof MutationObserver === 'function' && document.documentElement) {
var observer = new MutationObserver(function (records) {
for (var i = 0; i < records.length; i++) {
var added = records[i].addedNodes;
for (var j = 0; j < added.length; j++) {
if (added[j].nodeType === 1) { pending.push(added[j]); }
}
}
// A burst larger than this is a page rebuilding itself; the tail is enough.
if (pending.length > 300) { pending.splice(0, pending.length - 300); }
if (pending.length) { schedule(); }
});
observer.observe(document.documentElement, { childList: true, subtree: true });
setTimeout(function () {
try { observer.disconnect(); } catch (e) {}
}, $OBSERVE_DURATION_MS);
}
} catch (e) {}
})();
(function () {
// Hides a site's own fixed bottom nav bar, which duplicates navigation the app
// already provides and costs a sixth of the screen on a ~480px-tall car display.
// Found by measuring rather than by a list of known selectors, so it works on any
// site. Toggled from the drawer.
//
// Hiding rather than repositioning is deliberate: an earlier version moved these
// bars instead, which fought sites that animate the bar's own position (Twitch
// slides its nav bar on scroll) and left it stranded mid-page. display:none has
// nothing to fight.
try {
if (window.__gearslipFooterHiding) { return; }
window.__gearslipFooterHiding = true;
var bridge = window.$BRIDGE_OBJECT_NAME;
if (!bridge || typeof bridge.hideSiteFootersEnabled !== 'function') { return; }
if (!bridge.hideSiteFootersEnabled()) { return; }
var MAX_BAR_HEIGHT = 140;
function hideBottomBars(root, depth) {
if (!root || depth < 0 || !root.children) { return; }
for (var i = 0; i < root.children.length; i++) {
var el = root.children[i];
if (el.__gearslipFooterHidden) { continue; }
var cs;
try { cs = getComputedStyle(el); } catch (e) { continue; }
if (cs.display === 'none') { continue; }
if (cs.position === 'fixed' || cs.position === 'sticky') {
var rect;
try { rect = el.getBoundingClientRect(); } catch (e) { rect = null; }
if (rect && rect.width >= window.innerWidth * 0.6 &&
rect.height > 0 && rect.height <= MAX_BAR_HEIGHT &&
Math.abs(window.innerHeight - rect.bottom) < 6) {
el.__gearslipFooterHidden = true;
el.style.setProperty('display', 'none', 'important');
continue;
}
}
hideBottomBars(el, depth - 1);
}
}
function run() {
try {
hideBottomBars(document.body, 3);
} catch (e) {}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', run);
} else {
run();
}
// Single-page apps mount their nav bar well after first paint, and swap it out
// again on navigation, so one pass at load isn't enough.
var passes = 0;
var timer = setInterval(function () {
run();
if (++passes > 40) { clearInterval(timer); }
}, 500);
} catch (e) {}
})();
(function () {
// ":has-text(pattern)" cosmetic rules: hides elements matching a base selector whose
// own text also matches a pattern. There is no CSS equivalent for the text half, so
// this runs the check in JavaScript instead of the plain <style> the rest of the
// cosmetic filters use.
try {
if (window.__gearslipHasText) { return; }
var bridge = window.$BRIDGE_OBJECT_NAME;
if (!bridge || typeof bridge.hasTextRulesForHost !== 'function') { return; }
var rules;
try {
rules = JSON.parse(bridge.hasTextRulesForHost(location.hostname) || '[]');
} catch (e) {
rules = [];
}
if (!rules.length) { return; }
window.__gearslipHasText = true;
var compiled = [];
for (var i = 0; i < rules.length; i++) {
try {
compiled.push({ selector: rules[i][0], regex: new RegExp(rules[i][1], 'i') });
} catch (e) {}
}
if (!compiled.length) { return; }
function run() {
for (var i = 0; i < compiled.length; i++) {
var rule = compiled[i];
var elements;
try { elements = document.querySelectorAll(rule.selector); } catch (e) { continue; }
for (var j = 0; j < elements.length; j++) {
var el = elements[j];
if (el.__gearslipHasTextHidden) { continue; }
var text = el.textContent;
if (text && rule.regex.test(text)) {
el.__gearslipHasTextHidden = true;
el.style.setProperty('display', 'none', 'important');
}
}
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', run);
} else {
run();
}
// Matches uBlock Origin's own behaviour for this rule type: it re-evaluates on a
// timer rather than per-mutation, since a text match can only be judged once an
// element's subtree has actually finished changing.
var passes = 0;
var timer = setInterval(function () {
run();
if (++passes > 40) { clearInterval(timer); }
}, 500);
} catch (e) {}
})();
""".trimIndent()
fun attach(webView: WebView) {
webView.addJavascriptInterface(Bridge(webView.context.applicationContext), BRIDGE_OBJECT_NAME)
if (!needsPageStartFallback) {
runCatching {
WebViewCompat.addDocumentStartJavaScript(webView, INJECTION_JS, setOf("*"))
}
runCatching {
WebViewCompat.addDocumentStartJavaScript(
webView,
YouTubeAdBlock.INJECTION_JS,
setOf("*")
)
}
}
}
class Bridge(private val context: Context) {
@JavascriptInterface
fun cssForHost(host: String?): String = runCatching {
AdBlockManager.cssForHost(host)
}.getOrDefault("")
@JavascriptInterface
fun youtubeBlockingEnabled(host: String?): Boolean = runCatching {
AdBlockManager.isYouTubeBlockingEnabled(host)
}.getOrDefault(false)
@JavascriptInterface
fun customSiteTweaksEnabled(): Boolean = runCatching {
AdBlockManager.isCustomSiteTweaksEnabled()
}.getOrDefault(false)
@JavascriptInterface
fun hideSiteFootersEnabled(): Boolean = runCatching {
AdBlockManager.isHideSiteFootersEnabled()
}.getOrDefault(false)
@JavascriptInterface
fun hasTextRulesForHost(host: String?): String = runCatching {
AdBlockManager.hasTextRulesForHost(host)
}.getOrDefault("[]")
@JavascriptInterface
fun cssForTokens(host: String?, classNames: String?, ids: String?): String = runCatching {
AdBlockManager.cssForTokens(host, classNames.orEmpty(), ids.orEmpty())
}.getOrDefault("")
}
}
@@ -0,0 +1,610 @@
package app.seb3thehacker.gearslip.browser.adblock
import android.util.LruCache
/**
* An immutable, queryable index over a set of parsed filter rules.
*
* Network rules are bucketed so that a request only ever tests a handful of them:
* - `||host^` rules are indexed by host and found by walking the request host's suffixes;
* - every other rule is indexed under one literal token from its pattern, and a request only
* tests rules whose token appears in the URL;
* - the few rules with no usable token are tested on every request.
*
* Element-hiding rules are split the same way. Nearly all generic hiding rules are anchored on
* a single class or id (`.ad-banner`, `#sponsored`), so they are indexed by that name and only
* handed to a page once that class or id actually appears in its DOM. Injecting all of them
* unconditionally would mean roughly 400 KB of CSS on every page load.
*/
internal class FilterEngine private constructor(
private val blockRules: RuleSet,
private val allowRules: RuleSet,
private val genericComplexSelectors: List<String>,
private val genericByClass: HashMap<String, MutableList<String>>,
private val genericById: HashMap<String, MutableList<String>>,
private val specificSelectors: HashMap<String, MutableList<String>>,
private val cosmeticExceptions: HashMap<String, MutableList<String>>,
private val genericHasText: List<Pair<String, String>>,
private val specificHasText: HashMap<String, MutableList<Pair<String, String>>>,
val networkRuleCount: Int,
val cosmeticRuleCount: Int
) {
private val hostCssCache = LruCache<String, String>(32)
/** True when the request should be blocked at all, regardless of which rule matched. */
fun shouldBlock(
url: String,
requestHost: String?,
documentHost: String?,
type: Int,
isThirdParty: Boolean
): Boolean = matchBlockingRule(url, requestHost, documentHost, type, isThirdParty) != null
/**
* The block match for this request, or null when it is not blocked. Split out from
* [shouldBlock] so the caller can also read the matched rule's `$redirect=` resource name
* without a second, separate walk over the same rule sets. A plain `||host^` match (the
* common case) carries no [NetworkRule] object at all, hence [BlockMatch] rather than
* returning the rule directly.
*/
fun matchBlockingRule(
url: String,
requestHost: String?,
documentHost: String?,
type: Int,
isThirdParty: Boolean
): BlockMatch? {
val lowerUrl = url.lowercase()
val hostStart = hostStart(lowerUrl)
val hostEnd = hostEnd(lowerUrl, hostStart)
val tokens = ArrayList<String>(16)
tokenize(lowerUrl, tokens)
val blockingMatch = matchingRule(blockRules, lowerUrl, hostStart, hostEnd, requestHost, documentHost, type, isThirdParty, tokens)
?: return null
if (matchesAny(allowRules, lowerUrl, hostStart, hostEnd, requestHost, documentHost, type, isThirdParty, tokens)) {
return null
}
return blockingMatch
}
class BlockMatch(val redirectResource: String?) {
companion object {
val PLAIN = BlockMatch(null)
}
}
/**
* The stylesheet injected at document start: rules written for this site, plus the handful
* of generic rules that are not anchored on a class or id.
*/
fun cssForHost(host: String?): String {
val normalized = host?.lowercase().orEmpty()
hostCssCache.get(normalized)?.let { return it }
val exceptions = exceptionsFor(normalized)
val selectors = ArrayList<String>(genericComplexSelectors.size + 32)
if (normalized.isNotEmpty()) {
DomainUtils.forEachHostSuffix(normalized) { suffix ->
specificSelectors[suffix]?.let { selectors.addAll(it) }
}
}
selectors.addAll(genericComplexSelectors)
val css = buildHidingCss(selectors, exceptions)
hostCssCache.put(normalized, css)
return css
}
private val hasTextCache = LruCache<String, List<Pair<String, String>>>(32)
/**
* The `:has-text()` rules (base selector, JS regex source) that apply to [host]. Delivered
* separately from [cssForHost] because there is no CSS equivalent for a text match -- the
* caller runs these with JavaScript instead of a `<style>` element.
*/
fun hasTextRulesForHost(host: String?): List<Pair<String, String>> {
val normalized = host?.lowercase().orEmpty()
hasTextCache.get(normalized)?.let { return it }
val rules = ArrayList<Pair<String, String>>(genericHasText.size + 4)
if (normalized.isNotEmpty()) {
DomainUtils.forEachHostSuffix(normalized) { suffix ->
specificHasText[suffix]?.let { rules.addAll(it) }
}
}
rules.addAll(genericHasText)
hasTextCache.put(normalized, rules)
return rules
}
/**
* The stylesheet for generic rules whose class or id has turned up in the page. [classNames]
* and [ids] are whitespace-separated, as collected from the live DOM.
*/
fun cssForTokens(host: String?, classNames: String, ids: String): String {
val normalized = host?.lowercase().orEmpty()
val exceptions = exceptionsFor(normalized)
val selectors = ArrayList<String>()
collectByToken(classNames, genericByClass, selectors)
collectByToken(ids, genericById, selectors)
if (selectors.isEmpty()) return ""
return buildHidingCss(selectors, exceptions)
}
private fun collectByToken(
tokens: String,
index: HashMap<String, MutableList<String>>,
out: MutableList<String>
) {
if (tokens.isEmpty() || index.isEmpty()) return
var start = 0
val length = tokens.length
while (start < length) {
while (start < length && tokens[start].isWhitespace()) start++
if (start >= length) break
var end = start
while (end < length && !tokens[end].isWhitespace()) end++
index[tokens.substring(start, end)]?.let { out.addAll(it) }
start = end
}
}
private fun exceptionsFor(host: String): Set<String> {
if (host.isEmpty() || cosmeticExceptions.isEmpty()) return emptySet()
var result: MutableSet<String>? = null
DomainUtils.forEachHostSuffix(host) { suffix ->
cosmeticExceptions[suffix]?.let { exceptions ->
val target = result ?: HashSet<String>().also { result = it }
target.addAll(exceptions)
}
}
return result ?: emptySet()
}
private fun buildHidingCss(selectors: List<String>, exceptions: Set<String>): String {
val usable = if (exceptions.isEmpty()) {
selectors
} else {
selectors.filterNot { it in exceptions }
}
if (usable.isEmpty()) return ""
val builder = StringBuilder(usable.size * 24)
// One rule per chunk rather than one rule for everything: a browser drops an entire
// rule if any selector in it fails to parse, so a chunk limits that blast radius.
for (chunk in usable.chunked(SELECTORS_PER_RULE)) {
builder.append(chunk.joinToString(","))
builder.append("{display:none!important}")
}
return builder.toString()
}
private fun matchesAny(
ruleSet: RuleSet,
url: String,
hostStart: Int,
hostEnd: Int,
requestHost: String?,
documentHost: String?,
type: Int,
isThirdParty: Boolean,
tokens: List<String>
): Boolean {
if (!requestHost.isNullOrEmpty()) {
var found = false
DomainUtils.forEachHostSuffix(requestHost) { suffix ->
if (!found) {
if (suffix in ruleSet.plainHosts) {
found = true
} else {
val candidates = ruleSet.hostIndex[suffix]
if (candidates != null) {
for (rule in candidates) {
if (optionsMatch(rule, type, isThirdParty, documentHost)) {
found = true
break
}
}
}
}
}
}
if (found) return true
}
for (token in tokens) {
val candidates = ruleSet.tokenIndex[token] ?: continue
for (rule in candidates) {
if (optionsMatch(rule, type, isThirdParty, documentHost) &&
matchesUrl(rule, url, hostStart, hostEnd)
) {
return true
}
}
}
for (rule in ruleSet.catchAll) {
if (optionsMatch(rule, type, isThirdParty, documentHost) &&
matchesUrl(rule, url, hostStart, hostEnd)
) {
return true
}
}
return false
}
/** Same walk as [matchesAny], but returns the match itself instead of a plain boolean. */
private fun matchingRule(
ruleSet: RuleSet,
url: String,
hostStart: Int,
hostEnd: Int,
requestHost: String?,
documentHost: String?,
type: Int,
isThirdParty: Boolean,
tokens: List<String>
): BlockMatch? {
if (!requestHost.isNullOrEmpty()) {
var result: BlockMatch? = null
DomainUtils.forEachHostSuffix(requestHost) { suffix ->
if (result == null) {
if (suffix in ruleSet.plainHosts) {
result = BlockMatch.PLAIN
} else {
val candidates = ruleSet.hostIndex[suffix]
if (candidates != null) {
for (rule in candidates) {
if (optionsMatch(rule, type, isThirdParty, documentHost)) {
result = BlockMatch(rule.redirectResource)
break
}
}
}
}
}
}
if (result != null) return result
}
for (token in tokens) {
val candidates = ruleSet.tokenIndex[token] ?: continue
for (rule in candidates) {
if (optionsMatch(rule, type, isThirdParty, documentHost) &&
matchesUrl(rule, url, hostStart, hostEnd)
) {
return BlockMatch(rule.redirectResource)
}
}
}
for (rule in ruleSet.catchAll) {
if (optionsMatch(rule, type, isThirdParty, documentHost) &&
matchesUrl(rule, url, hostStart, hostEnd)
) {
return BlockMatch(rule.redirectResource)
}
}
return null
}
private fun optionsMatch(
rule: NetworkRule,
type: Int,
isThirdParty: Boolean,
documentHost: String?
): Boolean {
if (rule.typeMask != 0 && (rule.typeMask and type) == 0) return false
if (rule.excludedTypeMask != 0 && (rule.excludedTypeMask and type) != 0) return false
val thirdParty = rule.thirdParty
if (thirdParty != null && thirdParty != isThirdParty) return false
val include = rule.includeDomains
val exclude = rule.excludeDomains
if (include != null || exclude != null) {
val host = documentHost ?: return false
if (exclude != null && exclude.any { DomainUtils.hostMatchesDomain(host, it) }) return false
if (include != null && !include.any { DomainUtils.hostMatchesDomain(host, it) }) return false
}
return true
}
private fun matchesUrl(rule: NetworkRule, url: String, hostStart: Int, hostEnd: Int): Boolean {
if (rule.anchorDomain) {
if (matchFrom(url, hostStart, rule)) return true
var index = hostStart
while (index < hostEnd) {
if (url[index] == '.' && matchFrom(url, index + 1, rule)) return true
index++
}
return false
}
if (rule.anchorStart) return matchFrom(url, 0, rule)
val first = rule.segments[0]
if (first.isNotEmpty() && first.indexOf('^') < 0) {
var index = url.indexOf(first)
while (index >= 0) {
if (matchFrom(url, index, rule)) return true
index = url.indexOf(first, index + 1)
}
return false
}
var index = 0
while (index <= url.length) {
if (matchFrom(url, index, rule)) return true
index++
}
return false
}
private fun matchFrom(url: String, start: Int, rule: NetworkRule): Boolean {
val segments = rule.segments
var position = matchSegmentAt(url, start, segments[0])
if (position < 0) return false
for (i in 1 until segments.size) {
val segment = segments[i]
if (segment.isEmpty()) continue
var probe = position
var matchedEnd = -1
while (probe <= url.length) {
val end = matchSegmentAt(url, probe, segment)
if (end >= 0) {
matchedEnd = end
break
}
probe++
}
if (matchedEnd < 0) return false
position = matchedEnd
}
if (rule.anchorEnd && segments[segments.size - 1].isNotEmpty() && position != url.length) {
return false
}
return true
}
/** Returns the index just past the match, or -1. `^` matches one separator or end-of-URL. */
private fun matchSegmentAt(url: String, start: Int, segment: String): Int {
var position = start
for (i in segment.indices) {
val expected = segment[i]
if (expected == '^') {
if (position >= url.length) {
// A separator may stand for the end of the address, but only as the last
// character of the segment.
return if (i == segment.length - 1) position else -1
}
if (!isSeparator(url[position])) return -1
position++
} else {
if (position >= url.length || url[position] != expected) return -1
position++
}
}
return position
}
companion object {
private const val MIN_TOKEN_LENGTH = 3
private const val SELECTORS_PER_RULE = 100
private fun isSeparator(c: Char): Boolean = when {
c in 'a'..'z' -> false
c in 'A'..'Z' -> false
c in '0'..'9' -> false
c == '_' || c == '-' || c == '.' || c == '%' -> false
else -> true
}
private fun hostStart(url: String): Int {
val scheme = url.indexOf("://")
return if (scheme < 0) 0 else scheme + 3
}
private fun hostEnd(url: String, hostStart: Int): Int {
var index = hostStart
while (index < url.length) {
val c = url[index]
if (c == '/' || c == '?' || c == '#' || c == ':') return index
index++
}
return url.length
}
private fun tokenize(text: String, out: MutableList<String>) {
var index = 0
val length = text.length
while (index < length) {
val c = text[index]
if (c.isLetterOrDigit()) {
var end = index + 1
while (end < length && text[end].isLetterOrDigit()) end++
if (end - index >= MIN_TOKEN_LENGTH) out.add(text.substring(index, end))
index = end
} else {
index++
}
}
}
/** The longest literal token in the pattern, used as the rule's index key. */
private fun bestToken(rule: NetworkRule): String? {
var best: String? = null
val tokens = ArrayList<String>(4)
for (segment in rule.segments) {
tokens.clear()
tokenize(segment, tokens)
for (token in tokens) {
val current = best
if (current == null || token.length > current.length) best = token
}
}
return best
}
/**
* The leading class or id a selector is anchored on, as (`.` or `#`) to name, or null
* when the selector does not start with a plain class or id.
*/
private fun anchorToken(selector: String): Pair<Char, String>? {
if (selector.length < 2) return null
val kind = selector[0]
if (kind != '.' && kind != '#') return null
var end = 1
while (end < selector.length) {
val c = selector[end]
if (c.isLetterOrDigit() || c == '-' || c == '_') end++ else break
}
if (end == 1) return null
return kind to selector.substring(1, end)
}
fun build(lines: Sequence<String>): FilterEngine {
val builder = Builder()
for (line in lines) builder.addLine(line)
return builder.finish()
}
}
/**
* Accumulates rules one line at a time, fed straight from a reader so that the whole list
* never exists in memory as a collection of lines. Parsing EasyList and EasyPrivacy
* together takes roughly 250ms on a Pixel 6 in a release build. Be wary of profiling this
* in a debug build: `android:debuggable` disables ART's inlining and made the same work
* take over nine seconds, which looks like an algorithmic problem but is not one.
*/
internal class Builder {
private val blockRules = RuleSet()
private val allowRules = RuleSet()
private val genericComplex = LinkedHashSet<String>()
// Pre-sized: EasyList and EasyPrivacy together fill these to roughly these counts, and
// rehashing a 90k-entry map on the way there is pure waste.
private val genericByClass = HashMap<String, MutableList<String>>(16384)
private val genericById = HashMap<String, MutableList<String>>(8192)
private val specificSelectors = HashMap<String, MutableList<String>>(16384)
private val cosmeticExceptions = HashMap<String, MutableList<String>>(1024)
private val genericHasText = mutableListOf<Pair<String, String>>()
private val specificHasText = HashMap<String, MutableList<Pair<String, String>>>()
private var networkRuleCount = 0
private var cosmeticRuleCount = 0
fun addLine(rawLine: String) {
if (rawLine.isEmpty()) return
// Only pay for trim() on the rare line that actually has surrounding whitespace.
val needsTrim = rawLine[0] == ' ' || rawLine[0] == '\t' ||
rawLine[rawLine.length - 1] == ' ' || rawLine[rawLine.length - 1] == '\t'
val line = if (needsTrim) rawLine.trim() else rawLine
if (line.isEmpty()) return
if (line[0] == '!' || line[0] == '[') return
val separator = FilterParser.cosmeticSeparatorIndex(line)
if (separator >= 0) {
addCosmeticRule(line, separator)
return
}
val isException = line.startsWith("@@")
val rule = FilterParser.parseNetworkRule(line) ?: return
networkRuleCount++
val target = if (isException) allowRules else blockRules
val hostAnchor = rule.hostAnchor
if (hostAnchor != null) {
if (rule.hasNoOptions) {
target.plainHosts.add(hostAnchor)
} else {
target.hostIndex.getOrPut(hostAnchor) { mutableListOf() }.add(rule)
}
return
}
val token = bestToken(rule)
if (token != null) {
target.tokenIndex.getOrPut(token) { mutableListOf() }.add(rule)
} else {
target.catchAll.add(rule)
}
}
private fun addCosmeticRule(line: String, separator: Int) {
val rule = FilterParser.parseCosmeticRule(line, separator) ?: return
cosmeticRuleCount++
// :has-text() has no CSS equivalent, so it is matched by JavaScript from its own
// list instead of joining the CSS selector strings above. Exceptions for it are not
// supported -- rare enough in EasyList/EasyPrivacy that dropping them outright (by
// not indexing this rule) is a safe simplification rather than a real gap.
val textPattern = rule.hasTextPattern
if (textPattern != null) {
if (rule.isException) return
val entry = rule.selector to textPattern
if (rule.includeDomains != null) {
rule.includeDomains.forEach { domain ->
specificHasText.getOrPut(domain) { mutableListOf() }.add(entry)
}
} else {
genericHasText.add(entry)
}
return
}
when {
rule.isException -> {
rule.includeDomains?.forEach { domain ->
cosmeticExceptions.getOrPut(domain) { mutableListOf() }.add(rule.selector)
}
}
rule.includeDomains != null -> {
rule.includeDomains.forEach { domain ->
specificSelectors.getOrPut(domain) { mutableListOf() }.add(rule.selector)
}
}
else -> {
val anchor = anchorToken(rule.selector)
if (anchor == null) {
genericComplex.add(rule.selector)
} else {
val index = if (anchor.first == '.') genericByClass else genericById
index.getOrPut(anchor.second) { mutableListOf() }.add(rule.selector)
}
// `~example.com##.ad` hides everywhere except the excluded domains.
rule.excludeDomains?.forEach { domain ->
cosmeticExceptions.getOrPut(domain) { mutableListOf() }.add(rule.selector)
}
}
}
}
fun finish(): FilterEngine = FilterEngine(
blockRules = blockRules,
allowRules = allowRules,
genericComplexSelectors = genericComplex.toList(),
genericByClass = genericByClass,
genericById = genericById,
specificSelectors = specificSelectors,
cosmeticExceptions = cosmeticExceptions,
genericHasText = genericHasText,
specificHasText = specificHasText,
networkRuleCount = networkRuleCount,
cosmeticRuleCount = cosmeticRuleCount
)
}
internal class RuleSet {
/** Bare `||host^` blocks with no options: matching the host is the whole test. */
val plainHosts = HashSet<String>()
val hostIndex = HashMap<String, MutableList<NetworkRule>>()
val tokenIndex = HashMap<String, MutableList<NetworkRule>>()
val catchAll = ArrayList<NetworkRule>()
}
}
@@ -0,0 +1,330 @@
package app.seb3thehacker.gearslip.browser.adblock
/**
* A parsed network filter rule (Adblock Plus syntax subset).
*
* [segments] is the pattern split on `*`; each segment is matched literally except for `^`,
* which stands for a single separator character (or the end of the URL).
*/
internal class NetworkRule(
val segments: Array<String>,
val anchorDomain: Boolean,
val anchorStart: Boolean,
val anchorEnd: Boolean,
val typeMask: Int,
val excludedTypeMask: Int,
val thirdParty: Boolean?,
val includeDomains: Array<String>?,
val excludeDomains: Array<String>?,
/** Set when the whole pattern is a bare `||host^`, which lets the engine index it by host. */
val hostAnchor: String?,
/** Set for `$redirect=name` / `$redirect-rule=name` rules with a resource we can serve. */
val redirectResource: String?
) {
/**
* True when the rule carries no options, so matching the host is the whole test. The great
* majority of list entries are of this shape and the engine stores them as bare strings.
*/
val hasNoOptions: Boolean
get() = typeMask == 0 && excludedTypeMask == 0 && thirdParty == null &&
includeDomains == null && excludeDomains == null && redirectResource == null
}
internal class CosmeticRule(
val selector: String,
val includeDomains: List<String>?,
val excludeDomains: List<String>?,
val isException: Boolean,
/** Set for a `selector:has-text(pattern)` rule: the text [pattern] to match within
* [selector]'s matches, applied by JavaScript rather than as plain CSS. */
val hasTextPattern: String? = null
)
internal object FilterParser {
/**
* Options that change *how* a request is answered rather than whether it is blocked.
* A rule carrying one of these is dropped: honouring only its matching half would block
* requests the list author meant to rewrite or merely restrict.
*/
private val UNSUPPORTED_OPTIONS = hashSetOf(
"csp", "removeparam", "rewrite", "replace", "empty", "mp4",
"inline-script", "inline-font", "genericblock", "generichide", "specifichide", "elemhide",
"badfilter", "denyallow", "method", "header", "to", "from", "ipaddress", "permissions",
"uritransform", "urlskip", "urltransform", "strict1p", "strict3p", "popup", "popunder",
"cname", "webrtc", "stealth", "cookie", "network", "app", "extension", "jsonprune",
"hls", "referrerpolicy", "match-case-path"
)
/**
* Procedural cosmetic syntax that needs uBlock Origin's own runtime to evaluate. These
* selectors cannot be expressed as plain CSS, so the rules are skipped.
*
* `:has()` is not in this list: modern WebView's Chromium supports it natively as plain CSS,
* so those selectors are just passed straight through. `:has-text()` is handled separately in
* [parseCosmeticRule] rather than being skipped outright -- the simple, common form (a plain
* selector followed by one `:has-text(...)`) is split out and matched by JavaScript instead of
* CSS, since there is no CSS equivalent for a text match.
*/
private val PROCEDURAL_MARKERS = arrayOf(
// ":has-text(" stays here as a safety net: it's stripped out before this check runs
// when it's the simple trailing form [extractHasText] understands, so a leftover
// occurrence here means that extraction either wasn't attempted (not a cosmetic rule
// that reached it) or failed (nested/combined usage), and the rule should be dropped
// rather than shipped as literal, invalid CSS.
":has-text(",
":matches-css", ":matches-media", ":matches-path", ":matches-attr",
":xpath(", ":upward(", ":nth-ancestor(", ":style(", ":remove(", ":remove-attr(",
":remove-class(", ":watch-attr(", ":min-text-length(", ":others(", ":shadow(", ":if(",
":if-not(", ":not(:has"
)
private fun isHostCharacter(c: Char): Boolean =
c in 'a'..'z' || c in '0'..'9' || c == '.' || c == '-' || c == '_'
fun parseNetworkRule(line: String): NetworkRule? {
var body = line
if (body.startsWith("@@")) body = body.substring(2)
if (body.isEmpty()) return null
var pattern = body
var optionText: String? = null
val dollar = body.lastIndexOf('$')
if (dollar >= 0) {
pattern = body.substring(0, dollar)
optionText = body.substring(dollar + 1)
}
// Regular-expression rules (/pattern/) are not supported.
if (pattern.length > 2 && pattern.startsWith("/") && pattern.endsWith("/")) return null
if (pattern.isEmpty()) return null
var anchorDomain = false
var anchorStart = false
var anchorEnd = false
if (pattern.startsWith("||")) {
anchorDomain = true
pattern = pattern.substring(2)
} else if (pattern.startsWith("|")) {
anchorStart = true
pattern = pattern.substring(1)
}
if (pattern.endsWith("|")) {
anchorEnd = true
pattern = pattern.dropLast(1)
}
if (pattern.isEmpty()) return null
pattern = pattern.lowercase()
var typeMask = 0
var excludedTypeMask = 0
var thirdParty: Boolean? = null
var includeDomains: MutableList<String>? = null
var excludeDomains: MutableList<String>? = null
var redirectResource: String? = null
if (optionText != null) {
for (rawOption in optionText.split(',')) {
var option = rawOption.trim().lowercase()
if (option.isEmpty()) continue
var negated = false
if (option.startsWith("~")) {
negated = true
option = option.substring(1)
}
val equals = option.indexOf('=')
val key = if (equals >= 0) option.substring(0, equals) else option
val value = if (equals >= 0) option.substring(equals + 1) else null
if (key.isEmpty()) return null
if (key in UNSUPPORTED_OPTIONS) return null
when (key) {
"domain" -> {
if (value.isNullOrEmpty()) return null
for (entry in value.split('|')) {
val domain = entry.trim()
if (domain.isEmpty()) continue
if (domain.startsWith("~")) {
val excluded = domain.substring(1)
if (excluded.isNotEmpty()) {
(excludeDomains ?: mutableListOf<String>()
.also { excludeDomains = it }).add(excluded)
}
} else {
(includeDomains ?: mutableListOf<String>()
.also { includeDomains = it }).add(domain)
}
}
}
"third-party", "3p" -> thirdParty = !negated
"first-party", "1p" -> thirdParty = negated
// Patterns are lower-cased for matching, so $match-case degrades to
// case-insensitive. It only ever widens the match.
"match-case", "important" -> Unit
"all" -> Unit
// `redirect-rule` only fires when another rule would already block the
// request; treating it the same as `redirect` is a harmless simplification
// since we only reach here once a block decision is otherwise made.
"redirect", "redirect-rule" -> {
// AdGuard's priority suffix (`name:10`) doesn't apply to a single-engine
// blocker with no other redirect rule to rank against.
val name = value?.substringBefore(':')
if (name.isNullOrEmpty()) return null
redirectResource = name
}
else -> {
val bit = ResourceType.fromOptionName(key)
if (bit == ResourceType.NONE) return null
if (negated) excludedTypeMask = excludedTypeMask or bit
else typeMask = typeMask or bit
}
}
}
}
val segments = splitOnWildcards(pattern)
if (segments.isEmpty()) return null
var hostAnchor: String? = null
if (anchorDomain && !anchorEnd && segments.size == 1) {
val candidate = if (pattern.endsWith("^")) pattern.dropLast(1) else pattern
if (candidate.isNotEmpty() && candidate.all { isHostCharacter(it) }) {
hostAnchor = candidate
}
}
return NetworkRule(
segments = segments,
anchorDomain = anchorDomain,
anchorStart = anchorStart,
anchorEnd = anchorEnd,
typeMask = typeMask,
excludedTypeMask = excludedTypeMask,
thirdParty = thirdParty,
includeDomains = includeDomains?.toTypedArray(),
excludeDomains = excludeDomains?.toTypedArray(),
hostAnchor = hostAnchor,
redirectResource = redirectResource
)
}
private fun splitOnWildcards(pattern: String): Array<String> {
if (pattern.indexOf('*') < 0) return arrayOf(pattern)
return pattern.split('*').toTypedArray()
}
/** Returns the index of the cosmetic separator, or -1 when the line is not a cosmetic rule. */
fun cosmeticSeparatorIndex(line: String): Int {
var search = 0
while (true) {
val hash = line.indexOf('#', search)
if (hash < 0) return -1
if (hash + 1 < line.length) {
val next = line[hash + 1]
if (next == '#') return hash
if (next == '@' && hash + 2 < line.length && line[hash + 2] == '#') return hash
// #?#, #$#, #$?#, #%# and friends are procedural or scriptlet syntax.
if (next == '?' || next == '$' || next == '%') return hash
}
search = hash + 1
}
}
fun parseCosmeticRule(line: String, separatorIndex: Int): CosmeticRule? {
val isException: Boolean
val selectorStart: Int
when {
line.startsWith("#@#", separatorIndex) -> {
isException = true
selectorStart = separatorIndex + 3
}
line.startsWith("##", separatorIndex) -> {
isException = false
selectorStart = separatorIndex + 2
}
// Procedural / scriptlet separators cannot be represented as CSS.
else -> return null
}
if (selectorStart >= line.length) return null
var selector = line.substring(selectorStart).trim()
if (selector.isEmpty()) return null
if (selector.startsWith("+js(") || selector.startsWith("script:")) return null
var hasTextPattern: String? = null
val hasTextSplit = extractHasText(selector)
if (hasTextSplit != null) {
selector = hasTextSplit.first
hasTextPattern = hasTextSplit.second
}
val lowerSelector = selector.lowercase()
if (PROCEDURAL_MARKERS.any { lowerSelector.contains(it) }) return null
// A stray brace would let a selector escape its own rule when concatenated.
if (selector.contains('{') || selector.contains('}')) return null
var includeDomains: MutableList<String>? = null
var excludeDomains: MutableList<String>? = null
val domainText = line.substring(0, separatorIndex).trim()
if (domainText.isNotEmpty()) {
for (entry in domainText.split(',')) {
val domain = entry.trim().lowercase()
if (domain.isEmpty()) continue
if (domain.startsWith("~")) {
val excluded = domain.substring(1)
if (excluded.isNotEmpty()) {
(excludeDomains ?: mutableListOf<String>().also { excludeDomains = it })
.add(excluded)
}
} else {
(includeDomains ?: mutableListOf<String>().also { includeDomains = it })
.add(domain)
}
}
}
return CosmeticRule(selector, includeDomains, excludeDomains, isException, hasTextPattern)
}
/**
* Splits `selector:has-text(pattern)` into (`selector`, JS regex source). Only the simple,
* common form is handled -- one `:has-text(...)` as the very last thing in the selector, with
* a non-empty selector before it. Anything else (nested inside `:has()`, combined with other
* procedural syntax, an empty base selector matching every element) returns null and the
* caller falls back to discarding the rule, same as before this existed.
*/
private fun extractHasText(selector: String): Pair<String, String>? {
val markerIndex = selector.indexOf(":has-text(")
if (markerIndex <= 0) return null
val argStart = markerIndex + ":has-text(".length
var depth = 1
var index = argStart
while (index < selector.length && depth > 0) {
when (selector[index]) {
'(' -> depth++
')' -> depth--
}
if (depth > 0) index++
}
// The closing paren must be the last character: a trailing combinator or pseudo after
// it means this is a more complex selector than the simple form handled here.
if (depth != 0 || index != selector.length - 1) return null
val base = selector.substring(0, markerIndex).trim()
if (base.isEmpty() || base.contains(":has-text(")) return null
var arg = selector.substring(argStart, index).trim()
if (arg.isEmpty()) return null
val pattern = if (arg.length > 1 && arg.startsWith("/") && arg.lastIndexOf('/') > 0) {
arg.substring(1, arg.lastIndexOf('/'))
} else {
Regex.escape(arg)
}
return base to pattern
}
}
@@ -0,0 +1,93 @@
package app.seb3thehacker.gearslip.browser.adblock
import android.webkit.WebResourceResponse
import java.io.ByteArrayInputStream
/**
* A small subset of uBlock Origin's / AdGuard's named `$redirect=` resources.
*
* Blocking most tracking scripts outright is safe -- the page never had a live reference to
* them, so nothing calls into the empty response [AdBlockManager.blockedResponse] already
* serves. But a handful of scripts are different: page code assumes the object they define
* (`ga`, `dataLayer`, `googletag`, ...) exists and calls it immediately, so an empty response
* leaves that call throwing a `ReferenceError` that can break unrelated script on the same page.
* These stubs define just enough of that surface to no-op safely, which is what the named
* `$redirect=` resource is for in the filter list that requested it.
*
* Anything not in this map still gets blocked -- just with the generic typed-empty response
* instead of an exact stub, same as before this existed.
*/
internal object RedirectResources {
private val EMPTY_JS = "".toByteArray()
private val GOOGLE_ANALYTICS_GA = """
(function(){function g(){}var t=function(){t.c[t.q.push(arguments)-1]=null};
t.q=[];t.c=[];window.ga=window.ga||t;window._gaq=window._gaq||{push:g};
window.GoogleAnalyticsObject=window.GoogleAnalyticsObject||'ga';})();
""".trimIndent().toByteArray()
private val GOOGLE_ANALYTICS_ANALYTICS = """
(function(){function s(){}var g={
create:function(){return{get:s,set:s,send:s}},getAll:function(){return[]},
getByName:function(){return null},remove:s};
window.ga=window.ga||function(){(window.ga.q=window.ga.q||[]).push(arguments)};
window.ga.create=g.create;window.ga.getAll=g.getAll;window.ga.getByName=g.getByName;
window.ga.remove=g.remove;window.gaplugins={Linker:function(){this.decorate=s;this.autoLink=s}};
})();
""".trimIndent().toByteArray()
private val GOOGLETAGMANAGER_GTM = """
(function(){window.dataLayer=window.dataLayer||[];
window.dataLayer.push=function(){};
window.google_tag_manager=window.google_tag_manager||{};})();
""".trimIndent().toByteArray()
private val GOOGLETAGSERVICES_GPT = """
(function(){function s(){}function u(){return{addService:function(){return this},
setTargeting:s,setCollapseEmptyDiv:s,addEventListener:function(){return this},
removeEventListener:s,get:s,set:s,getSlotElementId:function(){return''},
getSlotId:function(){return{getDomId:function(){return''}}}}}
window.googletag=window.googletag||{};
window.googletag.cmd=window.googletag.cmd||{push:function(fn){try{fn()}catch(e){}}};
var pubads=u();
window.googletag.pubads=function(){return pubads};
window.googletag.defineSlot=function(){return u()};
window.googletag.defineOutOfPageSlot=function(){return u()};
window.googletag.enableServices=s;window.googletag.display=s;
window.googletag.apiReady=true;})();
""".trimIndent().toByteArray()
private val NOOP_CSS = "".toByteArray()
private val NOOP_HTML = "<!doctype html><html><head></head><body></body></html>".toByteArray()
private val NOOP_JSON = "{}".toByteArray()
private val STUBS: Map<String, Pair<String, ByteArray>> = buildMap {
// Names as they appear across uBO's and AdGuard's resources.txt / redirect syntax.
for (name in listOf("noopjs", "noop.js", "noop-js")) put(name, "application/javascript" to EMPTY_JS)
for (name in listOf("noopcss", "noop.css", "noop-css")) put(name, "text/css" to NOOP_CSS)
for (name in listOf("noop.txt", "nooptext")) put(name, "text/plain" to "".toByteArray())
for (name in listOf("noop.json", "empty")) put(name, "application/json" to NOOP_JSON)
for (name in listOf("noop.html", "click2load.html")) put(name, "text/html" to NOOP_HTML)
for (name in listOf("google-analytics_ga.js", "google-analytics.com/ga.js", "ga.js"))
put(name, "application/javascript" to GOOGLE_ANALYTICS_GA)
for (name in listOf(
"google-analytics_analytics.js", "google-analytics.com/analytics.js",
"googletagmanager_gtag.js", "googletagmanager-gtag"
)) put(name, "application/javascript" to GOOGLE_ANALYTICS_ANALYTICS)
for (name in listOf("googletagmanager_gtm.js", "googletagmanager.com/gtm.js", "gtm.js"))
put(name, "application/javascript" to GOOGLETAGMANAGER_GTM)
for (name in listOf("googletagservices_gpt.js", "googletagservices.com/gpt.js", "gpt.js"))
put(name, "application/javascript" to GOOGLETAGSERVICES_GPT)
for (name in listOf("1x1.gif", "1x1-transparent.gif", "2x2.png", "3x2.png", "32x32.png"))
put(name, "image/gif" to AdBlockManager.transparentGifBytes)
}
/** Null when [name] is not one of the resources we ship a stub for. */
fun response(name: String, headers: Map<String, String>): WebResourceResponse? {
val stub = STUBS[name.lowercase()] ?: return null
val (mimeType, body) = stub
val encoding = if (mimeType.startsWith("image/")) null else "utf-8"
return WebResourceResponse(mimeType, encoding, 200, "OK", headers, ByteArrayInputStream(body))
}
}
@@ -0,0 +1,239 @@
package app.seb3thehacker.gearslip.browser.adblock
/**
* YouTube ad blocking, plus the cosmetic cleanup that rides along with it.
*
* Filter lists cannot touch YouTube's video ads. The ad and the video are delivered by the same
* hosts, so there is no request to block and no element to hide: the player decides to show an
* ad because its own API response told it to. What can be done is what uBlock Origin's
* scriptlets do — edit that response before the player reads it.
*
* Three hooks, all installed before any page script runs:
* - `ytInitialPlayerResponse`, the JSON embedded in the page for the first video watched;
* - `fetch`, used by the single-page app for every later video;
* - `JSON.parse`, a backstop for whatever path the two above miss.
*
* Each strips the keys the player uses to schedule ads. A periodic sweep then clicks any skip
* button and seeks past anything that still slipped through, which also covers ads stitched
* into the stream server-side, where nothing client-side can remove them cleanly. All of this,
* plus hiding the ad's own banner/in-feed markup, is gated on "Block YouTube video ads": it's
* inherently maintenance-prone (YouTube changes these field and class names when it wants to,
* and may respond with an anti-adblock notice), so it needs to be switchable off as a unit
* without losing ordinary filter-list blocking.
*
* The Shorts shelf and filter chip row are a separate concern -- cosmetic preference, not ad
* blocking -- so they sit behind their own "Custom site tweaks" setting instead, independent of
* whether YouTube ad blocking is on. The floating menu button covering a page's own fixed
* bottom bar is handled generically for every site in [CosmeticFilterBridge], not here.
*/
object YouTubeAdBlock {
val INJECTION_JS: String = """
(function () {
try {
var host = location.hostname || '';
if (!/(^|\.)(youtube\.com|youtube-nocookie\.com|youtubekids\.com)${'$'}/.test(host)) {
return;
}
var bridge = window.${CosmeticFilterBridge.BRIDGE_OBJECT_NAME};
if (!bridge) { return; }
var adBlockingOn = typeof bridge.youtubeBlockingEnabled === 'function' &&
bridge.youtubeBlockingEnabled(host);
var tweaksOn = typeof bridge.customSiteTweaksEnabled === 'function' &&
bridge.customSiteTweaksEnabled();
if (!adBlockingOn && !tweaksOn) { return; }
if (window.__gearslipYouTube) { return; }
window.__gearslipYouTube = true;
var styleRules = '';
if (adBlockingOn) {
// The fields the player consults to schedule ad breaks.
var AD_KEYS = [
'adPlacements', 'playerAds', 'adSlots', 'adBreakHeartbeatParams'
];
var stripKeys = function (object) {
if (!object || typeof object !== 'object') { return; }
for (var i = 0; i < AD_KEYS.length; i++) {
if (AD_KEYS[i] in object) {
try { delete object[AD_KEYS[i]]; } catch (e) {}
}
}
};
// Deliberately shallow. A player response is a large object and walking all of
// it on every parse would cost more than the ads do.
var prune = function (payload) {
if (!payload || typeof payload !== 'object') { return payload; }
if (Array.isArray(payload)) {
for (var i = 0; i < payload.length && i < 50; i++) { prune(payload[i]); }
return payload;
}
stripKeys(payload);
if (payload.playerResponse) { stripKeys(payload.playerResponse); }
if (payload.response) { stripKeys(payload.response); }
if (payload.playerData) { stripKeys(payload.playerData); }
return payload;
};
// 1. The first video: YouTube assigns this from an inline script in the document.
try {
var initial;
Object.defineProperty(window, 'ytInitialPlayerResponse', {
configurable: true,
get: function () { return initial; },
set: function (value) { initial = prune(value); }
});
} catch (e) {}
// 2. Every later video in the single-page app.
var originalFetch = window.fetch;
if (typeof originalFetch === 'function') {
window.fetch = function (input, init) {
var url = '';
try {
url = (typeof input === 'string') ? input : ((input && input.url) || '');
} catch (e) {}
var pending = originalFetch.apply(this, arguments);
// Only the player call carries ad scheduling. /next is the comments and
// related-video payload: large, and re-serialising it buys nothing.
if (url.indexOf('/youtubei/v1/player') < 0) {
return pending;
}
return pending.then(function (response) {
return response.text().then(function (text) {
var body = text;
// Cheap string test first: most responses have nothing to strip, and
// parsing plus re-serialising a player response is not free.
if (text.indexOf('"adPlacements"') >= 0 ||
text.indexOf('"playerAds"') >= 0 ||
text.indexOf('"adSlots"') >= 0) {
try {
var data = JSON.parse(text);
prune(data);
body = JSON.stringify(data);
} catch (e) {
body = text;
}
}
// Content-Length describes the original bytes, and Content-Encoding a
// compression this body no longer has. Copying either onto the edited
// body leaves the player waiting on bytes that never arrive.
var headers;
try {
headers = new Headers(response.headers);
headers.delete('content-length');
headers.delete('content-encoding');
} catch (e) {
headers = undefined;
}
return new Response(body, {
status: response.status,
statusText: response.statusText,
headers: headers
});
}).catch(function () { return response; });
});
};
}
// 3. Backstop for any path the first two miss.
var originalParse = JSON.parse;
JSON.parse = function (text, reviver) {
var result = originalParse.apply(this, arguments);
try { prune(result); } catch (e) {}
return result;
};
// Hide the banner, overlay and in-feed ads, which are ordinary elements. The
// :has() rules collapse the wrapping feed section too, instead of leaving an
// empty gap where the ad used to be.
styleRules +=
'ytd-promoted-sparkles-web-renderer,ytd-promoted-video-renderer,' +
'ytd-display-ad-renderer,ytd-ad-slot-renderer,ytd-in-feed-ad-layout-renderer,' +
'ytd-banner-promo-renderer,ytd-statement-banner-renderer,' +
'ytd-companion-slot-renderer,ytd-action-companion-ad-renderer,' +
'ytm-promoted-video-renderer,ytm-companion-ad-renderer,' +
'ytm-promoted-sparkles-web-renderer,ytm-search-promoted-video-renderer,' +
'.ytp-ad-overlay-container,.ytp-ad-overlay-slot,.ytp-ad-message-container,' +
'#masthead-ad,#player-ads,' +
'ytm-item-section-renderer:has(>*>ytm-companion-ad-renderer),' +
'ytm-item-section-renderer:has(>*>ytm-promoted-sparkles-web-renderer),' +
'ytm-item-section-renderer:has(>*>ytm-promoted-video-renderer),' +
'ytm-item-section-renderer:has(>*>ytm-search-promoted-video-renderer),' +
'ytm-item-section-renderer:has(>*>ytd-ad-slot-renderer),' +
// The "desktop site" grid feed uses different wrapper elements entirely: each
// card is a ytd-rich-item-renderer grid cell, so hiding just the ad renderer
// inside it still leaves an empty cell-shaped hole in the grid.
'ytd-rich-item-renderer:has(ytd-display-ad-renderer),' +
'ytd-rich-item-renderer:has(ytd-ad-slot-renderer),' +
'ytd-rich-item-renderer:has(ytd-promoted-sparkles-web-renderer),' +
'ytd-rich-item-renderer:has(ytd-statement-banner-renderer),' +
'ytd-rich-item-renderer:has(ytd-banner-promo-renderer),' +
// Ads can also occupy a whole grid row, or a section above/between rows, not
// just a single cell -- same empty-hole problem, one level up each time.
'ytd-rich-grid-row:has(ytd-display-ad-renderer),' +
'ytd-rich-grid-row:has(ytd-ad-slot-renderer),' +
'ytd-rich-grid-row:has(ytd-promoted-sparkles-web-renderer),' +
'ytd-rich-grid-row:has(ytd-statement-banner-renderer),' +
'ytd-rich-grid-row:has(ytd-banner-promo-renderer),' +
'ytd-rich-section-renderer:has(ytd-ad-slot-renderer),' +
'ytd-rich-section-renderer:has(ytd-display-ad-renderer),' +
'ytd-rich-section-renderer:has(ytd-statement-banner-renderer),' +
'ytd-rich-section-renderer:has(ytd-banner-promo-renderer),' +
'ytd-ad-slot-renderer,ytd-primetime-promo-renderer' +
'{display:none!important}';
// Sweep for anything still playing: press skip, or seek to the end of the ad.
var adTicks = 0;
var sweep = function () {
try {
var skip = document.querySelector(
'.ytp-ad-skip-button,.ytp-ad-skip-button-modern,.ytp-skip-ad-button,' +
'.ytp-ad-skip-button-container button'
);
if (skip) { skip.click(); }
var player = document.querySelector('.html5-video-player');
if (player && player.classList.contains('ad-showing')) {
// Only seek once the ad state has held for two ticks. Seeking during the
// player's own transition makes it re-buffer, which looks like a stall.
adTicks++;
var video = player.querySelector('video');
if (adTicks >= 2 && video && isFinite(video.duration) && video.duration > 0) {
video.currentTime = video.duration;
}
} else {
adTicks = 0;
}
} catch (e) {}
};
setInterval(sweep, 700);
}
if (tweaksOn) {
styleRules +=
// The mobile site's fixed Home/Shorts/You bar and the "desktop site" grid's
// reel shelf section it sits inside of.
'ytm-pivot-bar-renderer,' +
'ytd-rich-section-renderer:has(ytd-reel-shelf-renderer),' +
// Shorts: a different, faster-cut format than the rest of the feed, and its
// vertical-video shelf doesn't fit a landscape car display well either way.
'ytm-reel-shelf-renderer,ytm-rich-shelf-renderer:has(ytm-reel-item-renderer),' +
'ytm-reel-item-renderer,ytd-reel-shelf-renderer,' +
// The "All / Podcasts / News / ..." filter chip row above the feed.
'ytm-chip-bar-renderer,ytm-feed-filter-chip-bar-renderer' +
'{display:none!important}';
}
if (styleRules) {
var style = document.createElement('style');
style.setAttribute('data-gearslip', 'youtube');
style.textContent = styleRules;
var root = document.head || document.documentElement;
if (root) { root.appendChild(style); }
}
} catch (e) {}
})();
""".trimIndent()
}
@@ -23,19 +23,19 @@ data class Caller(val number: String, val name: String?) {
sealed interface CallUiState { sealed interface CallUiState {
data object None : CallUiState data object None : CallUiState
data class Ringing(val caller: Caller, val canDecline: Boolean) : CallUiState data class Ringing(val caller: Caller, val canDecline: Boolean) : CallUiState
data class Active(val caller: Caller?) : CallUiState /** [since] is the uptime the call went off-hook, for the timer. */
data class Active(val caller: Caller?, val since: Long) : CallUiState
} }
/** /**
* Tracks the phone's call state for the car screen, and answers or declines through the two * Tracks the phone's call state for the car screen, and answers or declines through the
* doors a sideloaded app can use: [android.telecom.TelecomManager.acceptRingingCall] to answer, * doors a sideloaded app can use: [android.telecom.TelecomManager.acceptRingingCall] to answer,
* and [GearslipCallScreening]'s response to decline one that is still ringing. * [GearslipCallScreening]'s response to decline one that is still ringing, and
* [android.telecom.TelecomManager.endCall] to hang up, which needs only ANSWER_PHONE_CALLS for an
* ordinary carrier call. Mute is the phone's microphone mute, put back when the call ends.
* *
* Ending a call already answered needs MODIFY_PHONE_STATE, a privileged permission Gearslip * Call audio stays on the phone's normal voice path; nothing here sends it over the USB link,
* cannot hold, so a call taken here is finished the way it would be without Gearslip at all - * which only ever carries media playback.
* from the phone itself, a paired headset, or the car's own Bluetooth if it has any. Call audio
* stays on the phone's normal voice path too; nothing here sends it over the USB link, which
* only ever carries media playback.
*/ */
object CarCalls { object CarCalls {
@@ -47,9 +47,14 @@ object CarCalls {
private var pendingRespond: ((allow: Boolean) -> Unit)? = null private var pendingRespond: ((allow: Boolean) -> Unit)? = null
private var ringingCaller: Caller? = null private var ringingCaller: Caller? = null
private val _muted = MutableStateFlow(false)
val muted: StateFlow<Boolean> = _muted.asStateFlow()
private var audio: android.media.AudioManager? = null
fun start(context: Context) { fun start(context: Context) {
if (watcher != null) return if (watcher != null) return
if (context.checkSelfPermission(Manifest.permission.READ_PHONE_STATE) != PackageManager.PERMISSION_GRANTED) return if (context.checkSelfPermission(Manifest.permission.READ_PHONE_STATE) != PackageManager.PERMISSION_GRANTED) return
audio = context.getSystemService(android.media.AudioManager::class.java)
val manager = context.getSystemService(TelephonyManager::class.java) ?: return val manager = context.getSystemService(TelephonyManager::class.java) ?: return
val callback = object : TelephonyCallback(), TelephonyCallback.CallStateListener { val callback = object : TelephonyCallback(), TelephonyCallback.CallStateListener {
override fun onCallStateChanged(state: Int) = onPhoneState(state) override fun onCallStateChanged(state: Int) = onPhoneState(state)
@@ -66,6 +71,7 @@ object CarCalls {
watcher = null watcher = null
pendingRespond = null pendingRespond = null
ringingCaller = null ringingCaller = null
unmute()
_state.value = CallUiState.None _state.value = CallUiState.None
} }
@@ -74,11 +80,15 @@ object CarCalls {
TelephonyManager.CALL_STATE_IDLE -> { TelephonyManager.CALL_STATE_IDLE -> {
pendingRespond = null pendingRespond = null
ringingCaller = null ringingCaller = null
// A mute left on would silence the microphone for everything after the call.
unmute()
_state.value = CallUiState.None _state.value = CallUiState.None
} }
TelephonyManager.CALL_STATE_OFFHOOK -> { TelephonyManager.CALL_STATE_OFFHOOK -> {
pendingRespond = null pendingRespond = null
_state.value = CallUiState.Active(ringingCaller) if (_state.value !is CallUiState.Active) {
_state.value = CallUiState.Active(ringingCaller, android.os.SystemClock.uptimeMillis())
}
} }
TelephonyManager.CALL_STATE_RINGING -> { TelephonyManager.CALL_STATE_RINGING -> {
// The number itself only ever arrives through the screening service; if this // The number itself only ever arrives through the screening service; if this
@@ -109,6 +119,39 @@ object CarCalls {
runCatching { respond(false) }.onFailure { GearslipLog.w("calls: decline failed: ${it.message}") } runCatching { respond(false) }.onFailure { GearslipLog.w("calls: decline failed: ${it.message}") }
} }
/** A call Gearslip itself is placing, so the call pill can say who it's to. */
fun outgoingCall(context: Context, number: String) {
ringingCaller = Caller(number, lookUpName(context, number))
}
fun end(context: Context) {
if (context.checkSelfPermission(Manifest.permission.ANSWER_PHONE_CALLS) != PackageManager.PERMISSION_GRANTED) {
GearslipLog.w("calls: ending a call needs ANSWER_PHONE_CALLS, which was never granted")
return
}
val telecom = context.getSystemService(TelecomManager::class.java) ?: return
@Suppress("DEPRECATION")
runCatching { telecom.endCall() }
.onSuccess { if (!it) GearslipLog.w("calls: the phone wouldn't end the call") }
.onFailure { GearslipLog.w("calls: end failed: ${it.message}") }
}
fun toggleMute() {
val manager = audio ?: return
val want = !_muted.value
runCatching { manager.isMicrophoneMute = want }
.onFailure { GearslipLog.w("calls: mute failed: ${it.message}") }
// Read back rather than trusted: some phones let the call app overrule it.
_muted.value = manager.isMicrophoneMute
if (_muted.value != want) GearslipLog.w("calls: the phone didn't take the mute change")
}
private fun unmute() {
if (!_muted.value) return
runCatching { audio?.isMicrophoneMute = false }
_muted.value = false
}
fun answer(context: Context) { fun answer(context: Context) {
if (context.checkSelfPermission(Manifest.permission.ANSWER_PHONE_CALLS) != PackageManager.PERMISSION_GRANTED) { if (context.checkSelfPermission(Manifest.permission.ANSWER_PHONE_CALLS) != PackageManager.PERMISSION_GRANTED) {
GearslipLog.w("calls: answer needs ANSWER_PHONE_CALLS, which was never granted") GearslipLog.w("calls: answer needs ANSWER_PHONE_CALLS, which was never granted")
@@ -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}."
} }
@@ -39,6 +39,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource import androidx.compose.ui.res.vectorResource
import app.seb3thehacker.gearslip.R import app.seb3thehacker.gearslip.R
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -69,7 +70,8 @@ fun AssistantOverlay(modifier: Modifier = Modifier) {
modifier modifier
.fillMaxWidth() .fillMaxWidth()
.drawBehind { .drawBehind {
drawRect(Brush.verticalGradient(0f to Color.Transparent, 0.45f to Color.Black.copy(alpha = 0.6f), 1f to Color.Black.copy(alpha = 0.85f))) // Dark enough by the text that nothing on the screen behind reads through it.
drawRect(Brush.verticalGradient(0f to Color.Transparent, 0.3f to Color.Black.copy(alpha = 0.8f), 1f to Color.Black.copy(alpha = 0.92f)))
// A wide, flat oval of light rising from the bottom edge. // A wide, flat oval of light rising from the bottom edge.
scale(1f, 0.22f, Offset(size.width / 2, size.height)) { scale(1f, 0.22f, Offset(size.width / 2, size.height)) {
drawCircle( drawCircle(
@@ -86,8 +88,12 @@ fun AssistantOverlay(modifier: Modifier = Modifier) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp)) {
PulsingMic(listening) PulsingMic(listening)
Column(Modifier.weight(1f), horizontalAlignment = Alignment.CenterHorizontally) { Column(Modifier.weight(1f), horizontalAlignment = Alignment.CenterHorizontally) {
val listeningText = stringResource(R.string.assist_listening)
val momentText = stringResource(R.string.assist_one_moment)
val waiting = state.heard.isBlank() &&
(state.phase == CarAssistant.Phase.LISTENING || state.phase == CarAssistant.Phase.THINKING)
val (small, big) = when (state.phase) { val (small, big) = when (state.phase) {
CarAssistant.Phase.LISTENING -> "" to state.heard.ifBlank { if (state.ready) "Listening…" else "One moment…" } CarAssistant.Phase.LISTENING -> "" to state.heard.ifBlank { if (state.ready) listeningText else momentText }
CarAssistant.Phase.THINKING -> "" to state.heard.ifBlank { "…" } CarAssistant.Phase.THINKING -> "" to state.heard.ifBlank { "…" }
else -> state.heard to state.reply else -> state.heard to state.reply
} }
@@ -96,22 +102,33 @@ fun AssistantOverlay(modifier: Modifier = Modifier) {
} }
Text( Text(
big, big,
color = if (big == "Listening…" || big == "One moment…" || big == "…") Color.White.copy(alpha = 0.7f) else Color.White, color = if (waiting) Color.White.copy(alpha = 0.7f) else Color.White,
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
maxLines = 2, maxLines = 2,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
// What to say, until the driver starts saying it.
if (state.phase == CarAssistant.Phase.LISTENING && state.ready && state.heard.isBlank()) {
Text(
stringResource(R.string.assist_examples),
color = Color.White.copy(alpha = 0.6f),
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
maxLines = 2,
modifier = Modifier.padding(top = 4.dp),
)
}
} }
if (state.missingPermission) { if (state.missingPermission) {
GsButton(tone = GsTone.Tonal, onClick = { GsButton(tone = GsTone.Tonal, onClick = {
context.startActivity( context.startActivity(
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", context.packageName, null)), Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", context.packageName, null)),
) )
}) { Text("Grant") } }) { Text(stringResource(R.string.assist_grant)) }
} }
GsIconButton(Icons.Filled.Close, "Dismiss", CarAssistant::cancel) GsIconButton(Icons.Filled.Close, stringResource(R.string.msg_dismiss), CarAssistant::cancel)
} }
} }
} }
@@ -11,6 +11,7 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.content.pm.PackageManager import android.content.pm.PackageManager
import app.seb3thehacker.gearslip.R
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import app.seb3thehacker.gearslip.GearslipLog import app.seb3thehacker.gearslip.GearslipLog
@@ -25,6 +26,11 @@ import kotlinx.coroutines.flow.StateFlow
* *
* When the car named its Bluetooth address, only that device counts. Otherwise any audio * When the car named its Bluetooth address, only that device counts. Otherwise any audio
* connection does, since there's no telling which device is the car. * connection does, since there's no telling which device is the car.
*
* Many cars switch Bluetooth music off while Android Auto runs and want it over Android Auto
* instead (a Skoda, a VW, an Audi, a Bolt). If the phone's Bluetooth audio to the car was up when
* the car showed Gearslip and drops after, that's what happened: this car's music goes through
* Gearslip from now on, and the car screen says so.
*/ */
object BluetoothAudioCheck { object BluetoothAudioCheck {
@@ -41,6 +47,8 @@ object BluetoothAudioCheck {
private var appContext: Context? = null private var appContext: Context? = null
private var armed = false private var armed = false
private var receiver: BroadcastReceiver? = null private var receiver: BroadcastReceiver? = null
/** Whether Bluetooth audio to the car was up when the car showed Gearslip; null until known. */
private var connectedAtStart: Boolean? = null
fun init(context: Context) { fun init(context: Context) {
appContext = context.applicationContext appContext = context.applicationContext
@@ -50,11 +58,14 @@ object BluetoothAudioCheck {
fun onProjecting() = main.post { fun onProjecting() = main.post {
if (armed) return@post if (armed) return@post
armed = true armed = true
connectedAtStart = null
audioToCar { connectedAtStart = it }
main.postDelayed(::start, WAIT_MS) main.postDelayed(::start, WAIT_MS)
} }
fun onCarDisconnected() = main.post { fun onCarDisconnected() = main.post {
armed = false armed = false
connectedAtStart = null
main.removeCallbacksAndMessages(null) main.removeCallbacksAndMessages(null)
stopWatching() stopWatching()
_missing.value = false _missing.value = false
@@ -97,36 +108,58 @@ object BluetoothAudioCheck {
private fun check() { private fun check() {
if (!armed) return if (!armed) return
val context = appContext ?: return val context = appContext ?: return
if (CarSettings.pipeAudio.value) { if (CarSettings.pipesNow) {
set(false, "USB audio is on") set(false, "audio goes through Gearslip")
return return
} }
if (!CarSettings.bluetoothWarning.value) { audioToCar { ok ->
set(false, "the warning is turned off") if (ok == null) return@audioToCar
return if (!ok && connectedAtStart == true) return@audioToCar switchToGearslip()
if (!CarSettings.bluetoothWarning.value) {
set(false, "the warning is turned off")
return@audioToCar
}
set(!ok, if (ok) "connected to the car" else "not connected to the car")
} }
}
/** The car dropped Bluetooth music once Gearslip was up: send this car's music through Gearslip. */
private fun switchToGearslip() {
val car = CarBluetooth.carAddress ?: return
val context = appContext ?: return
if (!app.seb3thehacker.gearslip.audio.CarAudio.sinkOffered) return
log.i("Bluetooth audio: the car turned it off with Gearslip running, so this car's music goes through Gearslip")
CarSettings.rememberAudioCar(car)
set(false, "audio goes through Gearslip")
app.seb3thehacker.gearslip.audio.CarAudio.request()
CarToasts.show(this, context.getString(R.string.car_audio_switched), androidx.car.app.CarToast.LENGTH_LONG)
}
/**
* Whether the phone's Bluetooth audio is connected to the car, on the main thread: null when
* that can't be told (no permission). Bluetooth switched off counts as not connected.
*/
@SuppressLint("MissingPermission")
private fun audioToCar(result: (Boolean?) -> Unit) {
val context = appContext ?: return result(null)
if (context.checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED) { if (context.checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED) {
log.i("can't check the audio connection: the Bluetooth permission isn't granted") log.i("can't check the audio connection: the Bluetooth permission isn't granted")
return return result(null)
}
val adapter = context.getSystemService(BluetoothManager::class.java)?.adapter ?: return
if (!adapter.isEnabled) {
set(true, "Bluetooth is off")
return
} }
val adapter = context.getSystemService(BluetoothManager::class.java)?.adapter ?: return result(null)
if (!adapter.isEnabled) return result(false)
val car = CarBluetooth.carAddress?.uppercase() val car = CarBluetooth.carAddress?.uppercase()
adapter.getProfileProxy(context, object : BluetoothProfile.ServiceListener { val asked = adapter.getProfileProxy(context, object : BluetoothProfile.ServiceListener {
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
val connected = runCatching { proxy.connectedDevices }.getOrDefault(emptyList()) val connected = runCatching { proxy.connectedDevices }.getOrDefault(emptyList())
adapter.closeProfileProxy(profile, proxy) adapter.closeProfileProxy(profile, proxy)
val ok = if (car != null) connected.any { it.address.uppercase() == car } else connected.isNotEmpty() val ok = if (car != null) connected.any { it.address.uppercase() == car } else connected.isNotEmpty()
main.post { main.post { result(ok) }
set(!ok, if (ok) "connected to the car" else "not connected to the car")
}
} }
override fun onServiceDisconnected(profile: Int) {} override fun onServiceDisconnected(profile: Int) {}
}, BluetoothProfile.A2DP) }, BluetoothProfile.A2DP)
if (!asked) result(null)
} }
private fun set(missing: Boolean, why: String) { private fun set(missing: Boolean, why: String) {
@@ -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
@@ -36,7 +38,7 @@ import androidx.compose.ui.unit.dp
@Composable @Composable
internal fun BluetoothPairOverlay() { internal fun BluetoothPairOverlay() {
val prompt by CarBluetooth.prompt.collectAsState() val prompt by CarBluetooth.prompt.collectAsState()
val bluetoothAudio = !CarSettings.pipeAudio.collectAsState().value val bluetoothAudio = !CarSettings.pipeAudio.collectAsState().value && !CarSettings.thisCarPipes.collectAsState().value
when (prompt) { when (prompt) {
CarBluetooth.Prompt.NONE -> { CarBluetooth.Prompt.NONE -> {
val missing by BluetoothAudioCheck.missing.collectAsState() val missing by BluetoothAudioCheck.missing.collectAsState()
@@ -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,
) )
} }
} }
@@ -0,0 +1,182 @@
package app.seb3thehacker.gearslip.car
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import app.seb3thehacker.gearslip.R
import app.seb3thehacker.gearslip.browser.Bookmark
import app.seb3thehacker.gearslip.browser.BrowserSettings
import app.seb3thehacker.gearslip.browser.SiteIcons
import app.seb3thehacker.gearslip.car.theme.GsIconBox
import app.seb3thehacker.gearslip.car.theme.GsTone
import app.seb3thehacker.gearslip.car.theme.gsColors
import app.seb3thehacker.gearslip.car.theme.gsShape
/** How wide a tile is, icon and label together; the grid fits as many as the screen allows. */
private val TILE_WIDTH = 112.dp
private val ICON_SIZE = 64.dp
private val SEARCH_MAX_WIDTH = 560.dp
/** Five tiles across at most, so a short list sits in the middle instead of along the left. */
private val GRID_MAX_WIDTH = TILE_WIDTH * 5
/** Room above each tile for the remove button, which sits on the icon's corner. */
private val REMOVE_ROOM = 10.dp
/**
* What the browser shows before a page is open: a search bar, and the bookmarks as big tiles.
* Holding a tile shows a remove button on every tile, as a launcher does; tapping anywhere
* else, or Done, puts them away. Adding happens from the browser's star, or on the phone.
*/
@Composable
internal fun BrowserStartPage(onOpen: (String) -> Unit, onSearch: () -> Unit, onAdd: () -> Unit) {
val bookmarks by BrowserSettings.bookmarks.collectAsState()
var editing by remember { mutableStateOf(false) }
if (bookmarks.isEmpty()) editing = false
// A narrow, centred column: on a wide car screen a bar from edge to edge reads as a toolbar
// rather than a search box, and tiles spread across it look scattered.
Column(
Modifier.fillMaxSize().padding(horizontal = 16.dp).padding(top = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
SearchBar(onSearch)
LazyVerticalGrid(
columns = GridCells.Adaptive(TILE_WIDTH),
contentPadding = PaddingValues(bottom = 16.dp),
horizontalArrangement = Arrangement.Center,
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.widthIn(max = GRID_MAX_WIDTH).fillMaxSize(),
) {
items(bookmarks, key = { it.url }) { bookmark ->
BookmarkTile(
bookmark,
editing = editing,
onClick = { if (editing) editing = false else onOpen(bookmark.url) },
onHold = { editing = true },
onRemove = { BrowserSettings.removeBookmark(bookmark.url) },
)
}
item(key = "add") {
Box(Modifier.padding(top = REMOVE_ROOM)) {
if (editing) {
Tile(stringResource(R.string.web_done), onClick = { editing = false }) {
Icon(Icons.Filled.Close, null, Modifier.size(28.dp))
}
} else {
Tile(stringResource(R.string.web_add_bookmark), onClick = onAdd) {
Icon(Icons.Filled.Add, null, Modifier.size(28.dp))
}
}
}
}
}
}
}
/** Looks like a text field, but opens the car keyboard: the same as the header's Search. */
@Composable
private fun SearchBar(onSearch: () -> Unit) {
val colors = gsColors(GsTone.Neutral)
GsIconBox(onSearch, Modifier.widthIn(max = SEARCH_MAX_WIDTH).fillMaxWidth().height(56.dp), colors, shape = CircleShape) {
Row(Modifier.fillMaxWidth().padding(horizontal = 20.dp), verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Filled.Search, null, Modifier.size(24.dp))
Text(
stringResource(R.string.web_search),
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(start = 12.dp),
)
}
}
}
@Composable
private fun BookmarkTile(bookmark: Bookmark, editing: Boolean, onClick: () -> Unit, onHold: () -> Unit, onRemove: () -> Unit) {
val context = LocalContext.current
val host = bookmark.host
val version by SiteIcons.version.collectAsState()
LaunchedEffect(host) { SiteIcons.fetch(context, host) }
val icon = remember(host, version) { SiteIcons.icon(context, host) }
// The remove button overhangs the icon's corner, into the room left above it, so nothing
// clips it: neither the grid's edge nor the next row.
Box(Modifier.width(TILE_WIDTH).padding(top = REMOVE_ROOM)) {
Tile(bookmark.title, onClick = onClick, onHold = onHold) {
if (icon != null) {
Image(icon.asImageBitmap(), null, Modifier.size(40.dp).clip(gsShape()))
} else {
Text(
bookmark.title.firstOrNull()?.uppercase() ?: "?",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
)
}
}
if (editing) {
GsIconBox(
onRemove,
Modifier.align(Alignment.TopCenter).offset(x = ICON_SIZE / 2, y = -REMOVE_ROOM).size(28.dp),
gsColors(GsTone.Danger),
shape = CircleShape,
) {
Icon(Icons.Filled.Close, stringResource(R.string.web_remove_bookmark, bookmark.title), Modifier.size(18.dp))
}
}
}
}
/** An icon box with its label under it, the start page's one shape. */
@Composable
private fun Tile(label: String, onClick: () -> Unit, onHold: (() -> Unit)? = null, icon: @Composable () -> Unit) {
Column(Modifier.width(TILE_WIDTH), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(6.dp)) {
GsIconBox(onClick, Modifier.size(ICON_SIZE), gsColors(GsTone.Tonal), onLongClick = onHold) { icon() }
Text(
label,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp),
)
}
}
@@ -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
@@ -33,21 +35,17 @@ import app.seb3thehacker.gearslip.call.CallUiState
import app.seb3thehacker.gearslip.call.CarCalls import app.seb3thehacker.gearslip.call.CarCalls
/** /**
* Sits above whatever the car is showing while a call is ringing or under way. Ringing takes the * Takes the whole car screen while a call is ringing, because nothing else matters more right
* whole screen, because nothing else matters more right now; an active call is a thin band along * now. A call under way moves into the nav bar instead (see NavCallPill), so it covers nothing.
* the top, since from here the driver can only see who it is, not end it - see [CarCalls].
*/ */
@Composable @Composable
fun CallOverlay(modifier: Modifier = Modifier) { fun CallOverlay() {
val state by CarCalls.state.collectAsState() val state by CarCalls.state.collectAsState()
val context = LocalContext.current.applicationContext val context = LocalContext.current.applicationContext
when (val s = state) { when (val s = state) {
// Covers the box regardless of where the caller aligned it, so the alignment below is
// only ever felt by the active-call banner, which is the one that isn't full-screen.
is CallUiState.Ringing -> IncomingCall(s, onAnswer = { CarCalls.answer(context) }, onDecline = CarCalls::decline) is CallUiState.Ringing -> IncomingCall(s, onAnswer = { CarCalls.answer(context) }, onDecline = CarCalls::decline)
is CallUiState.Active -> ActiveCallBanner(s, modifier) else -> {}
CallUiState.None -> {}
} }
} }
@@ -67,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,
) )
@@ -80,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,
) )
@@ -110,25 +108,3 @@ private fun CallButton(icon: ImageVector, label: String, color: Color, enabled:
Text(label, style = MaterialTheme.typography.labelLarge) Text(label, style = MaterialTheme.typography.labelLarge)
} }
} }
@Composable
private fun ActiveCallBanner(s: CallUiState.Active, modifier: Modifier = Modifier) {
Surface(
color = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = modifier.fillMaxWidth(),
) {
Row(
Modifier.padding(horizontal = 20.dp, vertical = 10.dp),
horizontalArrangement = Arrangement.Center,
) {
Icon(Icons.Filled.Call, contentDescription = null, modifier = Modifier.size(20.dp))
Spacer(Modifier.height(0.dp))
Text(
" On a call" + (s.caller?.let { " with ${it.label}" } ?: ""),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold,
)
}
}
}
@@ -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,11 +13,14 @@ 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
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.height
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -34,8 +39,11 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.material.icons.filled.Close
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import app.seb3thehacker.gearslip.GearslipLog import app.seb3thehacker.gearslip.GearslipLog
import app.seb3thehacker.gearslip.host.CarAppConnection import app.seb3thehacker.gearslip.host.CarAppConnection
@@ -59,13 +67,44 @@ fun CarAppStage(
) { ) {
val template by connection.template.collectAsStateWithLifecycle() val template by connection.template.collectAsStateWithLifecycle()
val cover = remember { ChromeCover() }
val navBar = LocalNavBarInset.current
Box(Modifier.fillMaxSize()) { Box(Modifier.fillMaxSize()) {
// The app's own drawing goes underneath; everything else is the host's rendering of // The app's own drawing goes underneath; everything else is the host's rendering of
// the template it described, laid over the top. // the template it described, laid over the top.
if (status.surfaceRequested) { androidx.compose.runtime.CompositionLocalProvider(LocalChromeCover provides cover, LocalCarAppConnection provides connection) {
AppSurface(connection, frame, template, Modifier.fillMaxSize()) // A place list's map is the host's to draw; any other map is the app's own surface.
val placeList = template as? androidx.car.app.model.PlaceListMapTemplate
if (placeList != null) {
HostPlaceMap(placeList, Modifier.fillMaxSize())
} else if (status.surfaceRequested) {
AppSurface(connection, frame, template, embedded, Modifier.fillMaxSize())
}
// 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(
template,
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))
} }
TemplateChrome(template, Modifier.fillMaxSize().padding(top = if (embedded) 0.dp else CHROME_BAR))
if (!embedded) ChromeBar(status, onDisconnect, Modifier.align(Alignment.TopCenter)) if (!embedded) ChromeBar(status, onDisconnect, Modifier.align(Alignment.TopCenter))
} }
} }
@@ -81,17 +120,51 @@ private fun AppSurface(
connection: CarAppConnection, connection: CarAppConnection,
frame: CarEnvironment.Frame, frame: CarEnvironment.Frame,
template: Template?, template: Template?,
embedded: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
var size by remember { mutableStateOf(0 to 0) } var size by remember { mutableStateOf(0 to 0) }
var bounds by remember { mutableStateOf<androidx.compose.ui.geometry.Rect?>(null) }
val cover = LocalChromeCover.current
// The app lays its map out around whatever the host covers, so a template change moves the // The app lays its map out around whatever the host covers, so a template change or a card
// visible area even though the surface itself hasn't changed. // changing size moves the visible area even though the surface itself hasn't changed.
LaunchedEffect(template, size) { val (width, height) = size
val (width, height) = size // A full-screen map takes the knob: turn to zoom, nudge to pan, press for its buttons. The
// home screen's map pane leaves it alone, so the knob still moves between the cards there.
val focusManager = androidx.compose.ui.platform.LocalFocusManager.current
val isMap = !embedded && !isContentTemplate(template)
DisposableEffect(connection, isMap, width, height) {
if (!isMap || width == 0 || height == 0) return@DisposableEffect onDispose {}
val knob = MapKnob(connection, width, height) { focusManager.clearFocus() }
CarKeys.knobTaker = knob
onDispose {
knob.release()
if (CarKeys.knobTaker === knob) CarKeys.knobTaker = null
}
}
val measured = bounds?.takeIf { width > 0 && height > 0 }?.let { cover?.visibleWithin(it, width, height) }
// The card column moves the map's centre but isn't taken out of the stable area: apps size
// their own overlays from it, and Vela's speedometer shrank to fit the strip right of the cards.
val cardsAtEnd = LocalCardsAtEnd.current
val cardEdge = cardEdge
val measuredStable = bounds?.takeIf { width > 0 && height > 0 }?.let { cover?.visibleWithin(it, width, height, skip = cardEdge) }
// A little room above whatever ends the map at the bottom (the nav bar, or the screen's edge):
// apps draw their own corner pieces (Waze's speedometer) right on the area's bottom edge,
// which left them sitting flush on the bar.
val bottomGap = with(androidx.compose.ui.platform.LocalDensity.current) { MAP_BOTTOM_GAP.roundToPx() }
LaunchedEffect(template, size, measured, measuredStable, cardsAtEnd, bottomGap) {
if (width > 0 && height > 0) { if (width > 0 && height > 0) {
val visible = visibleAreaFor(template, width, height) fun android.graphics.Rect.clearOfBar() = if (isEmpty) this else android.graphics.Rect(left, top, right, maxOf(top, bottom - bottomGap))
connection.reportAreas(visible = visible, stable = visible) val visible = visibleAreaFor(template, width, height, measured, cardsAtEnd).clearOfBar()
// Waze seems to centre its car in the stable area rather than the visible one, so it
// gets the cards taken out of that too. Test: see whether its car moves out from under them.
val stable = if (!isContentTemplate(template) && connection.appPackage != "com.waze") {
visibleAreaFor(template, width, height, measuredStable, cardsAtEnd).clearOfBar()
} else {
visible
}
connection.reportAreas(visible = visible, stable = stable)
} }
} }
@@ -101,7 +174,9 @@ private fun AppSurface(
// new one on it before releasing the old: the old display's disconnect then takes the new one's // new one on it before releasing the old: the old display's disconnect then takes the new one's
// connection with it and the map freezes for good. The nav bar hiding for the search keyboard // connection with it and the map freezes for good. The nav bar hiding for the search keyboard
// and the lyrics pane opening both resize this area. // and the lyrics pane opening both resize this area.
androidx.compose.foundation.layout.BoxWithConstraints(modifier) { androidx.compose.foundation.layout.BoxWithConstraints(
modifier.onGloballyPositioned { bounds = it.boundsInWindow() },
) {
val epoch by connection.surfaceEpoch.collectAsState() val epoch by connection.surfaceEpoch.collectAsState()
androidx.compose.runtime.key(constraints.maxWidth, constraints.maxHeight, epoch) { androidx.compose.runtime.key(constraints.maxWidth, constraints.maxHeight, epoch) {
SurfaceHost(connection, frame, onSize = { size = it }, Modifier.fillMaxSize()) SurfaceHost(connection, frame, onSize = { size = it }, Modifier.fillMaxSize())
@@ -189,34 +264,43 @@ private fun ChromeBar(
onDisconnect: () -> Unit, onDisconnect: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
// The app's name and a way out, nothing else: which template is showing, or whether the
// app has drawn yet, is for the log, not the driver.
Surface( Surface(
color = Color.Black.copy(alpha = 0.55f), color = MaterialTheme.colorScheme.surfaceContainer,
contentColor = Color.White, contentColor = MaterialTheme.colorScheme.onSurface,
modifier = modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth().height(CHROME_BAR),
) { ) {
Row( Row(
Modifier.padding(horizontal = 16.dp, vertical = 6.dp), Modifier.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Column(Modifier.weight(1f)) { Text(
Text( status.app ?: stringResource(R.string.stage_car_app),
status.app ?: "Car app", style = MaterialTheme.typography.titleSmall,
style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold,
fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f),
) )
val detail = buildList { GsIconButton(
status.template?.let { add(it) } androidx.compose.material.icons.Icons.Filled.Close,
add(if (status.surfaceAttached) "drawing" else "waiting for the app") status.app?.let { stringResource(R.string.stage_close, it) } ?: stringResource(R.string.stage_close_app),
}.joinToString(" - ") onDisconnect,
Text(detail, style = MaterialTheme.typography.bodySmall) size = 36.dp,
} iconSize = 20.dp,
GsButton(onClick = onDisconnect, tone = GsTone.Tonal) { Text("Stop") } shape = androidx.compose.foundation.shape.CircleShape,
)
} }
} }
} }
/** How far above the bottom of the map the area an app may draw in stops. */
private val MAP_BOTTOM_GAP = 12.dp
/** Height of [ChromeBar]; the template's own chrome starts below it. */ /** Height of [ChromeBar]; the template's own chrome starts below it. */
private val CHROME_BAR = 44.dp private val CHROME_BAR = 44.dp
/** Slower than this when the finger lifts and it was a drag that stopped, not a fling. */ /** Slower than this when the finger lifts and it was a drag that stopped, not a fling. */
private const val FLING_MIN_PX_S = 400f private const val FLING_MIN_PX_S = 400f
/** The app connection a template on screen belongs to: the map app's, or the one opened from the launcher. */
internal val LocalCarAppConnection = androidx.compose.runtime.staticCompositionLocalOf { CarServices.nav }
@@ -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 {
@@ -50,49 +57,154 @@ object CarBluetooth {
private val main = Handler(Looper.getMainLooper()) private val main = Handler(Looper.getMainLooper())
private var appContext: Context? = null private var appContext: Context? = null
/** This session's car: its channel, and how to open and write to it. */ /** This session's car: its channel, how to open and write to it, and who hears its answers. */
private class Car( private class Car(
val service: ServiceDiscovery.BluetoothService, val service: ServiceDiscovery.BluetoothService,
val open: () -> Unit, val open: () -> Unit,
val send: (messageId: Int, body: ByteArray) -> Unit, val send: (messageId: Int, body: ByteArray) -> Unit,
val answered: () -> Unit,
) )
@Volatile private var car: Car? = null @Volatile private var car: Car? = null
@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
_addressWanted.value = AppSettings.getString(context, KEY_ADDRESS_ASK, "").ifEmpty { null }
} }
/** Called once the car lists its services. Asks on the car screen only if there's pairing to do. */ private val _addressWanted = MutableStateFlow<String?>(null)
/**
* A car paired over Bluetooth connected while Gearslip didn't know the phone's address: the
* car's name, for the phone's home screen to ask for it. Null when there's nothing to ask.
* Issue #18: a 2021 Bolt hung up 25 seconds in until it was told which phone this is.
*/
val addressWanted: StateFlow<String?> = _addressWanted
/** Debug builds: shows the phone's card as if a car had just asked. */
fun askForAddressNow(context: Context) {
AppSettings.putString(context, KEY_ADDRESS_ASK, "Test car")
_addressWanted.value = "Test car"
}
/** The phone's card was answered or turned away: it doesn't come back for this car. */
fun addressAsked(context: Context) {
AppSettings.putString(context, KEY_ADDRESS_ASK, "")
_addressWanted.value = null
}
/**
* Once per car: it waits on the phone, and the car screen says so once. A car that keeps its
* screen isn't asked again on every drive: plenty of cars just wait for a tap on their icon.
*/
@SuppressLint("MissingPermission")
private fun askForAddress(device: BluetoothDevice, carAddress: String) {
val context = appContext ?: return
val asked = AppSettings.getString(context, KEY_ADDRESS_ASKED, "").split(",").filter { it.isNotEmpty() }
if (hash(carAddress) in asked) return
AppSettings.putString(context, KEY_ADDRESS_ASKED, (asked + hash(carAddress)).distinct().joinToString(","))
val name = runCatching { device.alias ?: device.name }.getOrNull()?.takeIf { it.isNotBlank() }
?: context.getString(R.string.bt_address_your_car)
AppSettings.putString(context, KEY_ADDRESS_ASK, name)
_addressWanted.value = name
log.i("paired, but the phone's address isn't known: asking on the phone")
main.post { CarToasts.show(this, context.getString(R.string.bt_address_car_note), CarToast.LENGTH_LONG) }
}
/**
* Called once the car lists its services. Asks on the car screen only if there's pairing to do.
* [answered] runs when the car answers a pairing request or the pairing finishes.
*/
fun onCarConnected( fun onCarConnected(
service: ServiceDiscovery.BluetoothService, service: ServiceDiscovery.BluetoothService,
open: () -> Unit, open: () -> Unit,
send: (messageId: Int, body: ByteArray) -> Unit, send: (messageId: Int, body: ByteArray) -> Unit,
answered: () -> Unit = {},
) { ) {
car = Car(service, open, send) car = Car(service, open, send, answered)
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 -> log.i("already paired with this car") 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")
askForAddress(device, service.carAddress)
}
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()
}
/**
* The car is keeping its own screen: do what Android Auto does on every connection, and tell
* it over Bluetooth which phone this is. Every Honda on file keeps its screen (a 2017 Pilot, a
* 2017 and a 2020 Civic; issues #43 and #57), and in each log the car's Bluetooth channel was
* never opened. Honda starts Android Auto with what its manuals call auto pairing, which runs
* on that channel, and a 2021 Bolt kept hanging up until it heard the phone's address (#18).
*
* - Paired, with the address typed in: the car already heard it at connect.
* - Paired, no address: open the channel, and ask for the address on the phone again.
* - Not paired: the Pair card waits on the car's screen, which this car won't give up, so
* pair as if the driver had tapped it. Android's pairing dialog still asks on the phone.
*
* True when the car was sent something it may answer.
*/
fun onScreenWithheld(): Boolean {
val c = car ?: return false
if (pairing || announcing) return false
val device = device() ?: return false
return when {
device.bondState == BluetoothDevice.BOND_BONDED -> {
askForAddress(device, c.service.carAddress)
announce("the car is keeping its screen")
true
}
device.bondState != BluetoothDevice.BOND_NONE -> false
declined(c.service.carAddress) -> false
else -> {
log.i("the car is keeping its screen and isn't paired with this phone - pairing, as Android Auto does")
main.post { pair() }
true
}
}
}
fun onCarDisconnected() { 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 +223,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 +249,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 +268,8 @@ 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}")
c.answered()
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 +287,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 +303,11 @@ 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 -> {
previous == BluetoothDevice.BOND_BONDING -> finish("Pairing didn't finish. Pair from the car's Bluetooth menu instead.") finish(R.string.bt_paired)
car?.answered?.invoke()
}
previous == BluetoothDevice.BOND_BONDING -> finish(R.string.bt_pair_unfinished)
} }
} }
} }
@@ -193,12 +315,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() {
@@ -209,7 +331,10 @@ object CarBluetooth {
/** The car as a Bluetooth device, or null (and logged) when Gearslip can't reach Bluetooth. */ /** The car as a Bluetooth device, or null (and logged) when Gearslip can't reach Bluetooth. */
private fun device(): BluetoothDevice? { private fun device(): BluetoothDevice? {
val context = appContext ?: return null val context = appContext ?: run {
log.w("can't check pairing: Bluetooth isn't set up yet")
return null
}
val address = car?.service?.carAddress ?: return null val address = car?.service?.carAddress ?: return null
if (context.checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED) { if (context.checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED) {
log.i("can't check pairing: the Bluetooth permission isn't granted") log.i("can't check pairing: the Bluetooth permission isn't granted")
@@ -227,12 +352,31 @@ 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)
if (tidy.isNotEmpty()) addressAsked(context)
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 +388,11 @@ 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"
/** The car's name while the phone's card asks for the address; empty when it doesn't. */
private const val KEY_ADDRESS_ASK = "bluetooth_address_ask"
/** Hashes of the cars already asked about, so each asks once. */
private const val KEY_ADDRESS_ASKED = "bluetooth_address_asked"
private const val KEY_PHONE_ADDRESS = "phone_bluetooth_address"
private const val CAR_REPLY_WAIT_MS = 5_000L private const val CAR_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
@@ -12,6 +14,10 @@ import androidx.compose.foundation.layout.Row
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.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -60,7 +66,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,27 +90,74 @@ 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 -> {
val w = s.data val w = s.data
WeatherIcon(w.code, w.isDay, size = 44.dp) WeatherIcon(w.code, w.isDay, size = 44.dp)
Column { Column(Modifier.weight(1f)) {
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,
maxLines = 1, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
) )
Text(w.place, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) Text(w.place, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1)
} }
NextHours(w.hours, Modifier.weight(1.3f))
} }
} }
} }
} }
} }
/**
* The next few hours beside the weather now, in the same card, so a glance answers "will it rain
* on the way" without opening Weather. As many as fit, up to five; none if fewer than three would.
*/
@Composable
private fun NextHours(all: List<HourForecast>, modifier: Modifier) {
val now = System.currentTimeMillis()
// The forecast is cached, so its first hours may already be past.
val hours = all.filter { it.time > now }
androidx.compose.foundation.layout.BoxWithConstraints(modifier) {
val fit = (maxWidth / HOUR_WIDTH).toInt().coerceAtMost(5)
if (fit < 3 || hours.size < 3) return@BoxWithConstraints
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.width(1.dp).height(64.dp).background(MaterialTheme.colorScheme.outlineVariant))
hours.take(fit).forEach { h ->
Column(
Modifier.weight(1f),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Text(
DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(h.time)).replace(":00", ""),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
)
WeatherIcon(h.code, h.isDay, size = 24.dp)
Text("${h.temp}°", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
// Kept as a blank line when dry, so every column lines up.
Text(
if (h.rainChance >= RAIN_SHOWN) "${h.rainChance}%" else " ",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
)
}
}
}
}
}
private val HOUR_WIDTH = 64.dp
/** Below this a chance of rain is noise on a glance card; the Weather screen still shows it. */
private const val RAIN_SHOWN = 20
@Composable @Composable
private fun AgendaCard(context: Context) { private fun AgendaCard(context: Context) {
val events by produceState<List<AgendaEvent>?>(cachedAgenda) { val events by produceState<List<AgendaEvent>?>(cachedAgenda) {
@@ -112,12 +165,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 +188,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 +202,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) {
@@ -0,0 +1,51 @@
package app.seb3thehacker.gearslip.car
import android.os.SystemClock
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
/**
* Full screen for the screen that asks for it (the browser, or a video in it): no header and no
* nav bar. A swipe in from the top or bottom edge brings the nav bar back for a few seconds,
* with a button to leave full screen; [revealed] says when.
*/
object CarFullscreen {
var active by mutableStateOf(false)
private set
var revealed by mutableStateOf(false)
private set
private var lastTouch = 0L
fun enter() {
active = true
revealed = false
}
fun exit() {
active = false
revealed = false
}
/** A swipe in from an edge: show the nav bar and the way out. */
fun reveal() {
if (!active) return
lastTouch = SystemClock.uptimeMillis()
revealed = true
}
/** Any touch keeps a revealed bar up, so it doesn't slide away under a finger reaching for it. */
fun touch() {
if (revealed) lastTouch = SystemClock.uptimeMillis()
}
/** Called on a timer; hides the bar again after [REVEAL_MS] without a touch. */
fun tick() {
if (revealed && SystemClock.uptimeMillis() - lastTouch >= REVEAL_MS) revealed = false
}
private const val REVEAL_MS = 4_000L
}
@@ -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
@@ -93,27 +98,57 @@ fun CarHome() {
if (onHome) seenHome[0] = true if (onHome) seenHome[0] = true
} }
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()
// 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 outer padding on any side, split screen or not - only the gap between the two panes // The map runs edge to edge underneath; the player floats over one side of it as a card, with
// when the player is sharing the screen with the map. // a gap all round. The map's surface keeps its size when the card comes and goes, so the app
Row( // only re-centres its map instead of drawing onto a new surface.
Modifier.fillMaxSize(), BoxWithConstraints(Modifier.fillMaxSize()) {
horizontalArrangement = Arrangement.spacedBy(8.dp), 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) { androidx.compose.animation.AnimatedVisibility(
val side = Modifier.weight(1f).fillMaxHeight() splitScreen,
if (panel == SidePanel.LYRICS) SideLyrics(now, side) else DockedPlayer(now, side) 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
@@ -44,6 +46,12 @@ object CarKeyboardVisibility {
val state = mutableStateOf(false) val state = mutableStateOf(false)
} }
/**
* True for the car UI the phone's own car preview draws. The phone is in hand there, so a
* [CarKeyboard] draws nothing and the phone's keyboard does the typing instead (see [PhoneTyping]).
*/
val LocalCarPreview = compositionLocalOf { false }
/** /**
* A keyboard the host draws itself. * A keyboard the host draws itself.
* *
@@ -73,10 +81,11 @@ fun CarKeyboard(
) { ) {
var symbols by remember { mutableStateOf(false) } var symbols by remember { mutableStateOf(false) }
var shifted by remember { mutableStateOf(false) } var shifted by remember { mutableStateOf(false) }
val preview = LocalCarPreview.current
DisposableEffect(Unit) { DisposableEffect(preview) {
CarKeyboardVisibility.state.value = true if (!preview) CarKeyboardVisibility.state.value = true
onDispose { CarKeyboardVisibility.state.value = false } onDispose { if (!preview) CarKeyboardVisibility.state.value = false }
} }
// The same field on the phone, for typing on its own keyboard: see [PhoneTyping]. // The same field on the phone, for typing on its own keyboard: see [PhoneTyping].
@@ -88,6 +97,7 @@ fun CarKeyboard(
} }
DisposableEffect(phoneField) { onDispose { PhoneTyping.close(phoneField) } } DisposableEffect(phoneField) { onDispose { PhoneTyping.close(phoneField) } }
LaunchedEffect(text) { PhoneTyping.carText(phoneField, text) } LaunchedEffect(text) { PhoneTyping.carText(phoneField, text) }
if (preview) return
val rows = if (symbols) SYMBOL_ROWS else LETTER_ROWS val rows = if (symbols) SYMBOL_ROWS else LETTER_ROWS
val keyHeight = keyHeight(LocalConfiguration.current.screenHeightDp) val keyHeight = keyHeight(LocalConfiguration.current.screenHeightDp)
@@ -145,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))
} }
@@ -192,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))
} }
} }
} }
@@ -212,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),
) )
} }
} }
@@ -35,11 +35,35 @@ object CarKeys {
/** Delivers an Android key event to the car display; set while the car UI is showing. */ /** Delivers an Android key event to the car display; set while the car UI is showing. */
@Volatile var focusSink: ((keyCode: Int, down: Boolean) -> Unit)? = null @Volatile var focusSink: ((keyCode: Int, down: Boolean) -> Unit)? = null
/** What a [KnobTaker] is offered: a turn of the knob, a focus key edge, or Back. */
sealed interface KnobEvent {
data class Turn(val steps: Int) : KnobEvent
data class Key(val keyCode: Int, val down: Boolean) : KnobEvent
data object Back : KnobEvent
}
/**
* Something on screen that wants the knob before focus moves: the full-screen map, which
* zooms and pans with it ([MapKnob]), and the app grid, which starts on the app opened last.
* Returns true for an event it kept. Called on the thread the keys arrive on, not the main one.
*/
fun interface KnobTaker {
fun take(event: KnobEvent): Boolean
}
@Volatile var knobTaker: KnobTaker? = null
private var appContext: Context? = null private var appContext: Context? = null
private val main = Handler(Looper.getMainLooper()) private val main = Handler(Looper.getMainLooper())
fun init(context: Context) { appContext = context.applicationContext } fun init(context: Context) { appContext = context.applicationContext }
/** The keys that act on the media app. */
private val MEDIA_KEYS = setOf(
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, KeyEvent.KEYCODE_MEDIA_PLAY, KeyEvent.KEYCODE_MEDIA_PAUSE,
KeyEvent.KEYCODE_MEDIA_STOP, KeyEvent.KEYCODE_MEDIA_NEXT, KeyEvent.KEYCODE_MEDIA_PREVIOUS,
)
/** /**
* Every key worth asking the head unit for. The binding request lists what the head unit * Every key worth asking the head unit for. The binding request lists what the head unit
* offered; when it offers nothing (some units leave the list empty and send keys anyway), * offered; when it offers nothing (some units leave the list empty and send keys anyway),
@@ -63,22 +87,30 @@ object CarKeys {
*/ */
fun onKey(keyCode: Int, down: Boolean, longPress: Boolean): Boolean { fun onKey(keyCode: Int, down: Boolean, longPress: Boolean): Boolean {
if (keyCode in FOCUS_KEYS) { if (keyCode in FOCUS_KEYS) {
if (knobTaker?.take(KnobEvent.Key(keyCode, down)) == true) return true
_focusMoves.tryEmit(Unit) _focusMoves.tryEmit(Unit)
focusSink?.invoke(keyCode, down) focusSink?.invoke(keyCode, down)
return true return true
} }
if (!down) return keyCode in WANTED if (!down) return keyCode in WANTED
val media = CarServices.media // Null until the car shows Gearslip's screen. A 2020 Civic sends Play as soon as it hears
// the phone's Bluetooth address, while still on its own screen, and reaching for media
// then ended the session (issue #43).
val media = CarServices.mediaOrNull()
if (media == null && keyCode in MEDIA_KEYS) {
GearslipLog.i("keys: $keyCode before the car shows Gearslip - no media app to send it to yet")
return true
}
when (keyCode) { when (keyCode) {
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> media.togglePlay() KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> media?.togglePlay()
KeyEvent.KEYCODE_MEDIA_PLAY -> if (!media.now.value.playing) media.togglePlay() KeyEvent.KEYCODE_MEDIA_PLAY -> if (media != null && !media.now.value.playing) media.togglePlay()
KeyEvent.KEYCODE_MEDIA_PAUSE, KeyEvent.KEYCODE_MEDIA_STOP -> if (media.now.value.playing) media.togglePlay() KeyEvent.KEYCODE_MEDIA_PAUSE, KeyEvent.KEYCODE_MEDIA_STOP -> if (media != null && media.now.value.playing) media.togglePlay()
KeyEvent.KEYCODE_MEDIA_NEXT -> media.next() KeyEvent.KEYCODE_MEDIA_NEXT -> media?.next()
KeyEvent.KEYCODE_MEDIA_PREVIOUS -> media.previous() KeyEvent.KEYCODE_MEDIA_PREVIOUS -> media?.previous()
KeyEvent.KEYCODE_SEARCH, KeyEvent.KEYCODE_VOICE_ASSIST -> voice() KeyEvent.KEYCODE_SEARCH, KeyEvent.KEYCODE_VOICE_ASSIST -> voice()
KeyEvent.KEYCODE_CALL -> call() KeyEvent.KEYCODE_CALL -> call()
KeyEvent.KEYCODE_ENDCALL -> endCall() KeyEvent.KEYCODE_ENDCALL -> endCall()
KeyEvent.KEYCODE_BACK -> screen(ScreenKey.BACK) KeyEvent.KEYCODE_BACK -> if (knobTaker?.take(KnobEvent.Back) != true) screen(ScreenKey.BACK)
KeyEvent.KEYCODE_HOME -> screen(ScreenKey.HOME) KeyEvent.KEYCODE_HOME -> screen(ScreenKey.HOME)
KEYCODE_NAVIGATION -> screen(ScreenKey.MAP) KEYCODE_NAVIGATION -> screen(ScreenKey.MAP)
KEYCODE_MEDIA -> screen(ScreenKey.MEDIA) KEYCODE_MEDIA -> screen(ScreenKey.MEDIA)
@@ -92,6 +124,7 @@ object CarKeys {
/** A turn of the rotary knob: each click moves focus one step, like Tab or Shift+Tab. */ /** A turn of the rotary knob: each click moves focus one step, like Tab or Shift+Tab. */
fun onRotate(delta: Int) { fun onRotate(delta: Int) {
val sink = focusSink ?: return val sink = focusSink ?: return
if (knobTaker?.take(KnobEvent.Turn(delta)) == true) return
_focusMoves.tryEmit(Unit) _focusMoves.tryEmit(Unit)
val code = if (delta > 0) KeyEvent.KEYCODE_TAB else KEYCODE_SHIFT_TAB val code = if (delta > 0) KeyEvent.KEYCODE_TAB else KEYCODE_SHIFT_TAB
repeat(kotlin.math.abs(delta).coerceAtMost(MAX_ROTARY_STEPS)) { repeat(kotlin.math.abs(delta).coerceAtMost(MAX_ROTARY_STEPS)) {
@@ -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
@@ -14,6 +16,16 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import app.seb3thehacker.gearslip.AppSettings
import kotlinx.coroutines.launch
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.KeyboardArrowDown
@@ -73,6 +85,8 @@ private class Tile(
/** Known not to work; drawn with a red X instead of being left off the launcher. */ /** Known not to work; drawn with a red X instead of being left off the launcher. */
val broken: Boolean = false, val broken: Boolean = false,
val onLongClick: (() -> Unit)? = null, val onLongClick: (() -> Unit)? = null,
/** The entry's [Entry.componentId], for finding the app opened last. */
val id: String? = null,
val onClick: () -> Unit, val onClick: () -> Unit,
) )
@@ -119,35 +133,66 @@ internal object LauncherCache {
context.packageManager.getApplicationIcon(pkg).toBitmap(128, 128).asImageBitmap() context.packageManager.getApplicationIcon(pkg).toBitmap(128, 128).asImageBitmap()
}.getOrNull() }.getOrNull()
val navApps = CarAppCatalog.installed(context) val carApps = CarAppCatalog.installed(context).associateBy { it.component.packageName }
val mediaApps = MediaCatalog.installed(context) val mediaApps = MediaCatalog.installed(context).associateBy { it.component.packageName }
// Some apps (Spotify among them) ship both a Car App Library template and a legacy val messagingApps = MessagingCatalog.installed(context).associateBy { it.packageName }
// MediaBrowserService, which otherwise land as two identically-labeled tiles with no
// way to tell them apart. They're genuinely different screens - browsing versus Now
// Playing - so only the label needs disambiguating, not the tile itself.
val mediaPackages = mediaApps.map { it.component.packageName }.toSet()
val messagingApps = MessagingCatalog.installed(context) // One decision per app over everything it offers a car - a car screen, a player, car
// Google Messages ships its own car app, which won't start under Gearslip. Gearslip's // notifications - rather than a filter per kind that has to know about the others.
// own Messages screen does the same job, so a messenger gets only that tile. val tiles = (carApps.keys + mediaApps.keys + messagingApps.keys).flatMap { pkg ->
val messagingPackages = messagingApps.map { it.packageName }.toSet() val icon by lazy { iconOf(pkg) }
tilesFor(carApps[pkg], mediaApps[pkg], messagingApps[pkg], context.getString(R.string.media_browse)).map { tile ->
val nav = navApps when (tile) {
.filter { it.component.packageName !in messagingPackages } is AppTile.Screen -> Entry(tile.label, icon, template = tile.app)
// An app whose template screen is rejected but whose player works through Gearslip is AppTile.Player -> Entry(tile.app.label, icon, media = tile.app)
// (Spotify) shows only its player tile - a "· Browse" tile beside it would just report is AppTile.Messages -> Entry(tile.app.label, icon, messaging = tile.app)
// that the car screen isn't supported. }
.filter { !(KnownApps.screenBroken(it.component.packageName) && KnownApps.playerWorks(it.component.packageName)) }
.map {
val label = if (it.component.packageName in mediaPackages) "${it.label} · Browse" else it.label
Entry(label, iconOf(it.component.packageName), template = it)
} }
val media = mediaApps.map { Entry(it.label, iconOf(it.component.packageName), media = it) } }
// A messenger that is also a car media app keeps only its player tile. CarSettings.hideOnFirstSight(tiles.mapNotNull { entry ->
val messaging = messagingApps val pkg = (entry.template?.component ?: entry.media?.component)?.packageName ?: entry.messaging?.packageName
.filter { it.packageName !in mediaPackages } entry.componentId?.takeIf { pkg != null && KnownApps.startsHidden(context, pkg, entry.messaging != null) }
.map { Entry(it.label, iconOf(it.packageName), messaging = it) } })
return (nav + media + messaging).sortedBy { it.label.lowercase() }.also { flow.value = it } return tiles.sortedBy { it.label.lowercase() }.also { flow.value = it }
}
}
/** What one app gets on the launcher. */
private sealed interface AppTile {
class Screen(val app: TemplateApp, val label: String) : AppTile
class Player(val app: app.seb3thehacker.gearslip.media.MediaApp) : AppTile
class Messages(val app: app.seb3thehacker.gearslip.notify.MessagingApp) : AppTile
}
/**
* The tiles for one app, from what it offers.
*
* - A car screen that is something other than media or messaging (RF Sentinel's POI screen) is
* the app: its media service only makes sound, and its notifications are alerts, so it gets the
* one tile under its own name.
* - A media app with both a car screen and a player (Spotify) gets both, the screen labelled
* "· Browse", since they really are different screens - unless the screen is known to refuse
* Gearslip while the player works, when the Browse tile would only report that.
* - A messenger's own car screen (Google Messages) won't start under Gearslip, and Gearslip's
* Messages screen does the job, so it gets only that - and a messenger that is also a media
* app keeps only its player.
*/
private fun tilesFor(
car: TemplateApp?,
media: app.seb3thehacker.gearslip.media.MediaApp?,
messaging: app.seb3thehacker.gearslip.notify.MessagingApp?,
browse: String,
): List<AppTile> {
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()
return buildList {
val screenRefused = KnownApps.screenBroken(pkg) && KnownApps.playerWorks(pkg)
val messengerScreen = messaging != null && (car?.kind == "messaging" || car?.kind == "app")
if (car != null && !messengerScreen && !(media != null && screenRefused)) {
add(AppTile.Screen(car, if (media != null) "${car.label} · $browse" else car.label))
}
if (media != null) add(AppTile.Player(media))
else if (messaging != null) add(AppTile.Messages(messaging))
} }
} }
@@ -156,10 +201,13 @@ internal object LauncherCache {
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. */
@@ -195,9 +243,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() }),
@@ -211,7 +260,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) }
@@ -239,6 +288,7 @@ internal fun launchEntry(entry: Entry, navigator: CarNavigator, frame: CarEnviro
/** The app launcher: every app as an icon grid, grouped Phone, Maps, Music, Messaging, Web, Screen sharing, Settings. */ /** The app launcher: every app as an icon grid, grouped Phone, Maps, Music, Messaging, Web, Screen sharing, Settings. */
@Composable @Composable
fun CarLauncher() { fun CarLauncher() {
val context = LocalContext.current
val navigator = LocalCarNavigator.current val navigator = LocalCarNavigator.current
val frame by CarEnvironment.frame.collectAsState() val frame by CarEnvironment.frame.collectAsState()
@@ -253,23 +303,56 @@ 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(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) } id = id,
) {
id?.let { AppSettings.putString(context, KEY_LAST_APP, it) }
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)
// With the knob, the first move on this screen lands on the app opened last rather than on
// the first button anywhere on screen (issue #74). After that the knob moves as usual, out to
// the nav bar and back; nudging up or down already jumps a row.
val gridState = rememberLazyGridState()
val scope = rememberCoroutineScope()
val lastFocus = remember { FocusRequester() }
val lastId = remember { AppSettings.getString(context, KEY_LAST_APP, "") }
val lastIndex = tiles.indexOfFirst { it.id == lastId }.coerceAtLeast(0)
val focusIndex by rememberUpdatedState(lastIndex)
DisposableEffect(Unit) {
var used = false
val taker = CarKeys.KnobTaker { event ->
if (used || event == CarKeys.KnobEvent.Back || (event is CarKeys.KnobEvent.Key && !event.down)) {
return@KnobTaker false
}
used = true
scope.launch {
gridState.scrollToItem(focusIndex)
withFrameNanos { }
runCatching { lastFocus.requestFocus() }
}
true
}
CarKeys.knobTaker = taker
onDispose { if (CarKeys.knobTaker === taker) CarKeys.knobTaker = null }
}
LazyVerticalGrid( LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 108.dp), columns = GridCells.Adaptive(minSize = 108.dp),
state = gridState,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(20.dp), contentPadding = PaddingValues(20.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
items(tiles) { AppTile(it) } itemsIndexed(tiles) { i, tile -> AppTile(tile, if (i == lastIndex) Modifier.focusRequester(lastFocus) else Modifier) }
} }
} }
@@ -280,20 +363,20 @@ 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))
} }
} }
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
private fun AppTile(tile: Tile) { private fun AppTile(tile: Tile, modifier: Modifier = Modifier) {
val shape = MaterialTheme.shapes.extraLarge val shape = MaterialTheme.shapes.extraLarge
// A long press opens the app's menu (pin, close) - the same gesture everywhere apps are // A long press opens the app's menu (pin, close) - the same gesture everywhere apps are
// shown, here and on the nav bar. GsIconBox marks it in the theme's style. // shown, here and on the nav bar. GsIconBox marks it in the theme's style.
GsIconBox( GsIconBox(
onClick = tile.onClick, onClick = tile.onClick,
onLongClick = tile.onLongClick, onLongClick = tile.onLongClick,
modifier = Modifier.height(120.dp), modifier = modifier.height(120.dp),
colors = GsColors(MaterialTheme.colorScheme.surfaceVariant, MaterialTheme.colorScheme.onSurfaceVariant), colors = GsColors(MaterialTheme.colorScheme.surfaceVariant, MaterialTheme.colorScheme.onSurfaceVariant),
shape = shape, shape = shape,
) { ) {
@@ -342,3 +425,6 @@ private fun TileLabel(tile: Tile, maxLines: Int = 2, modifier: Modifier = Modifi
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
} }
/** The app opened from the launcher last, where the knob starts on the Apps screen. */
private const val KEY_LAST_APP = "car_last_app"
@@ -0,0 +1,694 @@
package app.seb3thehacker.gearslip.car
import androidx.compose.ui.res.stringResource
import androidx.compose.material.icons.filled.Call
import app.seb3thehacker.gearslip.call.CarCalls
import app.seb3thehacker.gearslip.call.CallUiState
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.graphics.Brush
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.border
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.foundation.layout.BoxScope
import androidx.car.app.model.CarIcon
import androidx.car.app.navigation.model.NavigationTemplate
import androidx.car.app.navigation.model.RoutingInfo
import app.seb3thehacker.gearslip.host.CarAppConnection
import app.seb3thehacker.gearslip.host.display
import app.seb3thehacker.gearslip.host.text
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.hapticfeedback.HapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.LocalView
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.animation.animateContentSize
import android.media.session.PlaybackState
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.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextOverflow
import app.seb3thehacker.gearslip.R
import app.seb3thehacker.gearslip.notify.PhoneNavigation
import app.seb3thehacker.gearslip.car.theme.GsColors
import app.seb3thehacker.gearslip.car.theme.withTintedSurfaces
import app.seb3thehacker.gearslip.car.theme.GsIconBox
import app.seb3thehacker.gearslip.car.theme.GsIconButton
import app.seb3thehacker.gearslip.car.theme.GsButton
import app.seb3thehacker.gearslip.car.theme.GsTone
import app.seb3thehacker.gearslip.car.theme.gsColors
import app.seb3thehacker.gearslip.car.theme.GsThemes
import app.seb3thehacker.gearslip.car.theme.LocalGsTheme
import app.seb3thehacker.gearslip.media.CarMedia
import app.seb3thehacker.gearslip.media.MediaArt
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import app.seb3thehacker.gearslip.notify.CarNotifications
import androidx.core.graphics.drawable.toBitmap
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import java.text.DateFormat
import java.util.Date
// The nav bar along the bottom of the car screen: home, apps, the assistant, running apps,
// the next turn or a call, the player, and the clock. Split out of CarUi.kt.
/**
* "Home > Apps > Settings", like a file manager's address bar: hidden at Home itself (nothing to
* show yet), and grows by one crumb for every screen pushed from there. The back arrow undoes one
* step; a crumb jumps straight to it, dropping everything after - this is the only on-screen way
* back at all, since a car touchscreen has no hardware button for it.
*/
@Composable
private fun BreadcrumbBar(navigator: CarNavigator) {
val trail = navigator.trail
if (trail.size <= 1) return
Row(
Modifier.fillMaxWidth().height(40.dp).padding(start = 2.dp, end = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
GsIconButton(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back), navigator::back, size = 32.dp, iconSize = 20.dp)
Spacer(Modifier.width(2.dp))
trail.forEachIndexed { index, crumb ->
val isLast = index == trail.lastIndex
Text(
crumb.shown(),
style = MaterialTheme.typography.labelLarge,
fontWeight = if (isLast) FontWeight.SemiBold else FontWeight.Normal,
color = if (isLast) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = if (isLast) Modifier else Modifier
.clip(RoundedCornerShape(6.dp))
.clickable { navigator.jumpTo(index) }
.padding(horizontal = 4.dp, vertical = 2.dp),
)
if (!isLast) {
Text(
"›",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 2.dp),
)
}
}
}
}
/**
* Apps and open/pinned shortcuts on the left; now-playing pill against them; clock (tap for
* calendar and weather), phone battery and the darkness signal on the right. Icon-only: a driver
* reads a glyph faster than a label, and it keeps the bar short enough to leave the app underneath
* more room.
*/
@Composable
internal fun CarNavBar(navigator: CarNavigator) {
val battery by CarEnvironment.battery.collectAsState()
val now by rememberNow()
val weather by Weather.state.collectAsState()
val weatherData = (weather as? WeatherState.Ready)?.data
val pinnedIds by CarSettings.pinnedApps.collectAsState()
val entries = rememberAllEntries()
val pinnedEntries = remember(entries, pinnedIds) {
pinnedIds.mapNotNull { id -> entries.find { it.componentId == id } }
}
val frame by CarEnvironment.frame.collectAsState()
val running = rememberRunningApps()
// 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
val navMic by CarSettings.navMic.collectAsState()
Surface(color = if (overPlayer) androidx.compose.ui.graphics.Color.Transparent else MaterialTheme.colorScheme.background) {
BoxWithConstraints(Modifier.fillMaxWidth().height(NAV_BAR_HEIGHT).padding(horizontal = 10.dp)) {
Row(Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) {
// Home and Apps are two different places now, not a toggle on one button - the
// breadcrumb trail is how you retrace your steps, but reaching for either of
// these two from three screens deep in a media app shouldn't mean reading it
// first.
NavHomeButton(navigator, entries, running)
Spacer(Modifier.width(6.dp))
NavItem(MediaIcons.Apps, navigator.current == CarScreen.Apps) { navigator.apps() }
if (navMic) {
Spacer(Modifier.width(6.dp))
NavAssistantIcon()
}
// Pinned shortcuts, then whatever else is open right now. A pinned app that's also
// open stays in its pinned spot, marked with a dot, instead of showing up twice.
// The map app lives on the Home button, so it never gets a second icon here.
val runningEntries = running.keys
.filter { it !in pinnedIds && running[it] != RunningSlot.NAV }
.mapNotNull { id -> entries.find { it.componentId == id } }
val shownPins = pinnedEntries.filter { running[it.componentId] != RunningSlot.NAV }
if (shownPins.isNotEmpty() || runningEntries.isNotEmpty()) NavDivider()
shownPins.forEach { entry -> NavEntryIcon(entry, running, navigator, frame) }
if (shownPins.isNotEmpty() && runningEntries.isNotEmpty()) NavDivider()
runningEntries.forEach { entry -> NavEntryIcon(entry, running, navigator, frame) }
// 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.
// A call takes the player's place until it ends.
// 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
BoxWithConstraints(Modifier.weight(1f).padding(horizontal = 16.dp), contentAlignment = Alignment.Center) {
val roomForBoth = maxWidth >= TURN_AND_PLAYER_MIN_WIDTH
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
if (call == null || roomForBoth) turn?.let { NavTurnPill(it) { if (!it.fromPhone) navigator.home() } }
if (call != null) {
NavCallPill(call, Modifier.weight(1f, fill = false))
} else if (turn == null || roomForBoth) {
NavNowPlaying(navigator, selected = navigator.current == CarScreen.Media, modifier = Modifier.weight(1f, fill = false))
}
}
}
// Weather no longer has its own chip - the temperature alone (no icon, no room
// for one) sits where "Night"/"Day" used to, freeing a whole chip's width for the
// Home button above.
// The clock is also the notifications button: the dashboard lists them under the
// calendar and weather, and a dot here marks anything unread.
val unread by CarNotifications.unread.collectAsState()
NavChip(
navigator.current == CarScreen.Dashboard || navigator.current is CarScreen.Notifications,
{ navigator.dashboard() },
) {
if (unread > 0) {
Box(
Modifier
.align(Alignment.TopEnd)
.offset(x = 8.dp, y = (-2).dp)
.size(8.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary),
)
}
Column(horizontalAlignment = Alignment.End) {
Text(
DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(now)),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(3.dp)) {
if (battery.percent >= 0) {
Text(
"${battery.percent}%",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (battery.charging) {
ChargingGlyph(Modifier.size(13.dp))
}
}
weatherData?.let { w ->
Text(
" · ${w.temp}°",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
}
}
}
/**
* Home: the map, with the player or lyrics beside it. While a map app runs, the button wears that
* app's icon and a long press offers to close it, so the app needs no icon of its own on the bar.
*/
@Composable
private fun NavHomeButton(navigator: CarNavigator, entries: List<Entry>, running: Map<String, RunningSlot>) {
val selected = navigator.current == CarScreen.Home
val navId = running.entries.firstOrNull { it.value == RunningSlot.NAV }?.key
val navEntry = navId?.let { id -> entries.find { it.componentId == id } }
GsIconBox(
{ navigator.home() },
colors = navColors(selected),
latched = selected,
onLongClick = navId?.let { id -> { navigator.showAppMenu(id) } },
) {
val icon = navEntry?.icon
if (icon != null) {
Box(Modifier.padding(NAV_ITEM_PADDING).size(28.dp), contentAlignment = Alignment.Center) {
Image(icon, navEntry.label, Modifier.size(28.dp).clip(RoundedCornerShape(7.dp)))
}
} else {
Icon(
ImageVector.vectorResource(R.drawable.navigation_24), contentDescription = stringResource(R.string.crumb_home),
modifier = Modifier.padding(NAV_ITEM_PADDING).size(28.dp),
)
}
}
}
/** Splits the nav bar's sections: Home and Apps, pinned apps, open apps. */
@Composable
private fun NavDivider() {
Box(
Modifier
.padding(horizontal = 10.dp)
.width(1.dp)
.height(28.dp)
.background(MaterialTheme.colorScheme.outlineVariant),
)
}
/**
* A pinned or open app on the nav bar. Tapping brings it back without reconnecting if it's
* already open; a long press opens its menu (pin, unpin, close).
*/
@Composable
private fun NavEntryIcon(
entry: Entry,
running: Map<String, RunningSlot>,
navigator: CarNavigator,
frame: CarEnvironment.Frame,
) {
val id = entry.componentId ?: return
val slot = running[id]
Box(Modifier.padding(horizontal = 3.dp)) {
NavAppIcon(
entry,
open = slot != null,
showing = slot != null && slot.isShowing(navigator.current),
onLongClick = { navigator.showAppMenu(id) },
) { openApp(entry, slot, navigator, frame) }
}
}
/** One open or pinned app's icon on the nav bar - the same fixed size and shape either way. */
@Composable
private fun NavAppIcon(
entry: Entry,
open: Boolean,
showing: Boolean,
onLongClick: () -> Unit,
onClick: () -> Unit,
) {
val scheme = MaterialTheme.colorScheme
GsIconBox(
onClick,
Modifier.size(44.dp),
colors = if (showing) GsColors(scheme.secondaryContainer, scheme.onSecondaryContainer)
else GsColors(scheme.surfaceVariant, scheme.onSurfaceVariant),
latched = showing,
onLongClick = onLongClick,
) {
entry.icon?.let { Image(it, entry.label, Modifier.size(30.dp).clip(RoundedCornerShape(8.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.
if (open) {
Box(
Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 2.dp)
.size(width = 12.dp, height = 3.dp)
.clip(RoundedCornerShape(50))
.background(scheme.primary),
)
}
}
}
/** A tappable group in the bar that stays highlighted while its screen is open, like [NavItem]. */
@Composable
private fun NavChip(selected: Boolean, onClick: () -> Unit, content: @Composable BoxScope.() -> Unit) {
GsIconBox(onClick, colors = navColors(selected), latched = selected) {
Box(Modifier.padding(horizontal = 10.dp, vertical = 2.dp)) { content() }
}
}
/** Every nav bar item is raised, so it reads as pressable; the current screen's takes the accent colour. */
@Composable
private fun navColors(selected: Boolean): GsColors {
val s = MaterialTheme.colorScheme
return if (selected) GsColors(s.secondaryContainer, s.onSecondaryContainer)
else GsColors(s.surfaceContainerHighest, s.onSurface)
}
/**
* Between Apps and whatever's actually open, not with the clock/battery group on the right - a
* driver reaches for it right after deciding "not a tap for this", same gesture family as the
* two buttons beside it.
*/
@Composable
private fun NavAssistantIcon() {
val state by CarAssistant.state.collectAsState()
val active = state.phase != CarAssistant.Phase.IDLE
GsIconBox({ CarAssistant.start() }, colors = navColors(active), latched = active) {
MicGlyph(Modifier.padding(NAV_ITEM_PADDING).size(28.dp))
}
}
/** Inner padding of every icon on the nav bar, leaving each button room inside the 56dp bar. */
private val NAV_ITEM_PADDING = 8.dp
@Composable
private fun NavItem(icon: ImageVector, selected: Boolean, onClick: () -> Unit) {
GsIconBox(onClick, colors = navColors(selected), latched = selected) {
Icon(icon, contentDescription = null, modifier = Modifier.padding(NAV_ITEM_PADDING).size(28.dp))
}
}
/** Wall-clock time, ticking on the minute: no reason to redraw (and re-encode) more often. */
@Composable
internal fun rememberNow(): State<Long> = produceState(System.currentTimeMillis()) {
while (true) {
value = System.currentTimeMillis()
delay(60_000 - value % 60_000)
}
}
/** How long the pill shows a new song's title before settling to just the controls. */
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. */
private val TURN_AND_PLAYER_MIN_WIDTH = 520.dp
/** 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, val fromPhone: Boolean = false)
/**
* Read straight off the nav app's own template: a navigation app keeps sending its routing card
* while it's running, whether or not the map is the screen being shown.
*/
@Composable
private fun rememberNextTurn(): NextTurn? {
val status by CarServices.nav.status.collectAsState()
val template by CarServices.nav.template.collectAsState()
// Read so a change of units redraws the distance straight away, not at the next update.
CarSettings.units.collectAsState().value
val phone by PhoneNavigation.turn.collectAsState()
val routing = ((template as? NavigationTemplate)?.navigationInfo as? RoutingInfo)
?.takeIf { status.phase == CarAppConnection.Phase.RUNNING }
val step = routing?.takeIf { !it.isLoading }?.currentStep ?: return phone?.let { phoneTurn(it) }
return NextTurn(
icon = step.maneuver?.icon,
distance = routing.currentDistance.display(),
road = step.road.text().ifEmpty { step.cue.text() },
)
}
/** 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. */
@Composable
private fun NavTurnPill(turn: NextTurn, onClick: () -> Unit) {
// Same finish as every other button on the bar, in the accent colour; the text runs a step
// bigger than anything else there, since it's the one thing worth a glance mid-drive.
GsIconBox(
onClick,
Modifier.widthIn(max = 340.dp).height(44.dp),
colors = gsColors(GsTone.Primary),
) {
Row(
Modifier.padding(NAV_ITEM_PADDING),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
CarGlyph(turn.icon, Modifier.size(28.dp))
if (turn.distance.isNotEmpty()) {
Text(turn.distance, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, maxLines = 1)
}
if (turn.road.isNotEmpty()) {
Text(
turn.road,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
/** The call under way: who, how long, and Mute and End right here. Ringing has its own screen. */
@Composable
private fun NavCallPill(call: CallUiState.Active, modifier: Modifier = Modifier) {
val context = LocalContext.current.applicationContext
val muted by CarCalls.muted.collectAsState()
val elapsed by produceState(0L, call.since) {
while (true) {
value = (android.os.SystemClock.uptimeMillis() - call.since) / 1000
delay(1000 - (android.os.SystemClock.uptimeMillis() - call.since) % 1000)
}
}
val scheme = MaterialTheme.colorScheme
Surface(
modifier = modifier.height(44.dp),
shape = RoundedCornerShape(14.dp),
color = scheme.secondaryContainer,
contentColor = scheme.onSecondaryContainer,
) {
Row(
Modifier.fillMaxHeight().padding(start = 12.dp, end = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Icon(Icons.Filled.Call, contentDescription = null, modifier = Modifier.size(20.dp))
Text(
call.caller?.label ?: stringResource(R.string.navbar_on_call),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
Text(
"%d:%02d".format(elapsed / 60, elapsed % 60),
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
)
Spacer(Modifier.width(2.dp))
GsIconButton(
if (muted) MediaIcons.MicOff else MediaIcons.Mic,
stringResource(if (muted) R.string.navbar_unmute else R.string.navbar_mute),
CarCalls::toggleMute,
tone = if (muted) GsTone.Primary else GsTone.Neutral, size = 36.dp, iconSize = 22.dp,
)
GsIconButton(
MediaIcons.CallEnd, stringResource(R.string.navbar_end_call), { CarCalls.end(context) },
colors = GsColors(scheme.error, scheme.onError), size = 36.dp, iconSize = 22.dp,
)
}
}
}
/** "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 =
listOf(title.trim(), artist.trim()).filter { it.isNotEmpty() }.joinToString(" - ")
.ifEmpty { stringResource(R.string.media_nothing_playing) }
/**
* 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
* 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 closes them.
*/
@Composable
private fun NavNowPlaying(navigator: CarNavigator, selected: Boolean, modifier: Modifier = Modifier) {
val media = CarServices.media
val phase by media.phase.collectAsState()
val now by media.now.collectAsState()
if (phase != CarMedia.Phase.READY || !now.isActive) return
// Docked beside the map, the player is already on screen - the pill steps aside for it.
if (navigator.sidePanel == SidePanel.CONTROLS && navigator.current == CarScreen.Home) return
// The media screen is the full player already; the pill would only repeat its controls.
if (navigator.current == CarScreen.Media) return
val context = LocalContext.current
val art by produceState(now.art, now.art, now.artUri) { value = now.art ?: MediaArt.load(context, now.artUri) }
val position by produceState(now.currentPosition(), now) {
while (true) {
value = now.currentPosition()
if (!now.playing) break
delay(1000)
}
}
// A new song announces itself: title, play/pause and dock for a few seconds, then the title
// goes and the pill settles to just the controls - unless the driver keeps the title up.
val keepTitle by CarSettings.playerTitle.collectAsState()
var announcing by remember { mutableStateOf(true) }
LaunchedEffect(now.title, now.artist) {
announcing = true
delay(TITLE_SHOWN_MS)
announcing = false
}
val showTitle = keepTitle || announcing
val scheme = MaterialTheme.colorScheme
val onPill = if (selected) scheme.onPrimaryContainer else scheme.onSecondaryContainer
Surface(
modifier = modifier
.animateContentSize()
// With the title up, all the room the bar has, so as much of it shows as can.
.then(if (showTitle) Modifier.fillMaxWidth() else Modifier)
.height(44.dp),
shape = RoundedCornerShape(14.dp),
color = if (selected) scheme.primaryContainer else scheme.secondaryContainer,
contentColor = onPill,
) {
Box {
Row(
Modifier.fillMaxHeight().padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
// Art (and the title while it shows) opens the full media screen.
Row(
Modifier
.then(if (showTitle) Modifier.weight(1f) else Modifier)
.fillMaxHeight()
.clip(RoundedCornerShape(10.dp))
.clickable { navigator.media() },
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Box(Modifier.size(34.dp).clip(RoundedCornerShape(8.dp)).background(scheme.surfaceVariant)) {
art?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) }
}
if (showTitle) {
Text(
nowPlayingLabel(now.title, now.artist),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
if (!showTitle) {
GsIconButton(
MediaIcons.Previous, stringResource(R.string.player_previous), media::previous,
enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_PREVIOUS), size = 36.dp, iconSize = 22.dp,
)
}
GsIconButton(
if (now.playing) MediaIcons.Pause else Icons.Filled.PlayArrow,
stringResource(if (now.playing) R.string.player_pause else R.string.media_play),
media::togglePlay,
tone = GsTone.Primary, size = 36.dp, iconSize = 22.dp,
)
if (!showTitle) {
GsIconButton(
MediaIcons.Next, stringResource(R.string.player_next), media::next,
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(
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,
)
}
if (now.durationMs > 0) {
// matchParentSize, so the line follows the pill's width rather than widening it.
Box(Modifier.matchParentSize(), contentAlignment = Alignment.BottomStart) {
Box(
Modifier
.fillMaxWidth((position.toFloat() / now.durationMs).coerceIn(0f, 1f))
.height(2.dp)
.background(onPill.copy(alpha = 0.7f)),
)
}
}
}
}
}
/**
* A filled lightning bolt, next to the battery percentage instead of the word "charging" - the
* core Material icon set this project ships (no extended pack, to keep the app small) has no
* bolt, so this is drawn rather than a vector asset.
*/
@Composable
private fun ChargingGlyph(modifier: Modifier = Modifier, tint: Color = MaterialTheme.colorScheme.onSurfaceVariant) {
androidx.compose.foundation.Canvas(modifier) {
val w = size.width
val h = size.height
val bolt = androidx.compose.ui.graphics.Path().apply {
moveTo(w * 0.58f, 0f)
lineTo(w * 0.12f, h * 0.58f)
lineTo(w * 0.46f, h * 0.58f)
lineTo(w * 0.42f, h)
lineTo(w * 0.88f, h * 0.40f)
lineTo(w * 0.54f, h * 0.40f)
close()
}
drawPath(bolt, tint)
}
}
@@ -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,23 @@ 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))
} }
/**
* Whether a back button on this screen leads anywhere the nav bar doesn't: false when the
* screen under it is Home or the app grid, which the nav bar's own buttons already reach.
* Android Auto shows no back button on an app's first screen for the same reason.
*/
val backLeadsSomewhere: Boolean
get() = stack.size >= 2 && stack[stack.size - 2].screen.let { it != CarScreen.Home && it != CarScreen.Apps }
/** The one way back to a clean slate: resets the whole trail to just Home. */ /** 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 +107,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 +117,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 +134,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 +152,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. */
@@ -5,16 +5,7 @@ import app.seb3thehacker.gearslip.Wire
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
/** /** Raw sensor readings, keyed by SensorBatch field number, which matches the sensor type id. */
* Everything the head unit's sensor channel has offered and sent, for the "Vehicle data" screen.
*
* [ServiceDiscovery.findSensorService] tells us which numeric sensor types this head unit is
* willing to stream; [SensorType] names the ones publicly documented by other AA reverse-engineering
* projects (aasdk, openauto). That mapping is carried over from other projects, not verified
* against this exact unit, so it's a label, not a guarantee - the raw field dump next to it is
* the ground truth. Once [onEvent] starts decoding real traffic from the 2018 Uconnect, correct
* any type that turns out to be mislabeled.
*/
object CarSensors { object CarSensors {
data class Reading(val typeId: Int, val typeName: String, val raw: String, val updatedAtMs: Long) data class Reading(val typeId: Int, val typeName: String, val raw: String, val updatedAtMs: Long)
@@ -30,16 +21,10 @@ object CarSensors {
} }
} }
/** /** Each SensorBatch field contains repeated readings of one sensor type. */
* One SensorEventIndication. Every reverse-engineered version of this message multiplexes
* every sensor type into one shared set of message-level field numbers (field N = "the data
* for sensor type N", roughly) rather than tagging events with the type id from
* SensorType.type - so field number is read here as a best-effort type id, same as the
* other services in this file.
*/
fun onEvent(body: ByteArray) { fun onEvent(body: ByteArray) {
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
val fields = Wire.fields(body) val fields = Wire.fields(body).filter { it.wireType == 2 }
val byType = fields.groupBy { it.number } val byType = fields.groupBy { it.number }
val updated = byType.mapValues { (number, group) -> val updated = byType.mapValues { (number, group) ->
Reading( Reading(
@@ -61,10 +46,7 @@ object CarSensors {
} }
} }
/** /** Sensor type values shared by the discovery service and SensorBatch. */
* Sensor type ids as named across public Android Auto reverse-engineering references. Treated as
* a label, not a fact - see [CarSensors] doc.
*/
object SensorType { object SensorType {
private val NAMES = mapOf( private val NAMES = mapOf(
1 to "Location (GPS)", 1 to "Location (GPS)",
@@ -89,6 +71,10 @@ object SensorType {
20 to "Gyroscope", 20 to "Gyroscope",
21 to "GPS satellites", 21 to "GPS satellites",
22 to "Toll card", 22 to "Toll card",
23 to "Vehicle energy model",
24 to "Trailer",
25 to "Raw vehicle energy model",
26 to "Raw EV trip settings",
) )
fun name(id: Int): String = NAMES[id]?.let { "$it (#$id)" } ?: "Sensor #$id" fun name(id: Int): String = NAMES[id]?.let { "$it (#$id)" } ?: "Sensor #$id"
@@ -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,8 +31,19 @@ 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)).also { m ->
// Remembered only once it connects: an app that refuses (NewPipe without Notification
// access) must not replace the one the driver was listening to on the next drive.
m.onReady = { app -> CarSettings.setLastMedia(app.component.flattenToString()) }
}
}
/** [nav], or null before the car screen has set things up. */ /** [nav], or null before the car screen has set things up. */
fun navOrNull(): CarAppConnection? = if (context != null) nav else null fun navOrNull(): CarAppConnection? = if (context != null) nav else null
@@ -60,6 +72,13 @@ object CarServices {
private var navStopped = false private var navStopped = false
private var autoplayPending = false private var autoplayPending = false
/**
* The app that was playing, or paused with a track loaded, when the driver opened another one. Opening an app is only a look
* at its library: until something plays there, the music carries on in this one, so leaving
* the new app's screen goes back to it, as Android Auto does.
*/
private var stillPlaying: MediaApp? = null
fun init(appContext: Context) { fun init(appContext: Context) {
context = appContext.applicationContext context = appContext.applicationContext
app.seb3thehacker.gearslip.notify.CarMotion.start(appContext) app.seb3thehacker.gearslip.notify.CarMotion.start(appContext)
@@ -125,17 +144,20 @@ object CarServices {
// --- media --------------------------------------------------------------------------- // --- media ---------------------------------------------------------------------------
fun openMedia(app: MediaApp, autoplay: Boolean = false) { fun openMedia(app: MediaApp, autoplay: Boolean = false) {
CarSettings.setLastMedia(app.component.flattenToString())
val phase = media.phase.value val phase = media.phase.value
val live = phase == CarMedia.Phase.READY || phase == CarMedia.Phase.CONNECTING val live = phase == CarMedia.Phase.READY || phase == CarMedia.Phase.CONNECTING
if (_mediaApp.value?.component == app.component && live) return if (_mediaApp.value?.component == app.component && live) return
val previous = _mediaApp.value
// Paused counts too: a track loaded there is still the music the driver is listening to.
val loaded = media.now.value.let { it.playing || it.title.isNotEmpty() }
stillPlaying = previous?.takeIf { it.component != app.component && loaded && !autoplay }
_mediaApp.value = app _mediaApp.value = app
autoplayPending = autoplay autoplayPending = autoplay
media.connect(app) media.connect(app)
// Not torn down and rebuilt per app: the capture covers whatever the phone plays, so // Not torn down and rebuilt per app: the capture covers whatever the phone plays, so
// switching media apps never costs the driver another consent dialog. // switching media apps never costs the driver another consent dialog.
if (CarSettings.pipeAudio.value) CarAudio.request() if (CarSettings.pipesNow) CarAudio.request()
} }
/** /**
@@ -151,12 +173,26 @@ object CarServices {
return true return true
} }
/**
* The media screen closed. If nothing started in the app it showed, go back to the one that
* kept playing meanwhile, so the player and its buttons follow the music you can hear.
*/
fun mediaScreenClosed() {
val back = stillPlaying ?: return
stillPlaying = null
// A track loaded here, even one paused since, means the driver used this app.
if (media.now.value.playing || media.now.value.title.isNotEmpty()) return
app.seb3thehacker.gearslip.GearslipLog.i("media: nothing played in ${_mediaApp.value?.label}, back to ${back.label}")
openMedia(back)
}
/** Closes the media app from the car: stops what it's playing and drops the connection. */ /** Closes the media app from the car: stops what it's playing and drops the connection. */
fun closeMedia() { fun closeMedia() {
if (media.now.value.playing) media.togglePlay() if (media.now.value.playing) media.togglePlay()
media.disconnect() media.disconnect()
_mediaApp.value = null _mediaApp.value = null
autoplayPending = false autoplayPending = false
stillPlaying = null
} }
/** Called once the media app is connected: starts playback if that was asked for. */ /** Called once the media app is connected: starts playback if that was asked for. */
@@ -178,7 +214,7 @@ object CarServices {
_hasNavApps.value = navApps.isNotEmpty() _hasNavApps.value = navApps.isNotEmpty()
// Never auto-pick one already known to fail outright - a driver who plugs in should // Never auto-pick one already known to fail outright - a driver who plugs in should
// land on a map, not on the one tile marked with a red X. // land on a map, not on the one tile marked with a red X.
val candidates = navApps.filterNot { KnownApps.isBroken(it.component.packageName) } val candidates = navApps.filterNot { KnownApps.isBroken(ctx, it.component.packageName) }
val last = CarSettings.lastNav.value val last = CarSettings.lastNav.value
// The one used last, or - first run, nothing on record yet - whichever comes first: // The one used last, or - first run, nothing on record yet - whichever comes first:
// a driver who plugs in for the first time shouldn't have to go pick one by hand // a driver who plugs in for the first time shouldn't have to go pick one by hand
@@ -229,25 +265,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
@@ -18,6 +19,33 @@ enum class Units { AUTO, IMPERIAL, METRIC }
/** The car UI's own colours. Separate from [NightMode], which is about the light outside (the map). */ /** The car UI's own colours. Separate from [NightMode], which is about the light outside (the map). */
enum class AppTheme { PHONE, LIGHT, DARK } enum class AppTheme { PHONE, LIGHT, DARK }
/** A screen's width and height in pixels, as someone typed it: "1280x480". */
data class ScreenSize(val width: Int, val height: Int) {
override fun toString() = "${width}x$height"
companion object {
fun parse(text: String?): ScreenSize? {
val parts = text?.lowercase()?.split('x', '×', ' ')?.filter { it.isNotBlank() } ?: return null
if (parts.size != 2) return null
val w = parts[0].trim().toIntOrNull() ?: return null
val h = parts[1].trim().toIntOrNull() ?: return null
return if (w in 100..8000 && h in 100..8000) ScreenSize(w, h) else null
}
}
}
/**
* How a head unit puts our frame on a screen of another shape. [CROP] cuts the edges off, as
* a car's own margins do; [SQUASH] shrinks the whole frame to fit, stretching everything.
*/
enum class ScreenFit { CROP, SQUASH }
/** What size the phone's car preview draws: the last car's, this device's whole screen, or one typed in. */
enum class PreviewSize { LAST_CAR, FILL, CUSTOM }
/** When the nav bar gets out of the way: never, during a route, or whenever the map is up. */
enum class NavBarMode { SHOW, HIDE_NAVIGATING, HIDE }
/** /**
* Settings changed from the car screen itself. Held as flows so the car UI reacts the moment a * Settings changed from the car screen itself. Held as flows so the car UI reacts the moment a
* button is tapped, and persisted through [AppSettings] so they survive the next connection. * button is tapped, and persisted through [AppSettings] so they survive the next connection.
@@ -25,10 +53,13 @@ enum class AppTheme { PHONE, LIGHT, DARK }
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_AUDIO_CARS = "car_audio_through_gearslip_cars"
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"
@@ -36,15 +67,28 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
private const val KEY_LAST_MEDIA = "car_last_media" private const val KEY_LAST_MEDIA = "car_last_media"
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_NAV_MIC = "car_nav_mic"
private const val KEY_DEFAULT_HIDDEN = "car_default_hidden"
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_CLUSTER_VIDEO = "cluster_video_enabled"
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"
private const val KEY_NAV_BAR = "car_nav_bar"
private const val KEY_SCREEN_SIZE = "car_screen_size"
private const val KEY_SCREEN_FIT = "car_screen_fit"
private const val KEY_PREVIEW_SIZE = "preview_size"
private const val KEY_PREVIEW_CUSTOM = "preview_custom_size"
private const val KEY_WHATS_NEW = "car_whats_new_after_updates" private const val KEY_WHATS_NEW = "car_whats_new_after_updates"
private const val KEY_WHATS_NEW_SEEN = "car_whats_new_seen_code" private const val KEY_WHATS_NEW_SEEN = "car_whats_new_seen_code"
@@ -58,32 +102,81 @@ 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)
private val lastMediaFlow = MutableStateFlow<String?>(null) private val lastMediaFlow = MutableStateFlow<String?>(null)
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 navMicFlow = 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 clusterVideoFlow = MutableStateFlow(false)
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)
val units: StateFlow<Units> = unitsFlow val units: StateFlow<Units> = unitsFlow
private val screenSizeFlow = MutableStateFlow<ScreenSize?>(null)
private val screenFitFlow = MutableStateFlow(ScreenFit.CROP)
private val previewSizeFlow = MutableStateFlow(PreviewSize.LAST_CAR)
private val previewCustomFlow = MutableStateFlow<ScreenSize?>(null)
/** The car screen's real size, when the head unit gets it wrong; null trusts the head unit. */
val screenSize: StateFlow<ScreenSize?> = screenSizeFlow
val screenFit: StateFlow<ScreenFit> = screenFitFlow
val previewSize: StateFlow<PreviewSize> = previewSizeFlow
val previewCustom: StateFlow<ScreenSize?> = previewCustomFlow
/** Takes effect the next time the car connects: the video size is agreed at the start. */
fun setScreenSize(size: ScreenSize?) {
screenSizeFlow.value = size
AppSettings.putString(app, KEY_SCREEN_SIZE, size?.toString().orEmpty())
}
fun setScreenFit(fit: ScreenFit) {
screenFitFlow.value = fit
AppSettings.putString(app, KEY_SCREEN_FIT, fit.name)
}
fun setPreviewSize(size: PreviewSize, custom: ScreenSize? = previewCustomFlow.value) {
previewSizeFlow.value = size
previewCustomFlow.value = custom
AppSettings.putString(app, KEY_PREVIEW_SIZE, size.name)
AppSettings.putString(app, KEY_PREVIEW_CUSTOM, custom?.toString().orEmpty())
}
private val navBarFlow = MutableStateFlow(NavBarMode.SHOW)
val navBarMode: StateFlow<NavBarMode> = navBarFlow
fun setNavBarMode(mode: NavBarMode) {
navBarFlow.value = mode
AppSettings.putString(app, KEY_NAV_BAR, mode.name)
}
private val whatsNewFlow = MutableStateFlow(true) private val whatsNewFlow = MutableStateFlow(true)
private val whatsNewSeenFlow = MutableStateFlow(0) private val whatsNewSeenFlow = MutableStateFlow(0)
@@ -133,6 +226,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
@@ -146,7 +242,27 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
*/ */
val playerTitle: StateFlow<Boolean> = playerTitleFlow val playerTitle: StateFlow<Boolean> = playerTitleFlow
/** The home screen's player sits left of the map instead of right of it. */
val playerLeft: StateFlow<Boolean> = playerLeftFlow
/** The assistant's mic button in the nav bar. Off hides only the button: the car's voice key still works. */
val navMic: StateFlow<Boolean> = navMicFlow
/** Lyrics from BetterLyrics and KuGou as well as LRCLIB; off sends song details to LRCLIB only. */
val 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)
/** The range a typed-in density may take (issue #82): small enough for more on screen, big enough to read. */
val CUSTOM_DENSITIES = 60..480
/** An absolute DPI for the car UI and its apps; the video's resolution doesn't change with it. */
val carDensity: StateFlow<Int> = carDensityFlow
/** 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
@@ -159,9 +275,47 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
*/ */
val pipeAudio: StateFlow<Boolean> = pipeAudioFlow val pipeAudio: StateFlow<Boolean> = pipeAudioFlow
private val thisCarPipesFlow = MutableStateFlow(false)
/**
* The car connected now switches Bluetooth music off while Android Auto runs, so its music
* goes through Gearslip whatever [pipeAudio] says. Learned by [BluetoothAudioCheck] and
* remembered per car: a Skoda, a VW, an Audi and a Bolt all did this.
*/
val thisCarPipes: StateFlow<Boolean> = thisCarPipesFlow
/** Whether music goes through Gearslip in this car: the driver's choice, or what the car needs. */
val pipesNow: Boolean get() = pipeAudioFlow.value || thisCarPipesFlow.value
/** A car connected: whether it's one that needs music through Gearslip. Null for none known. */
fun carConnected(carAddress: String?) {
thisCarPipesFlow.value = carAddress != null && carKey(carAddress) in audioCars()
}
fun carDisconnected() {
thisCarPipesFlow.value = false
}
/** This car turned Bluetooth music off: from now on its music goes through Gearslip. */
fun rememberAudioCar(carAddress: String) {
AppSettings.putString(app, KEY_AUDIO_CARS, (audioCars() + carKey(carAddress)).distinct().joinToString(","))
thisCarPipesFlow.value = true
}
private fun audioCars(): List<String> =
AppSettings.getString(app, KEY_AUDIO_CARS, "").split(",").filter { it.isNotEmpty() }
/** Settings keep a short hash, not the car's address. */
private fun carKey(address: String): String =
java.security.MessageDigest.getInstance("SHA-256").digest(address.uppercase().toByteArray())
.take(8).joinToString("") { "%02x".format(it) }
/** Gearslip reads the map app's turns aloud on the car's directions channel; see DirectionsVoice. */ /** 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
@@ -198,6 +352,27 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
*/ */
val carSensorsEnabled: StateFlow<Boolean> = carSensorsFlow val carSensorsEnabled: StateFlow<Boolean> = carSensorsFlow
/**
* Video bitrate that backs off when the car, or the Wi-Fi to it, falls behind. Takes effect
* only while [experimentalFeaturesEnabled] is on too; untested in a car so far.
*/
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. */
val debugBadge: StateFlow<Boolean> = debugBadgeFlow
fun setDebugBadge(on: Boolean) {
debugBadgeFlow.value = on
AppSettings.putString(app, KEY_DEBUG_BADGE, on.toString())
}
/** /**
* Whether the car screen's own "drive safely" warning has already been shown and dismissed * Whether the car screen's own "drive safely" warning has already been shown and dismissed
* once, from this same install. Separate from the phone side's own compat/safety dialogs - * once, from this same install. Separate from the phone side's own compat/safety dialogs -
@@ -214,19 +389,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 || it in CUSTOM_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"
navMicFlow.value = AppSettings.getString(app, KEY_NAV_MIC, "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 {
@@ -236,13 +418,29 @@ 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"
clusterVideoFlow.value = AppSettings.getString(app, KEY_CLUSTER_VIDEO, "false") == "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 {
Units.valueOf(AppSettings.getString(app, KEY_UNITS, Units.AUTO.name)) Units.valueOf(AppSettings.getString(app, KEY_UNITS, Units.AUTO.name))
}.getOrDefault(Units.AUTO) }.getOrDefault(Units.AUTO)
screenSizeFlow.value = ScreenSize.parse(AppSettings.getString(app, KEY_SCREEN_SIZE, ""))
screenFitFlow.value = runCatching {
ScreenFit.valueOf(AppSettings.getString(app, KEY_SCREEN_FIT, ScreenFit.CROP.name))
}.getOrDefault(ScreenFit.CROP)
previewSizeFlow.value = runCatching {
PreviewSize.valueOf(AppSettings.getString(app, KEY_PREVIEW_SIZE, PreviewSize.LAST_CAR.name))
}.getOrDefault(PreviewSize.LAST_CAR)
previewCustomFlow.value = ScreenSize.parse(AppSettings.getString(app, KEY_PREVIEW_CUSTOM, ""))
navBarFlow.value = runCatching {
NavBarMode.valueOf(AppSettings.getString(app, KEY_NAV_BAR, NavBarMode.SHOW.name))
}.getOrDefault(NavBarMode.SHOW)
whatsNewFlow.value = AppSettings.getString(app, KEY_WHATS_NEW, "true") == "true" whatsNewFlow.value = AppSettings.getString(app, KEY_WHATS_NEW, "true") == "true"
whatsNewSeenFlow.value = AppSettings.getString(app, KEY_WHATS_NEW_SEEN, "0").toIntOrNull() ?: 0 whatsNewSeenFlow.value = AppSettings.getString(app, KEY_WHATS_NEW_SEEN, "0").toIntOrNull() ?: 0
} }
@@ -252,6 +450,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)
@@ -272,6 +475,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)
@@ -282,6 +490,16 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
AppSettings.putString(app, KEY_PLAYER_TITLE, on.toString()) AppSettings.putString(app, KEY_PLAYER_TITLE, on.toString())
} }
fun setNavMic(on: Boolean) {
navMicFlow.value = on
AppSettings.putString(app, KEY_NAV_MIC, on.toString())
}
fun setPlayerLeft(on: Boolean) {
playerLeftFlow.value = on
AppSettings.putString(app, KEY_PLAYER_LEFT, on.toString())
}
fun 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())
@@ -294,6 +512,11 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
fun setPipeAudio(on: Boolean) { fun setPipeAudio(on: Boolean) {
// Saved even when unchanged, so picking the default still counts as a choice. // Saved even when unchanged, so picking the default still counts as a choice.
AppSettings.putString(app, KEY_PIPE_AUDIO, on.toString()) AppSettings.putString(app, KEY_PIPE_AUDIO, on.toString())
// Choosing Bluetooth by hand overrules what Gearslip learned about each car.
if (!on) {
AppSettings.putString(app, KEY_AUDIO_CARS, "")
thisCarPipesFlow.value = false
}
if (pipeAudioFlow.value == on) return if (pipeAudioFlow.value == on) return
pipeAudioFlow.value = on pipeAudioFlow.value = on
if (AppSettings.getString(app, KEY_SPEAK_DIRECTIONS, "") == "") speakDirectionsFlow.value = on if (AppSettings.getString(app, KEY_SPEAK_DIRECTIONS, "") == "") speakDirectionsFlow.value = on
@@ -305,6 +528,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())
@@ -325,6 +553,16 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
AppSettings.putString(app, KEY_SYSTEM_SPEECH, on.toString()) AppSettings.putString(app, KEY_SYSTEM_SPEECH, on.toString())
} }
fun setAdaptiveVideoEnabled(on: Boolean) {
adaptiveVideoFlow.value = on
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())
@@ -349,6 +587,27 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
AppSettings.putString(app, KEY_PINNED, next.joinToString(",")) AppSettings.putString(app, KEY_PINNED, next.joinToString(","))
} }
/**
* Unticks each of [ids] the first time it's seen, and never again: a known-broken app starts
* off the drawer, but once the driver ticks it back on it stays on.
*/
fun hideOnFirstSight(ids: List<String>) {
if (!::app.isInitialized) return
val seen = AppSettings.getString(app, KEY_DEFAULT_HIDDEN, "").split(",").filter { it.isNotEmpty() }.toSet()
val fresh = ids.filter { it !in seen }
if (fresh.isEmpty()) return
AppSettings.putString(app, KEY_DEFAULT_HIDDEN, (seen + fresh).joinToString(","))
val next = hiddenAppsFlow.value + fresh
hiddenAppsFlow.value = next
AppSettings.putString(app, KEY_HIDDEN_APPS, next.joinToString(","))
}
fun setAppHidden(id: String, hide: Boolean) {
val next = if (hide) hiddenAppsFlow.value + id else hiddenAppsFlow.value - id
hiddenAppsFlow.value = next
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,16 +1,25 @@
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
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
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
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.Box
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
@@ -23,7 +32,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -36,9 +44,6 @@ import app.seb3thehacker.gearslip.car.theme.GsColors
import app.seb3thehacker.gearslip.car.theme.GsIconBox import app.seb3thehacker.gearslip.car.theme.GsIconBox
import app.seb3thehacker.gearslip.car.theme.GsThemes import app.seb3thehacker.gearslip.car.theme.GsThemes
import app.seb3thehacker.gearslip.AppSettings import app.seb3thehacker.gearslip.AppSettings
import app.seb3thehacker.gearslip.ui.CertSummary
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/** /**
* Settings that make sense while sitting in the car. The web page URL is edited here, with the * Settings that make sense while sitting in the car. The web page URL is edited here, with the
@@ -49,170 +54,319 @@ 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 screenSize by CarSettings.screenSize.collectAsState()
val screenFit by CarSettings.screenFit.collectAsState()
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 navMic by CarSettings.navMic.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 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()
val context = LocalContext.current val context = LocalContext.current
val cert by produceState<CertSummary?>(null) {
value = withContext(Dispatchers.Default) { CertSummary.read(context) }
}
Column( // A rail of the groups down the left, and the chosen group's settings beside it: five short
Modifier // lists instead of one long scroll. The groups and their order match Settings on the phone.
.fillMaxSize() var chosen by androidx.compose.runtime.saveable.rememberSaveable { androidx.compose.runtime.mutableIntStateOf(0) }
.verticalScroll(rememberScrollState()) val listState = androidx.compose.foundation.lazy.rememberLazyListState()
.padding(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 }
ScreenHeader("Settings") LazyColumn(
Modifier.weight(1f).fillMaxHeight(),
ChoiceRow( state = listState,
"Open on connect", // Built as it scrolls into view rather than all at once: every row here is a set of
listOf(CarSettings.HOME to "Home") + CarApps.all.filter { it.id != "settings" }.map { it.id to it.label }, // buttons, and building the whole screen made opening Settings visibly slow.
open, contentPadding = PaddingValues(horizontal = 24.dp, vertical = 16.dp),
) { CarSettings.setOpenOnConnect(it) } verticalArrangement = Arrangement.spacedBy(20.dp),
) {
ChoiceRow( when (chosen) {
"Size", 0 -> {
listOf(1f to "Normal", 1.25f to "Large", 1.5f to "Extra large"), item { Group(stringResource(R.string.car_group_screen)) }
scale, item {
) { CarSettings.setScale(it) } ChoiceRow(
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) },
"App theme", open,
listOf(AppTheme.PHONE to "Follow phone", AppTheme.LIGHT to "Light", AppTheme.DARK to "Dark"), ) { CarSettings.setOpenOnConnect(it) }
theme, }
) { CarSettings.setAppTheme(it) } item {
ChoiceRow(
ChoiceRow( stringResource(R.string.car_size),
"Theme", 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)),
GsThemes.builtIn.map { it.id to it.name }, scale,
uiTheme, ) { CarSettings.setScale(it) }
) { CarSettings.setUiTheme(it) } }
item {
ChoiceRow( ChoiceRow(
"Map day/night", stringResource(R.string.car_app_theme),
listOf(NightMode.AUTO to "Auto", NightMode.DAY to "Day", NightMode.NIGHT to "Night"), 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)),
night, theme,
) { CarSettings.setNightMode(it) } ) { CarSettings.setAppTheme(it) }
}
ChoiceRow( if (theme != AppTheme.LIGHT) item {
"Units", ChoiceRow(
listOf(Units.AUTO to "Auto", Units.IMPERIAL to "Imperial (mi, ft, °F)", Units.METRIC to "Metric (km, m, °C)"), stringResource(R.string.car_dark_mode),
units, listOf(
) { CarSettings.setUnits(it) } DarkStyle.BLACK to stringResource(R.string.choice_dark_black),
DarkStyle.BLACK_COLOUR to stringResource(R.string.choice_dark_black_colour),
ChoiceRow( DarkStyle.DEEP_COLOUR to stringResource(R.string.choice_dark_deep_colour),
"Player in the nav bar", DarkStyle.GREY to stringResource(R.string.choice_dark_grey),
listOf(false to "Skip buttons", true to "Song title"), ),
playerTitle, darkStyle,
) { CarSettings.setPlayerTitle(it) } ) { CarSettings.setDarkStyle(it) }
}
ChoiceRow( item {
"Autoplay on connect", ChoiceRow(
listOf(false to "Off", true to "On"), stringResource(R.string.car_buttons),
autoplay, GsThemes.builtIn.map { it.id to it.name },
) { CarSettings.setAutoplay(it) } uiTheme,
) { CarSettings.setUiTheme(it) }
ChoiceRow( }
"Car audio", item {
listOf(false to "Bluetooth", true to "USB"), ChoiceRow(
pipeAudio, stringResource(R.string.car_map_day_night),
) { CarSettings.setPipeAudio(it) } 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)),
Text( night,
"USB sends the phone's sound through Gearslip. Android asks to share your screen each " + ) { CarSettings.setNightMode(it) }
"drive, and a few apps block it. Bluetooth plays as the phone normally would, and " + }
"Android never asks.", item {
style = MaterialTheme.typography.bodySmall, ChoiceRow(
color = MaterialTheme.colorScheme.onSurfaceVariant, 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)),
if (!pipeAudio) { units,
ChoiceRow( ) { CarSettings.setUnits(it) }
"Warn when Bluetooth isn't connected", }
listOf(false to "Off", true to "On"), // Fixes for a head unit that gets its own screen wrong (issue #12), together
bluetoothWarning, // and last, since most cars never need them.
) { CarSettings.setBluetoothWarning(it) } item { SubGroup(stringResource(R.string.carset_advanced)) }
} item {
ChoiceRow( ChoiceRow(
"Warn when location is off", stringResource(R.string.carset_screen_size),
listOf(false to "Off", true to "On"), listOf<ScreenSize?>(null).plus(SCREEN_PRESETS).plus(screenSize).distinct()
locationWarning, .map { it to (it?.toString() ?: stringResource(R.string.screen_from_car)) },
) { CarSettings.setLocationWarning(it) } screenSize,
) { CarSettings.setScreenSize(it) }
ChoiceRow( }
"Spoken directions", if (screenSize != null) item {
listOf(false to "Off", true to "On"), ChoiceRow(
speakDirections, stringResource(R.string.carset_fit),
) { CarSettings.setSpeakDirections(it) } listOf(ScreenFit.CROP to stringResource(R.string.screen_crop), ScreenFit.SQUASH to stringResource(R.string.screen_squash)),
Text( screenFit,
"Gearslip reads each turn aloud through the car's speakers, and the music dips while it " + ) { CarSettings.setScreenFit(it) }
"speaks. Turn it off if your map app's own voice already plays in the car.", }
style = MaterialTheme.typography.bodySmall, item { ScreenFit(vehicle) }
color = MaterialTheme.colorScheme.onSurfaceVariant, }
) 1 -> {
item { Group(stringResource(R.string.car_group_nav_bar)) }
ExperimentalFeatureRow( item {
title = "Use the phone's speech recognition", ChoiceRow(
description = "Listens through the phone's own speech service instead of Gearslip's. On " + stringResource(R.string.car_nav_bar),
"most phones that's Google's, which may send your voice to Google. GrapheneOS has " + listOf(
"none, so Gearslip keeps using its own.", NavBarMode.SHOW to stringResource(R.string.choice_nav_show),
checked = systemSpeech, NavBarMode.HIDE_NAVIGATING to stringResource(R.string.choice_nav_hide_navigating),
) { CarSettings.setSystemSpeech(it) } NavBarMode.HIDE to stringResource(R.string.choice_nav_hide),
),
WebPageSetting() navBar,
) { CarSettings.setNavBarMode(it) }
ScreenFit(vehicle) }
item {
ChoiceRow( SwitchRow(stringResource(R.string.car_nav_mic), stringResource(R.string.car_nav_mic_note), navMic) { CarSettings.setNavMic(it) }
"Experimental features", }
listOf(false to "Hide", true to "Show"), item {
experimentalFeatures, ChoiceRow(
) { CarSettings.setExperimentalFeaturesEnabled(it) } stringResource(R.string.car_player_nav_bar),
if (experimentalFeatures) { listOf(false to stringResource(R.string.car_skip_buttons), true to stringResource(R.string.car_song_title)),
ExperimentalFeaturesList(carSensors) playerTitle,
} ) { CarSettings.setPlayerTitle(it) }
}
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { item {
Text("Connection", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) ChoiceRow(
Info("Phone", "${Build.MANUFACTURER} ${Build.MODEL}") stringResource(R.string.car_player_beside_map),
Info("Vehicle", vehicle.ifEmpty { "-" }) listOf(false to stringResource(R.string.car_right), true to stringResource(R.string.car_left)),
Info("Video", display.ifEmpty { "-" }) playerLeft,
Info("Certificate", cert?.headline ?: "Checking…") ) { CarSettings.setPlayerLeft(it) }
} }
item { Note(stringResource(R.string.car_player_beside_map_note)) }
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { item {
Text("About", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) SwitchRow(stringResource(R.string.car_autoplay), stringResource(R.string.car_autoplay_note), autoplay) { CarSettings.setAutoplay(it) }
Info("Version", app.seb3thehacker.gearslip.BuildConfig.VERSION_NAME) }
Info("Build", app.seb3thehacker.gearslip.BuildConfig.VERSION_CODE.toString()) }
ExperimentalFeatureRow( 2 -> {
"What's new after updates", item { Group(stringResource(R.string.car_group_sound)) }
"Show the list of changes once, the first time the car connects after an update.", item {
whatsNew, ChoiceRow(
) { CarSettings.setWhatsNewAfterUpdates(it) } stringResource(R.string.car_audio),
GsButton(onClick = CarSettings::openWhatsNew, tone = GsTone.Tonal, modifier = Modifier.height(56.dp)) { listOf(false to stringResource(R.string.guide_audio_bluetooth), true to stringResource(R.string.guide_audio_usb)),
Text("What's new", style = MaterialTheme.typography.titleMedium) pipeAudio,
) { CarSettings.setPipeAudio(it) }
}
item { Note(stringResource(R.string.carset_audio_note)) }
item {
SwitchRow(stringResource(R.string.car_spoken_directions), stringResource(R.string.carset_directions_note), speakDirections) { CarSettings.setSpeakDirections(it) }
}
if (speakDirections) item {
SwitchRow(stringResource(R.string.car_directions_over_music), stringResource(R.string.car_directions_over_music_note), directionsOverMusic) { CarSettings.setDirectionsOverMusic(it) }
}
item {
SwitchRow(stringResource(R.string.car_phone_speech), stringResource(R.string.carset_phone_speech_note), systemSpeech) { CarSettings.setSystemSpeech(it) }
}
}
3 -> {
item { Group(stringResource(R.string.car_group_warnings)) }
if (!pipeAudio) item {
SwitchRow(stringResource(R.string.car_warn_bluetooth), null, bluetoothWarning) { CarSettings.setBluetoothWarning(it) }
}
item {
SwitchRow(stringResource(R.string.car_warn_location), null, locationWarning) { CarSettings.setLocationWarning(it) }
}
}
else -> {
item { Group(stringResource(R.string.car_group_more)) }
item { WebPageSetting() }
item {
SwitchRow(stringResource(R.string.car_experimental), stringResource(R.string.car_experimental_note), experimentalFeatures) { CarSettings.setExperimentalFeaturesEnabled(it) }
}
if (experimentalFeatures) item {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
SubGroup(stringResource(R.string.carset_experimental_heading))
ExperimentalFeaturesList(carSensors, adaptiveVideo, clusterVideo)
}
}
item {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
SubGroup(stringResource(R.string.settings_connection))
// The certificate is managed on the phone, which shows it in full.
val none = stringResource(R.string.status_idle)
Info(stringResource(R.string.carset_phone), "${Build.MANUFACTURER} ${Build.MODEL}")
Info(stringResource(R.string.carset_vehicle), vehicle.ifEmpty { none })
Info(stringResource(R.string.carset_video), display.ifEmpty { none })
}
}
item {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
SubGroup(stringResource(R.string.settings_about))
Info(stringResource(R.string.carset_version), app.seb3thehacker.gearslip.BuildConfig.VERSION_NAME)
Info(stringResource(R.string.carset_build), app.seb3thehacker.gearslip.BuildConfig.VERSION_CODE.toString())
SwitchRow(
stringResource(R.string.carset_whats_new_after),
stringResource(R.string.carset_whats_new_after_sub),
whatsNew,
) { CarSettings.setWhatsNewAfterUpdates(it) }
GsButton(onClick = CarSettings::openWhatsNew, tone = GsTone.Tonal, modifier = Modifier.height(56.dp)) {
Text(stringResource(R.string.settings_whats_new), style = MaterialTheme.typography.titleMedium)
}
}
}
}
} }
} }
} }
} }
/** A heading inside a group, for a few settings that belong together. */
@Composable
private fun SubGroup(title: String) {
Text(title, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
}
/** What a setting above it does, in a sentence or two. */
@Composable
private fun Note(text: String) {
Text(text, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
@Composable @Composable
private fun <T> ChoiceRow(title: String, options: List<Pair<T, String>>, selected: T, onSelect: (T) -> Unit) { private fun <T> ChoiceRow(title: String, options: List<Pair<T, String>>, selected: T, onSelect: (T) -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { // On a wide screen the name sits beside its choices, which halves how far there is to scroll.
Text(title, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) androidx.compose.foundation.layout.BoxWithConstraints(Modifier.fillMaxWidth()) {
if (maxWidth >= 720.dp && options.size <= 4) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp)) {
Text(title, style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(0.3f))
Box(Modifier.weight(0.7f)) { ChoiceButtons(options, selected, onSelect) }
}
} else {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(title, style = MaterialTheme.typography.titleMedium)
ChoiceButtons(options, selected, onSelect)
}
}
}
}
/** 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. */
@Composable
private fun Group(title: String) {
Text(
title,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(top = 12.dp),
)
}
@Composable
private fun <T> ChoiceButtons(options: List<Pair<T, String>>, selected: T, onSelect: (T) -> Unit) {
run {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
options.forEach { (value, label) -> options.forEach { (value, label) ->
val on = value == selected val on = value == selected
@@ -226,7 +380,16 @@ private fun <T> ChoiceRow(title: String, options: List<Pair<T, String>>, selecte
else GsColors(scheme.surfaceContainerHigh, scheme.onSurface), else GsColors(scheme.surfaceContainerHigh, scheme.onSurface),
latched = on, latched = on,
) { ) {
Text(label, style = MaterialTheme.typography.titleMedium, fontWeight = if (on) FontWeight.Bold else FontWeight.Normal) // Centred however many lines it takes: "Black and colour" wrapped to the left.
Text(
label,
style = MaterialTheme.typography.titleMedium,
fontWeight = if (on) FontWeight.Bold else FontWeight.Normal,
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(horizontal = 8.dp),
)
} }
} }
} }
@@ -239,26 +402,33 @@ private fun <T> ChoiceRow(title: String, options: List<Pair<T, String>>, selecte
* 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) { 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( SwitchRow(
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) }
SwitchRow(
title = stringResource(R.string.car_adaptive_video),
description = stringResource(R.string.carset_adaptive_note),
checked = adaptiveVideo,
) { CarSettings.setAdaptiveVideoEnabled(it) }
SwitchRow(
title = stringResource(R.string.car_cluster_video),
description = stringResource(R.string.car_cluster_video_note),
checked = clusterVideo,
) { CarSettings.setClusterVideoEnabled(it) }
} }
} }
@Composable @Composable
private fun ExperimentalFeatureRow( private fun SwitchRow(
title: String, title: String,
description: String, description: String?,
checked: Boolean, checked: Boolean,
warning: Boolean = false, warning: Boolean = false,
onCheckedChange: (Boolean) -> Unit, onCheckedChange: (Boolean) -> Unit,
@@ -276,7 +446,7 @@ private fun ExperimentalFeatureRow(
) { ) {
Column(Modifier.weight(1f)) { Column(Modifier.weight(1f)) {
Text(title, style = MaterialTheme.typography.titleMedium) Text(title, style = MaterialTheme.typography.titleMedium)
Text( if (description != null) Text(
description, description,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = if (warning) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant, color = if (warning) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant,
@@ -297,7 +467,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,
@@ -305,7 +475,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,
@@ -314,7 +484,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,
) )
@@ -357,22 +527,23 @@ 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)
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( // With no car to name, a greyed-out button only said what couldn't be done.
if (known) 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,
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(stringResource(R.string.carset_save_vehicle)) }
else Note(stringResource(R.string.carset_fit_needs_car))
saved?.let { Text(it, style = MaterialTheme.typography.bodyMedium) } saved?.let { Text(it, style = MaterialTheme.typography.bodyMedium) }
} }
} }
@@ -384,7 +555,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,
@@ -406,3 +577,8 @@ private fun StepButton(text: String, modifier: Modifier, onClick: () -> Unit) {
} }
} }
/** Common car screen sizes; Settings on the phone takes any other. */
private val SCREEN_PRESETS = listOf(
ScreenSize(800, 480), ScreenSize(1024, 600), ScreenSize(1280, 480), ScreenSize(1280, 720),
ScreenSize(1440, 540), ScreenSize(1920, 720), ScreenSize(1920, 1080),
)
@@ -1,5 +1,9 @@
package app.seb3thehacker.gearslip.car package app.seb3thehacker.gearslip.car
import androidx.compose.ui.res.stringResource
import androidx.compose.material.icons.filled.Call
import app.seb3thehacker.gearslip.call.CarCalls
import app.seb3thehacker.gearslip.call.CallUiState
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.LocalIndication
@@ -59,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
@@ -72,6 +78,7 @@ 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.car.theme.GsColors import app.seb3thehacker.gearslip.car.theme.GsColors
import app.seb3thehacker.gearslip.car.theme.withTintedSurfaces
import app.seb3thehacker.gearslip.car.theme.GsIconBox import app.seb3thehacker.gearslip.car.theme.GsIconBox
import app.seb3thehacker.gearslip.car.theme.GsIconButton import app.seb3thehacker.gearslip.car.theme.GsIconButton
import app.seb3thehacker.gearslip.car.theme.GsButton import app.seb3thehacker.gearslip.car.theme.GsButton
@@ -106,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
@@ -114,15 +122,21 @@ fun CarUi() {
AppTheme.LIGHT -> false AppTheme.LIGHT -> false
AppTheme.DARK -> true AppTheme.DARK -> true
} }
val colors = if (dark) dynamicDarkColorScheme(appContext) else dynamicLightColorScheme(appContext) 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) {
delay(250) delay(250)
controlsWake.tick() controlsWake.tick()
CarFullscreen.tick()
} }
} }
// The knob counts as a touch: the map's buttons can't be found while they're faded out. // The knob counts as a touch: the map's buttons can't be found while they're faded out.
@@ -132,7 +146,7 @@ fun CarUi() {
// Ask for the phone's sound as soon as the car connects, not only when a media app opens: // Ask for the phone's sound as soon as the car connects, not only when a media app opens:
// a video in the web browser or a map's voice needs it too. Asks once, after the car offers // a video in the web browser or a map's voice needs it too. Asks once, after the car offers
// somewhere to play it. // somewhere to play it.
LaunchedEffect(Unit) { if (CarSettings.pipeAudio.value) app.seb3thehacker.gearslip.audio.CarAudio.request() } LaunchedEffect(Unit) { if (CarSettings.pipesNow) app.seb3thehacker.gearslip.audio.CarAudio.request() }
// A new version gets one card in the top right, a few seconds in and only while parked. // A new version gets one card in the top right, a few seconds in and only while parked.
LaunchedEffect(Unit) { app.seb3thehacker.gearslip.update.UpdateChecker.onCarConnected(appContext) } LaunchedEffect(Unit) { app.seb3thehacker.gearslip.update.UpdateChecker.onCarConnected(appContext) }
// The map follows the light outside, whatever the app's own theme is set to. // The map follows the light outside, whatever the app's own theme is set to.
@@ -193,17 +207,34 @@ fun CarUi() {
.fillMaxSize() .fillMaxSize()
// Watches every touch without taking any: first in line, consumes nothing. // Watches every touch without taking any: first in line, consumes nothing.
.pointerInput(controlsWake) { .pointerInput(controlsWake) {
// In full screen, a swipe that starts at the top or bottom edge brings the
// nav bar back. Only from the very edge, so scrolling a page still scrolls.
val edge = 32.dp.toPx()
val slop = 24.dp.toPx()
var startY = -1f
awaitPointerEventScope { awaitPointerEventScope {
while (true) { while (true) {
awaitPointerEvent(PointerEventPass.Initial) val event = awaitPointerEvent(PointerEventPass.Initial)
controlsWake.touch() controlsWake.touch()
CarFullscreen.touch()
val change = event.changes.firstOrNull() ?: continue
if (!CarFullscreen.active) continue
if (change.pressed && !change.previousPressed) {
val y = change.position.y
startY = if (y < edge || y > size.height - edge) y else -1f
} else if (startY >= 0f && change.pressed && kotlin.math.abs(change.position.y - startY) > slop) {
CarFullscreen.reveal()
startY = -1f
}
} }
} }
}, },
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(),
@@ -211,6 +242,26 @@ 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
// The bar always lies over the map and every other screen rather than taking a row
// of its own: hiding it then leaves the map's surface the size it was.
// 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 appTemplate by CarServices.nav.template.collectAsState()
val barMayHide = screen == CarScreen.Home && when (navBarMode) {
NavBarMode.SHOW -> false
NavBarMode.HIDE_NAVIGATING -> appTemplate is androidx.car.app.navigation.model.NavigationTemplate
NavBarMode.HIDE -> true
}
// A full wait from when hiding becomes possible, not from whenever the last touch was.
LaunchedEffect(barMayHide) { if (barMayHide) controlsWake.touch() }
// 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 barShown = !keyboardVisible && (!barMayHide || controlsWake.navBarAwake || onCall)
// 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.
val fullscreen = CarFullscreen.active && screen != CarScreen.Home
Box(Modifier.fillMaxSize().padding(pad)) { Box(Modifier.fillMaxSize().padding(pad)) {
Column(Modifier.fillMaxSize()) { Column(Modifier.fillMaxSize()) {
if (!safetyWarningSeen) { if (!safetyWarningSeen) {
@@ -223,31 +274,63 @@ fun CarUi() {
// Home never leaves the composition; other screens cover it. Dropping it // Home never leaves the composition; other screens cover it. Dropping it
// would tear down the map's surface, and coming back would then show black // would tear down the map's surface, and coming back would then show black
// while the nav app redrew on a new one. // while the nav app redrew on a new one.
CarHome() CompositionLocalProvider(LocalNavBarInset provides homeInset) { CarHome() }
}
// 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
// show both.
}
// 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
// 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) {
Box(Modifier.fillMaxSize()) {
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 bar never hides here, so keep the screen clear of it. The
// 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)) CarToastOverlay(Modifier.align(Alignment.BottomCenter).padding(bottom = homeInset))
CarAlertOverlay(Modifier.align(Alignment.TopCenter))
CallPromptOverlay() CallPromptOverlay()
BluetoothPairOverlay() BluetoothPairOverlay()
PopupTimer() PopupTimer()
@@ -258,21 +341,39 @@ fun CarUi() {
) )
// Above the popup: a call is more urgent than any notification. // Above the popup: a call is more urgent than any notification.
VoiceHelpOverlay(Modifier.align(Alignment.Center)) VoiceHelpOverlay(Modifier.align(Alignment.Center))
CallOverlay(Modifier.align(Alignment.TopCenter)) CallOverlay()
AssistantOverlay(Modifier.align(Alignment.BottomCenter)) AssistantOverlay(Modifier.align(Alignment.BottomCenter).padding(bottom = homeInset))
if (app.seb3thehacker.gearslip.BuildConfig.DEBUG) { val badge by CarSettings.debugBadge.collectAsState()
if (app.seb3thehacker.gearslip.BuildConfig.DEBUG && badge) {
// Bottom corner, not top: a screen's own header almost always puts // Bottom corner, not top: a screen's own header almost always puts
// something in the top-right (settings, search, a Liked Songs // something in the top-right (settings, search, a Liked Songs
// shortcut) and this badge used to sit right on top of it, hiding // shortcut) and this badge used to sit right on top of it, hiding
// whatever the screen drew there. // whatever the screen drew there.
DebugBuildBadge(Modifier.align(Alignment.BottomEnd)) DebugBuildBadge(Modifier.align(Alignment.BottomEnd).padding(bottom = homeInset))
}
run {
androidx.compose.animation.AnimatedVisibility(
if (fullscreen) CarFullscreen.revealed || onCall else barShown,
Modifier.align(Alignment.BottomCenter),
enter = androidx.compose.animation.slideInVertically { it },
exit = androidx.compose.animation.slideOutVertically { it },
) { CarNavBar(navigator) }
}
if (fullscreen) {
androidx.compose.animation.AnimatedVisibility(
CarFullscreen.revealed,
Modifier.align(Alignment.TopCenter).padding(top = 12.dp),
enter = androidx.compose.animation.slideInVertically { -it },
exit = androidx.compose.animation.slideOutVertically { -it },
) {
GsButton(onClick = CarFullscreen::exit, modifier = Modifier.height(48.dp)) {
Icon(MediaIcons.FullscreenExit, contentDescription = null, modifier = Modifier.size(22.dp))
Spacer(Modifier.width(8.dp))
Text(stringResource(R.string.carui_exit_fullscreen), style = MaterialTheme.typography.titleMedium)
}
}
} }
} }
// 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
// show both.
val keyboardVisible by CarKeyboardVisibility.state
if (!keyboardVisible) CarNavBar(navigator)
} }
// Over everything, nav bar included: a long press on an app, anywhere. // Over everything, nav bar included: a long press on an app, anywhere.
AppMenu(navigator, rememberRunningApps()) AppMenu(navigator, rememberRunningApps())
@@ -284,6 +385,14 @@ fun CarUi() {
} }
} }
internal val NAV_BAR_HEIGHT = 56.dp
/**
* How much of the bottom of Home the floating nav bar covers right now: its height while it shows,
* zero while it's hidden or when it has a row of its own.
*/
internal val LocalNavBarInset = androidx.compose.runtime.compositionLocalOf { 0.dp }
/** /**
* A single blocking screen, shown once per install before anything else the car screen can show - * A single blocking screen, shown once per install before anything else the car screen can show -
* Home included. The phone's own setup guide carries the same warning, but a driver who plugs in * Home included. The phone's own setup guide carries the same warning, but a driver who plugs in
@@ -305,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)
} }
} }
} }
@@ -351,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)
@@ -367,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)) }
} }
} }
@@ -398,512 +505,6 @@ private fun DebugBuildBadge(modifier: Modifier = Modifier) {
} }
} }
/**
* "Home > Apps > Settings", like a file manager's address bar: hidden at Home itself (nothing to
* show yet), and grows by one crumb for every screen pushed from there. The back arrow undoes one
* step; a crumb jumps straight to it, dropping everything after - this is the only on-screen way
* back at all, since a car touchscreen has no hardware button for it.
*/
@Composable
private fun BreadcrumbBar(navigator: CarNavigator) {
val trail = navigator.trail
if (trail.size <= 1) return
Row(
Modifier.fillMaxWidth().height(40.dp).padding(start = 2.dp, end = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
GsIconButton(Icons.AutoMirrored.Filled.ArrowBack, "Back", navigator::back, size = 32.dp, iconSize = 20.dp)
Spacer(Modifier.width(2.dp))
trail.forEachIndexed { index, crumb ->
val isLast = index == trail.lastIndex
Text(
crumb.label,
style = MaterialTheme.typography.labelLarge,
fontWeight = if (isLast) FontWeight.SemiBold else FontWeight.Normal,
color = if (isLast) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = if (isLast) Modifier else Modifier
.clip(RoundedCornerShape(6.dp))
.clickable { navigator.jumpTo(index) }
.padding(horizontal = 4.dp, vertical = 2.dp),
)
if (!isLast) {
Text(
"›",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 2.dp),
)
}
}
}
}
/**
* Apps and open/pinned shortcuts on the left; now-playing pill against them; clock (tap for
* calendar and weather), phone battery and the darkness signal on the right. Icon-only: a driver
* reads a glyph faster than a label, and it keeps the bar short enough to leave the app underneath
* more room.
*/
@Composable
private fun CarNavBar(navigator: CarNavigator) {
val battery by CarEnvironment.battery.collectAsState()
val now by rememberNow()
val weather by Weather.state.collectAsState()
val weatherData = (weather as? WeatherState.Ready)?.data
val pinnedIds by CarSettings.pinnedApps.collectAsState()
val entries = rememberAllEntries()
val pinnedEntries = remember(entries, pinnedIds) {
pinnedIds.mapNotNull { id -> entries.find { it.componentId == id } }
}
val frame by CarEnvironment.frame.collectAsState()
val running = rememberRunningApps()
Surface(color = MaterialTheme.colorScheme.surfaceContainer) {
BoxWithConstraints(Modifier.fillMaxWidth().height(56.dp).padding(horizontal = 10.dp)) {
Row(Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) {
// Home and Apps are two different places now, not a toggle on one button - the
// breadcrumb trail is how you retrace your steps, but reaching for either of
// these two from three screens deep in a media app shouldn't mean reading it
// first.
NavHomeButton(navigator, entries, running)
Spacer(Modifier.width(6.dp))
NavItem(MediaIcons.Apps, navigator.current == CarScreen.Apps) { navigator.apps() }
Spacer(Modifier.width(6.dp))
NavAssistantIcon()
// Pinned shortcuts, then whatever else is open right now. A pinned app that's also
// open stays in its pinned spot, marked with a dot, instead of showing up twice.
// The map app lives on the Home button, so it never gets a second icon here.
val runningEntries = running.keys
.filter { it !in pinnedIds && running[it] != RunningSlot.NAV }
.mapNotNull { id -> entries.find { it.componentId == id } }
val shownPins = pinnedEntries.filter { running[it.componentId] != RunningSlot.NAV }
if (shownPins.isNotEmpty() || runningEntries.isNotEmpty()) NavDivider()
shownPins.forEach { entry -> NavEntryIcon(entry, running, navigator, frame) }
if (shownPins.isNotEmpty() && runningEntries.isNotEmpty()) NavDivider()
runningEntries.forEach { entry -> NavEntryIcon(entry, running, navigator, frame) }
// 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.
val turn = rememberNextTurn().takeIf { navigator.current != CarScreen.Home }
BoxWithConstraints(Modifier.weight(1f).padding(horizontal = 16.dp), contentAlignment = Alignment.Center) {
val roomForBoth = maxWidth >= TURN_AND_PLAYER_MIN_WIDTH
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
turn?.let { NavTurnPill(it) { navigator.home() } }
if (turn == null || roomForBoth) {
NavNowPlaying(navigator, selected = navigator.current == CarScreen.Media, modifier = Modifier.weight(1f, fill = false))
}
}
}
// Weather no longer has its own chip - the temperature alone (no icon, no room
// for one) sits where "Night"/"Day" used to, freeing a whole chip's width for the
// Home button above.
// The clock is also the notifications button: the dashboard lists them under the
// calendar and weather, and a dot here marks anything unread.
val unread by CarNotifications.unread.collectAsState()
NavChip(
navigator.current == CarScreen.Dashboard || navigator.current is CarScreen.Notifications,
{ navigator.dashboard() },
) {
if (unread > 0) {
Box(
Modifier
.align(Alignment.TopEnd)
.offset(x = 8.dp, y = (-2).dp)
.size(8.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary),
)
}
Column(horizontalAlignment = Alignment.End) {
Text(
DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(now)),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(3.dp)) {
if (battery.percent >= 0) {
Text(
"${battery.percent}%",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (battery.charging) {
ChargingGlyph(Modifier.size(13.dp))
}
}
weatherData?.let { w ->
Text(
" · ${w.temp}°",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
}
}
}
/**
* Home: the map, with the player or lyrics beside it. While a map app runs, the button wears that
* app's icon and a long press offers to close it, so the app needs no icon of its own on the bar.
*/
@Composable
private fun NavHomeButton(navigator: CarNavigator, entries: List<Entry>, running: Map<String, RunningSlot>) {
val selected = navigator.current == CarScreen.Home
val navId = running.entries.firstOrNull { it.value == RunningSlot.NAV }?.key
val navEntry = navId?.let { id -> entries.find { it.componentId == id } }
GsIconBox(
{ navigator.home() },
colors = navColors(selected),
latched = selected,
onLongClick = navId?.let { id -> { navigator.showAppMenu(id) } },
) {
val icon = navEntry?.icon
if (icon != null) {
Box(Modifier.padding(NAV_ITEM_PADDING).size(28.dp), contentAlignment = Alignment.Center) {
Image(icon, navEntry.label, Modifier.size(28.dp).clip(RoundedCornerShape(7.dp)))
}
} else {
Icon(
ImageVector.vectorResource(R.drawable.navigation_24), contentDescription = "Home",
modifier = Modifier.padding(NAV_ITEM_PADDING).size(28.dp),
)
}
}
}
/** Splits the nav bar's sections: Home and Apps, pinned apps, open apps. */
@Composable
private fun NavDivider() {
Box(
Modifier
.padding(horizontal = 10.dp)
.width(1.dp)
.height(28.dp)
.background(MaterialTheme.colorScheme.outlineVariant),
)
}
/**
* A pinned or open app on the nav bar. Tapping brings it back without reconnecting if it's
* already open; a long press opens its menu (pin, unpin, close).
*/
@Composable
private fun NavEntryIcon(
entry: Entry,
running: Map<String, RunningSlot>,
navigator: CarNavigator,
frame: CarEnvironment.Frame,
) {
val id = entry.componentId ?: return
val slot = running[id]
Box(Modifier.padding(horizontal = 3.dp)) {
NavAppIcon(
entry,
open = slot != null,
showing = slot != null && slot.isShowing(navigator.current),
onLongClick = { navigator.showAppMenu(id) },
) { openApp(entry, slot, navigator, frame) }
}
}
/** One open or pinned app's icon on the nav bar - the same fixed size and shape either way. */
@Composable
private fun NavAppIcon(
entry: Entry,
open: Boolean,
showing: Boolean,
onLongClick: () -> Unit,
onClick: () -> Unit,
) {
val scheme = MaterialTheme.colorScheme
GsIconBox(
onClick,
Modifier.size(44.dp),
colors = if (showing) GsColors(scheme.secondaryContainer, scheme.onSecondaryContainer)
else GsColors(scheme.surfaceVariant, scheme.onSurfaceVariant),
latched = showing,
onLongClick = onLongClick,
) {
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)) }
// Only running apps get the line along the bottom edge, the usual taskbar mark for it.
if (open) {
Box(
Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 2.dp)
.size(width = 12.dp, height = 3.dp)
.clip(RoundedCornerShape(50))
.background(scheme.primary),
)
}
}
}
/** A tappable group in the bar that stays highlighted while its screen is open, like [NavItem]. */
@Composable
private fun NavChip(selected: Boolean, onClick: () -> Unit, content: @Composable BoxScope.() -> Unit) {
GsIconBox(onClick, colors = navColors(selected), latched = selected) {
Box(Modifier.padding(horizontal = 10.dp, vertical = 2.dp)) { content() }
}
}
/** Every nav bar item is raised, so it reads as pressable; the current screen's takes the accent colour. */
@Composable
private fun navColors(selected: Boolean): GsColors {
val s = MaterialTheme.colorScheme
return if (selected) GsColors(s.secondaryContainer, s.onSecondaryContainer)
else GsColors(s.surfaceContainerHighest, s.onSurface)
}
/**
* Between Apps and whatever's actually open, not with the clock/battery group on the right - a
* driver reaches for it right after deciding "not a tap for this", same gesture family as the
* two buttons beside it.
*/
@Composable
private fun NavAssistantIcon() {
val state by CarAssistant.state.collectAsState()
val active = state.phase != CarAssistant.Phase.IDLE
GsIconBox({ CarAssistant.start() }, colors = navColors(active), latched = active) {
MicGlyph(Modifier.padding(NAV_ITEM_PADDING).size(28.dp))
}
}
/** Inner padding of every icon on the nav bar, leaving each button room inside the 56dp bar. */
private val NAV_ITEM_PADDING = 8.dp
@Composable
private fun NavItem(icon: ImageVector, selected: Boolean, onClick: () -> Unit) {
GsIconBox(onClick, colors = navColors(selected), latched = selected) {
Icon(icon, contentDescription = null, modifier = Modifier.padding(NAV_ITEM_PADDING).size(28.dp))
}
}
/** Wall-clock time, ticking on the minute: no reason to redraw (and re-encode) more often. */
@Composable
internal fun rememberNow(): State<Long> = produceState(System.currentTimeMillis()) {
while (true) {
value = System.currentTimeMillis()
delay(60_000 - value % 60_000)
}
}
/** How long the pill shows a new song's title before settling to just the controls. */
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. */
private val TURN_AND_PLAYER_MIN_WIDTH = 520.dp
/** The next manoeuvre, as the running nav app last described it. */
private class NextTurn(val icon: CarIcon?, val distance: String, val road: String)
/**
* Read straight off the nav app's own template: a navigation app keeps sending its routing card
* while it's running, whether or not the map is the screen being shown.
*/
@Composable
private fun rememberNextTurn(): NextTurn? {
val status by CarServices.nav.status.collectAsState()
val template by CarServices.nav.template.collectAsState()
// Read so a change of units redraws the distance straight away, not at the next update.
CarSettings.units.collectAsState().value
if (status.phase != CarAppConnection.Phase.RUNNING) return null
val routing = (template as? NavigationTemplate)?.navigationInfo as? RoutingInfo ?: return null
if (routing.isLoading) return null
val step = routing.currentStep ?: return null
return NextTurn(
icon = step.maneuver?.icon,
distance = routing.currentDistance.display(),
road = step.road.text().ifEmpty { step.cue.text() },
)
}
/** The next turn in the nav bar: arrow, how far, onto what. Tapping goes back to the map. */
@Composable
private fun NavTurnPill(turn: NextTurn, onClick: () -> Unit) {
// Same finish as every other button on the bar, in the accent colour; the text runs a step
// bigger than anything else there, since it's the one thing worth a glance mid-drive.
GsIconBox(
onClick,
Modifier.widthIn(max = 340.dp).height(44.dp),
colors = gsColors(GsTone.Primary),
) {
Row(
Modifier.padding(NAV_ITEM_PADDING),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
CarGlyph(turn.icon, Modifier.size(28.dp))
if (turn.distance.isNotEmpty()) {
Text(turn.distance, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, maxLines = 1)
}
if (turn.road.isNotEmpty()) {
Text(
turn.road,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
/** "Title - Artist" on one line. The text itself cuts it off, and only where the pill runs out of room. */
private fun nowPlayingLabel(title: String, artist: String): String =
listOf(title.trim(), artist.trim()).filter { it.isNotEmpty() }.joinToString(" - ")
.ifEmpty { "Nothing playing" }
/**
* 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
* 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.
*/
@Composable
private fun NavNowPlaying(navigator: CarNavigator, selected: Boolean, modifier: Modifier = Modifier) {
val media = CarServices.media
val phase by media.phase.collectAsState()
val now by media.now.collectAsState()
if (phase != CarMedia.Phase.READY || !now.isActive) return
// Docked beside the map, the player is already on screen - the pill steps aside for it.
if (navigator.sidePanel == SidePanel.CONTROLS && navigator.current == CarScreen.Home) return
// The media screen is the full player already; the pill would only repeat its controls.
if (navigator.current == CarScreen.Media) return
val context = LocalContext.current
val art by produceState(now.art, now.art, now.artUri) { value = now.art ?: MediaArt.load(context, now.artUri) }
val position by produceState(now.currentPosition(), now) {
while (true) {
value = now.currentPosition()
if (!now.playing) break
delay(1000)
}
}
// A new song announces itself: title, play/pause and dock for a few seconds, then the title
// goes and the pill settles to just the controls - unless the driver keeps the title up.
val keepTitle by CarSettings.playerTitle.collectAsState()
var announcing by remember { mutableStateOf(true) }
LaunchedEffect(now.title, now.artist) {
announcing = true
delay(TITLE_SHOWN_MS)
announcing = false
}
val showTitle = keepTitle || announcing
val scheme = MaterialTheme.colorScheme
val onPill = if (selected) scheme.onPrimaryContainer else scheme.onSecondaryContainer
Surface(
modifier = modifier
.animateContentSize()
// With the title up, all the room the bar has, so as much of it shows as can.
.then(if (showTitle) Modifier.fillMaxWidth() else Modifier)
.height(44.dp),
shape = RoundedCornerShape(14.dp),
color = if (selected) scheme.primaryContainer else scheme.secondaryContainer,
contentColor = onPill,
) {
Box {
Row(
Modifier.fillMaxHeight().padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
// Art (and the title while it shows) opens the full media screen.
Row(
Modifier
.then(if (showTitle) Modifier.weight(1f) else Modifier)
.fillMaxHeight()
.clip(RoundedCornerShape(10.dp))
.clickable { navigator.media() },
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Box(Modifier.size(34.dp).clip(RoundedCornerShape(8.dp)).background(scheme.surfaceVariant)) {
art?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) }
}
if (showTitle) {
Text(
nowPlayingLabel(now.title, now.artist),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
if (!showTitle) {
GsIconButton(
MediaIcons.Previous, "Previous", media::previous,
enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_PREVIOUS), size = 36.dp, iconSize = 22.dp,
)
}
GsIconButton(
if (now.playing) MediaIcons.Pause else Icons.Filled.PlayArrow,
if (now.playing) "Pause" else "Play",
media::togglePlay,
tone = GsTone.Primary, size = 36.dp, iconSize = 22.dp,
)
if (!showTitle) {
GsIconButton(
MediaIcons.Next, "Next", media::next,
enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_NEXT), size = 36.dp, iconSize = 22.dp,
)
}
GsIconButton(
MediaIcons.DockRight, "Move the player beside the map", navigator::togglePlayerDock,
size = 36.dp, iconSize = 22.dp,
)
}
if (now.durationMs > 0) {
// matchParentSize, so the line follows the pill's width rather than widening it.
Box(Modifier.matchParentSize(), contentAlignment = Alignment.BottomStart) {
Box(
Modifier
.fillMaxWidth((position.toFloat() / now.durationMs).coerceIn(0f, 1f))
.height(2.dp)
.background(onPill.copy(alpha = 0.7f)),
)
}
}
}
}
}
/**
* A filled lightning bolt, next to the battery percentage instead of the word "charging" - the
* core Material icon set this project ships (no extended pack, to keep the app small) has no
* bolt, so this is drawn rather than a vector asset.
*/
@Composable
private fun ChargingGlyph(modifier: Modifier = Modifier, tint: Color = MaterialTheme.colorScheme.onSurfaceVariant) {
androidx.compose.foundation.Canvas(modifier) {
val w = size.width
val h = size.height
val bolt = androidx.compose.ui.graphics.Path().apply {
moveTo(w * 0.58f, 0f)
lineTo(w * 0.12f, h * 0.58f)
lineTo(w * 0.46f, h * 0.58f)
lineTo(w * 0.42f, h)
lineTo(w * 0.88f, h * 0.40f)
lineTo(w * 0.54f, h * 0.40f)
close()
}
drawPath(bolt, tint)
}
}
/** Stands in for the phone's vibration motor on the car screen: does nothing. */ /** Stands in for the phone's vibration motor on the car screen: does nothing. */
private object NoHaptics : HapticFeedback { private object NoHaptics : HapticFeedback {
override fun performHapticFeedback(hapticFeedbackType: HapticFeedbackType) {} override fun performHapticFeedback(hapticFeedbackType: HapticFeedbackType) {}
@@ -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
@@ -24,23 +25,36 @@ import app.seb3thehacker.gearslip.Protobuf
*/ */
class ClusterNav( class ClusterNav(
val channelId: Int, val channelId: Int,
minimumIntervalMs: Long = 0,
private val sendOnChannel: (messageId: Int, body: ByteArray) -> Unit, private val sendOnChannel: (messageId: Int, body: ByteArray) -> Unit,
) { ) {
private val updateIntervalMs = maxOf(TICK_MS, minimumIntervalMs)
@Volatile private var running = false @Volatile private var running = false
private var worker: Thread? = null
private var updatesRequested = true
private var sentStatus = -1 private var sentStatus = -1
private var sentTurn: String? = null private var sentTurn: String? = null
private var sentDistance: String? = null private var sentDistance: String? = null
@Synchronized
fun onMessage(messageId: Int, body: ByteArray) { fun onMessage(messageId: Int, body: ByteArray) {
when (messageId) { when (messageId) {
MSG_CHANNEL_OPEN_RESPONSE -> { MSG_CHANNEL_OPEN_RESPONSE -> {
val status = Protobuf.readInt32Field(body, 1) ?: 0 val status = Protobuf.readInt32Field(body, 1)
GearslipLog.i("cluster: <- ChannelOpenResponse: status=$status") GearslipLog.i("cluster: <- ChannelOpenResponse: status=$status")
if (status == 0) start() if (status == 0) start()
} }
// The car saying when it wants turns. Sending all along is harmless, so these are only logged. MSG_START -> {
MSG_START -> GearslipLog.i("cluster: <- start") updatesRequested = true
MSG_STOP -> GearslipLog.i("cluster: <- stop") sentStatus = -1
sentTurn = null
sentDistance = null
GearslipLog.i("cluster: <- start")
}
MSG_STOP -> {
updatesRequested = false
GearslipLog.i("cluster: <- stop")
}
else -> GearslipLog.i("cluster: <- unhandled message id=$messageId (${body.size} bytes)") else -> GearslipLog.i("cluster: <- unhandled message id=$messageId (${body.size} bytes)")
} }
} }
@@ -48,19 +62,28 @@ class ClusterNav(
private fun start() { private fun start() {
if (running) return if (running) return
running = true running = true
Thread({ worker = Thread({
while (running) { while (running) {
runCatching { tick() }.onFailure { GearslipLog.w("cluster: ${it.message}") } runCatching { tick() }.onFailure { GearslipLog.w("cluster: ${it.message}") }
Thread.sleep(TICK_MS) try {
Thread.sleep(updateIntervalMs)
} catch (_: InterruptedException) {
return@Thread
}
} }
}, "gearslip-cluster").start() }, "gearslip-cluster").also { it.start() }
} }
@Synchronized
fun stop() { fun stop() {
running = false running = false
worker?.interrupt()
worker = null
} }
@Synchronized
private fun tick() { private fun tick() {
if (!running || !updatesRequested) return
val turn = NavTurn.current() val turn = NavTurn.current()
val status = when { val status = when {
turn == null -> STATUS_INACTIVE turn == null -> STATUS_INACTIVE
@@ -82,7 +105,7 @@ class ClusterNav(
val maneuver = step.maneuver val maneuver = step.maneuver
val (event, side) = maneuver?.let { eventFor(it.type) } ?: (EVENT_UNKNOWN to SIDE_UNSPECIFIED) val (event, side) = maneuver?.let { eventFor(it.type) } ?: (EVENT_UNKNOWN to SIDE_UNSPECIFIED)
val road = step.road.text().ifBlank { step.cue.text() } val road = step.road.text().ifBlank { step.cue.text() }
val key = "$road|$event|$side|${maneuver?.roundaboutExitNumber}" val key = "$road|$event|$side|${maneuver?.roundaboutExitNumber}|${maneuver?.roundaboutExitAngle}"
if (key == sentTurn) return if (key == sentTurn) return
sentTurn = key sentTurn = key
var body = Protobuf.stringField(1, road) + var body = Protobuf.stringField(1, road) +
@@ -178,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,299 @@
package app.seb3thehacker.gearslip.car
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.view.SurfaceHolder
import android.view.SurfaceView
import androidx.car.app.CarAppService
import androidx.car.app.SessionInfo
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
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.DisposableEffect
import androidx.compose.runtime.key
import androidx.compose.runtime.remember
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import app.seb3thehacker.gearslip.Insets
import app.seb3thehacker.gearslip.host.CarAppConnection
import app.seb3thehacker.gearslip.host.TemplateApp
import app.seb3thehacker.gearslip.host.clockTime
import app.seb3thehacker.gearslip.host.display
import app.seb3thehacker.gearslip.host.remainingTime
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.StateFlow
/**
* The instrument-cluster screen: the next turn, big enough to read in a glance past the wheel.
* 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. Many cars also draw their own dials and bars over what's left, so the
* card keeps to [clearArea] when the car says where that is, and otherwise to the middle of a
* wide strip: on an Audi's virtual cockpit it ran under the rev counter and the bottom bar (#56).
*
* When the main screen's navigation app has a session for clusters, it draws its map to the
* right of the card, on a surface Gearslip lends that session as it does the main one.
*/
@Composable
internal fun ClusterScreen(marginX: Int, marginY: Int, densityDpi: Int, clearArea: StateFlow<Insets?>) {
val carInsets by clearArea.collectAsState()
val context = LocalContext.current
// Which app draws the map: whatever navigation app the main screen runs, if it can.
val navApp by produceState<ComponentName?>(null) {
while (true) {
value = CarServices.navOrNull()?.connectedComponent
delay(2_000)
}
}
val mapApp = navApp?.let { app -> remember(app) { app.takeIf { drawsOnCluster(context, it) } } }
// 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() },
vertical = with(density) { (marginY / 2).toDp() },
),
) {
val clear = clearPadding(maxWidth, maxHeight, carInsets?.let { with(density) { toDp(it) } })
BoxWithConstraints(
Modifier.fillMaxSize().padding(start = clear[0], top = clear[1], end = clear[2], bottom = clear[3]),
contentAlignment = Alignment.Center,
) {
val t = turn
val map = mapApp?.let { clusterMap(it, densityDpi, constraints.maxWidth, constraints.maxHeight) }
if (map != null && mapShowing(map)) {
Row(Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) {
if (t != null) {
BoxWithConstraints(Modifier.weight(0.42f).fillMaxHeight(), contentAlignment = Alignment.CenterStart) {
// Narrower than alone: the arrow and distance alone are about 12 units wide.
TurnCard(t, minOf(maxHeight / t.rows, maxWidth / 12))
}
Spacer(Modifier.width(12.dp))
}
MapSurface(map, densityDpi, Modifier.weight(0.58f).fillMaxHeight())
}
return@BoxWithConstraints
}
if (t == null) {
NowPlayingLine(Modifier.align(Alignment.Center))
return@BoxWithConstraints
}
// The card is t.rows units tall and about 14 wide: fit both ways.
TurnCard(t, minOf(maxHeight / t.rows, maxWidth / 14))
}
}
}
}
}
/**
* The navigation app's cluster session, open while this is on screen. Sized for the clear area
* it shares with the card, so the app lays its map out for a cluster-shaped screen.
*/
@Composable
private fun clusterMap(app: ComponentName, densityDpi: Int, width: Int, height: Int): CarAppConnection {
val context = LocalContext.current.applicationContext
val connection = remember(app) {
CarAppConnection(context, SessionInfo(SessionInfo.DISPLAY_TYPE_CLUSTER, CLUSTER_SESSION))
}
DisposableEffect(connection) {
connection.connect(TemplateApp(app, app.packageName, emptyList()), (width * 0.58f).toInt(), height, densityDpi)
onDispose { connection.disconnect() }
}
return connection
}
/**
* Whether the map's place is kept: while the session comes up, and once running only if the app
* asked for a surface. An app whose cluster session draws nothing leaves the card on its own.
*/
@Composable
private fun mapShowing(map: CarAppConnection): Boolean {
val status by map.status.collectAsState()
return when (status.phase) {
CarAppConnection.Phase.BINDING, CarAppConnection.Phase.HANDSHAKE -> true
CarAppConnection.Phase.RUNNING -> status.surfaceRequested || status.template == null
else -> false
}
}
/** A surface lent to the cluster session; a fresh one for every size, as on the main screen. */
@Composable
private fun MapSurface(map: CarAppConnection, densityDpi: Int, modifier: Modifier) {
BoxWithConstraints(modifier) {
key(constraints.maxWidth, constraints.maxHeight) {
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { context ->
SurfaceView(context).apply {
holder.addCallback(object : SurfaceHolder.Callback {
override fun surfaceCreated(holder: SurfaceHolder) = Unit
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) =
map.attachSurface(holder.surface, width, height, densityDpi)
override fun surfaceDestroyed(holder: SurfaceHolder) = map.detachSurface(holder.surface)
})
}
},
)
}
}
DisposableEffect(map) { onDispose { map.detachSurface() } }
}
/** Whether [component] says, in its manifest, that it has a session for instrument clusters. */
private fun drawsOnCluster(context: Context, component: ComponentName): Boolean = runCatching {
val intent = Intent(CarAppService.SERVICE_INTERFACE).addCategory(FEATURE_CLUSTER).setPackage(component.packageName)
context.packageManager.queryIntentServices(intent, 0).any { it.serviceInfo.name == component.className }
}.getOrDefault(false)
private const val FEATURE_CLUSTER = "androidx.car.app.category.FEATURE_CLUSTER"
private const val CLUSTER_SESSION = "gearslip-cluster"
/** The lane to be in, on the cockpit: green, like the main screen's header, against the black. */
private val LANE_FILL = Color(0xFF2F7D6D)
/**
* Start, top, end and bottom padding around the card inside the visible picture. The car's own
* insets win when they leave a usable area; a wide strip, where dials sit at both ends and a bar
* along the bottom, keeps to the upper middle; anything squarer just keeps off the edges.
*/
private fun clearPadding(width: Dp, height: Dp, car: List<Dp>?): List<Dp> {
if (car != null && width - car[0] - car[2] >= width * 0.3f && height - car[1] - car[3] >= height * 0.3f) return car
if (width >= height * 2) return listOf(width * 0.22f, height * 0.08f, width * 0.22f, height * 0.33f)
return listOf(24.dp, 16.dp, 24.dp, 16.dp)
}
/** Insets in frame pixels as start, top, end and bottom padding. */
private fun androidx.compose.ui.unit.Density.toDp(insets: Insets): List<Dp> =
listOf(insets.left.toDp(), insets.top.toDp(), insets.right.toDp(), insets.bottom.toDp())
@Composable
private fun TurnCard(t: ClusterTurn, unit: Dp) {
val density = LocalDensity.current
Column(verticalArrangement = Arrangement.spacedBy(unit * 0.4f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
CarGlyph(t.icon, Modifier.size(unit * 3.4f))
Spacer(Modifier.width(unit))
Text(
t.distance,
fontSize = with(density) { (unit * 2.2f).toSp() },
fontWeight = FontWeight.Bold,
maxLines = 1,
)
}
if (t.what.isNotEmpty()) {
Text(
t.what,
fontSize = with(density) { (unit * 1.1f).toSp() },
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
if (t.lanes.isNotEmpty()) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
LaneRoad(t.lanes, Modifier.width(unit * 10).height(unit * 2.4f), LANE_FILL, Color.White, Color.Black)
laneHint(t.lanes)?.let {
Text(
it,
Modifier.padding(top = unit * 0.3f),
fontSize = with(density) { (unit * 0.8f).toSp() },
color = Color.White.copy(alpha = 0.85f),
maxLines = 1,
)
}
}
}
if (t.trip.isNotEmpty()) {
Text(
t.trip,
fontSize = with(density) { (unit * 0.85f).toSp() },
color = Color.White.copy(alpha = 0.7f),
maxLines = 1,
)
}
}
}
@Composable
private fun NowPlayingLine(modifier: Modifier) {
val now = CarServices.mediaOrNull()?.now?.value
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,
val lanes: List<androidx.car.app.navigation.model.Lane>,
) {
/** How many units tall the card is: two lines of directions, and the lanes when there are some. */
val rows: Float get() = if (lanes.isEmpty()) 8.5f else 12f
companion object {
fun now(): ClusterTurn? {
val turn = NavTurn.current() ?: return null
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, step.lanes.orEmpty())
}
}
}
@@ -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
@@ -27,7 +32,8 @@ import kotlin.math.roundToInt
* same turn the instrument cluster gets ([NavTurn]) and says it itself: once when the turn comes * same turn the instrument cluster gets ([NavTurn]) and says it itself: once when the turn comes
* up, again a kilometre or so out on a long stretch, and once more just before it. The words are * up, again a kilometre or so out on a long stretch, and once more just before it. The words are
* rendered with the phone's text-to-speech to a file, never played on the phone, and streamed to * rendered with the phone's text-to-speech to a file, never played on the phone, and streamed to
* the car, which ducks the music under them the way it does for real Android Auto. * the car, which ducks the music under them the way it does for real Android Auto. When the music
* isn't Gearslip's, each announcement first asks the car for its speakers, as Android Auto does.
* *
* Nothing spoken is logged: road names are where the driver is going. * Nothing spoken is logged: road names are where the driver is going.
*/ */
@@ -38,11 +44,19 @@ 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
private val speaker = Executors.newSingleThreadExecutor { Thread(it, "gearslip-directions").apply { isDaemon = true } } @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 } }
/** Counts announcements, so a hand-back scheduled after one is dropped if another has begun. */
private var spoken = 0
@Volatile private var latest = 0 @Volatile private var latest = 0
private var turnKey: String? = null private var turnKey: String? = null
@@ -51,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() }
@@ -61,9 +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) { fun attach(link: AudioLink, musicIsOurs: () -> Boolean, musicSink: () -> AudioLink? = { null }) {
this.link = link this.link = link
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
@@ -82,16 +120,26 @@ object DirectionsVoice {
running = false running = false
} }
/** Why the last turn wasn't spoken, so the log says it once rather than every tick. */
private var silentBecause: String? = null
private fun quiet(reason: String) {
if (silentBecause == reason) return
silentBecause = reason
GearslipLog.i("guidance: not speaking - $reason")
}
private fun tick() { private fun tick() {
if (!CarSettings.speakDirections.value) return
val turn = NavTurn.current() val turn = NavTurn.current()
if (turn == null) { if (turn == null) {
turnKey = null turnKey = null
return return
} }
if (turn.rerouting) return if (!CarSettings.speakDirections.value) return quiet("Spoken directions is off in the car's Settings")
if (turn.rerouting) return quiet("the map app is rerouting")
val step = turn.step val step = turn.step
val instruction = instruction(step) ?: return val instruction = instruction(step) ?: return quiet("the map app gave this turn no words")
silentBecause = null
val meters = turn.distance?.let(NavTurn::toMeters) val meters = turn.distance?.let(NavTurn::toMeters)
val close = (turn.seconds != null && turn.seconds <= NEAR_SECONDS) || (meters != null && meters <= NEAR_METERS) val close = (turn.seconds != null && turn.seconds <= NEAR_SECONDS) || (meters != null && meters <= NEAR_METERS)
@@ -106,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
@@ -117,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
} }
} }
@@ -125,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())
} }
} }
@@ -204,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")
@@ -219,26 +289,67 @@ 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
} }
stream(sink, pcm) 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.
GearslipLog.i("guidance: speaking a turn")
// Held through back-to-back prompts rather than asked for and given back each time.
if (!musicIsOurs()) sink.askForSpeakers()
val turn = ++spoken
try {
stream(sink, pcm)
} finally {
speaker.schedule({ if (turn == spoken) sink.handBackSpeakers() }, HOLD_MS, TimeUnit.MILLISECONDS)
}
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
@@ -249,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()
} }
} }
@@ -321,6 +432,11 @@ object DirectionsVoice {
private const val PREPARE_FROM_METERS = 2_500 private const val PREPARE_FROM_METERS = 2_500
private const val PREPARE_AT_METERS = 1_000 private const val PREPARE_AT_METERS = 1_000
private const val LEAD_MS = 100L private const val LEAD_MS = 100L
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,182 @@
package app.seb3thehacker.gearslip.car
import android.graphics.Color as AndroidColor
import androidx.car.app.model.Place
import androidx.car.app.model.PlaceListMapTemplate
import androidx.car.app.model.Row
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import app.seb3thehacker.gearslip.GearslipLog
import org.maplibre.android.MapLibre
import org.maplibre.android.camera.CameraUpdateFactory
import org.maplibre.android.geometry.LatLng
import org.maplibre.android.geometry.LatLngBounds
import org.maplibre.android.maps.MapLibreMap
import org.maplibre.android.maps.MapLibreMapOptions
import org.maplibre.android.maps.MapView
import org.maplibre.android.maps.Style
import org.maplibre.android.style.expressions.Expression
import org.maplibre.android.style.layers.CircleLayer
import org.maplibre.android.style.layers.PropertyFactory
import org.maplibre.android.style.layers.SymbolLayer
import org.maplibre.android.style.sources.GeoJsonSource
import org.maplibre.geojson.Feature
import org.maplibre.geojson.FeatureCollection
import org.maplibre.geojson.Point
/**
* The map behind a [PlaceListMapTemplate]. For this template the host draws the map, not the
* app: Android Auto puts Google's map there, with a numbered pin for each place in the list and
* the anchor marked. RF Sentinel lists its nearby places this way, and with nothing drawn the
* area stayed empty.
*
* Tiles come from OpenFreeMap: free, no key, and no account, so nothing ties a drive to anyone.
* The camera fits every pin into the part of the map the list card doesn't cover.
*/
@Composable
internal fun HostPlaceMap(template: PlaceListMapTemplate, modifier: Modifier = Modifier) {
val dark = MaterialTheme.colorScheme.background.luminance() < 0.5f
val pin = MaterialTheme.colorScheme.primary.toArgb()
val pinText = MaterialTheme.colorScheme.onPrimary.toArgb()
val anchorColor = MaterialTheme.colorScheme.tertiary.toArgb()
val cardsAtEnd = LocalCardsAtEnd.current
val places = remember(template) { placesOf(template) }
var map by remember { mutableStateOf<MapLibreMap?>(null) }
var style by remember { mutableStateOf<Style?>(null) }
BoxWithConstraints(modifier) {
val density = LocalDensity.current
// Keep the pins out from under the list card, the way the app's own map would.
val card = with(density) { cardColumnWidth(maxWidth).roundToPx() }
val margin = with(density) { 48.dp.roundToPx() }
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { context ->
MapLibre.getInstance(context)
// A TextureView, so the map composites with the cards drawn over it inside the
// car's virtual display, as any other view does.
val options = MapLibreMapOptions.createFromAttributes(context).textureMode(true)
MapView(context, options).apply {
onCreate(null)
onStart()
onResume()
getMapAsync { m ->
m.uiSettings.isLogoEnabled = false
m.uiSettings.isCompassEnabled = false
m.uiSettings.isRotateGesturesEnabled = false
m.uiSettings.isTiltGesturesEnabled = false
map = m
}
}
},
onRelease = { view ->
runCatching {
view.onPause()
view.onStop()
view.onDestroy()
}
},
)
// The style follows the car's theme; the pins go in once it's loaded.
LaunchedEffect(map, dark) {
val m = map ?: return@LaunchedEffect
style = null
m.setStyle(Style.Builder().fromUri(if (dark) DARK_STYLE else LIGHT_STYLE)) { loaded -> style = loaded }
}
LaunchedEffect(style, places, pin, card, cardsAtEnd) {
val m = map ?: return@LaunchedEffect
val s = style ?: return@LaunchedEffect
showPins(s, places, pin, pinText, anchorColor)
val points = places.map { it.latLng }
when {
points.isEmpty() -> Unit
points.size == 1 -> m.moveCamera(CameraUpdateFactory.newLatLngZoom(points.first(), SINGLE_PLACE_ZOOM))
else -> {
val bounds = LatLngBounds.Builder().includes(points).build()
val left = if (cardsAtEnd) margin else card + margin
val right = if (cardsAtEnd) card + margin else margin
runCatching { m.moveCamera(CameraUpdateFactory.newLatLngBounds(bounds, left, margin, right, margin)) }
.onFailure { GearslipLog.w("host map: couldn't fit ${points.size} places: ${it.message}") }
}
}
}
DisposableEffect(Unit) { onDispose { map = null } }
}
}
/** One pin: where, and the label on it (the list's number, or the app's own marker text). */
private class Pin(val latLng: LatLng, val label: String, val anchor: Boolean)
private fun placesOf(template: PlaceListMapTemplate): List<Pin> {
val rows = template.itemList?.items.orEmpty().filterIsInstance<Row>()
val pins = rows.mapIndexedNotNull { i, row ->
val place = row.metadata?.place ?: return@mapIndexedNotNull null
pinOf(place, (i + 1).toString(), anchor = false)
}
return pins + listOfNotNull(template.anchor?.let { pinOf(it, "", anchor = true) })
}
private fun pinOf(place: Place, fallback: String, anchor: Boolean): Pin? {
val location = place.location
if (location.latitude == 0.0 && location.longitude == 0.0) return null
val label = place.marker?.label?.toString()?.takeIf { it.isNotBlank() } ?: fallback
return Pin(LatLng(location.latitude, location.longitude), label, anchor)
}
private fun showPins(style: Style, pins: List<Pin>, pin: Int, pinText: Int, anchorColor: Int) {
val features = FeatureCollection.fromFeatures(
pins.map { p ->
Feature.fromGeometry(Point.fromLngLat(p.latLng.longitude, p.latLng.latitude)).apply {
addStringProperty("label", p.label)
addBooleanProperty("anchor", p.anchor)
}
},
)
val source = style.getSourceAs<GeoJsonSource>(SOURCE)
if (source != null) {
source.setGeoJson(features)
return
}
style.addSource(GeoJsonSource(SOURCE, features))
style.addLayer(
CircleLayer(PIN_LAYER, SOURCE).withProperties(
PropertyFactory.circleRadius(Expression.switchCase(Expression.get("anchor"), Expression.literal(9f), Expression.literal(14f))),
PropertyFactory.circleColor(Expression.switchCase(Expression.get("anchor"), Expression.color(anchorColor), Expression.color(pin))),
PropertyFactory.circleStrokeColor(AndroidColor.WHITE),
PropertyFactory.circleStrokeWidth(2f),
),
)
style.addLayer(
SymbolLayer(LABEL_LAYER, SOURCE).withProperties(
PropertyFactory.textField(Expression.get("label")),
PropertyFactory.textFont(arrayOf("Noto Sans Bold")),
PropertyFactory.textSize(13f),
PropertyFactory.textColor(pinText),
PropertyFactory.textAllowOverlap(true),
PropertyFactory.textIgnorePlacement(true),
),
)
}
private const val LIGHT_STYLE = "https://tiles.openfreemap.org/styles/positron"
private const val DARK_STYLE = "https://tiles.openfreemap.org/styles/dark"
private const val SOURCE = "gearslip-places"
private const val PIN_LAYER = "gearslip-place-pins"
private const val LABEL_LAYER = "gearslip-place-labels"
private const val SINGLE_PLACE_ZOOM = 14.0
@@ -0,0 +1,258 @@
package app.seb3thehacker.gearslip.car
import androidx.car.app.navigation.model.Lane
import androidx.car.app.navigation.model.LaneDirection
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import app.seb3thehacker.gearslip.R
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.cos
import kotlin.math.sin
/** The dark band the lanes sit on under the turn card's header, tinted toward the theme. */
@Composable
internal fun laneBandColor(): Color = lerp(Color(0xFF0E1417), MaterialTheme.colorScheme.primary, 0.14f)
/**
* The lanes before a manoeuvre as a dark band under the turn card's header: the road ahead in
* perspective, with the lane or lanes to be in filled with the header's colour.
*/
@Composable
internal fun LaneBand(lanes: List<Lane>, modifier: Modifier = Modifier) {
val scheme = MaterialTheme.colorScheme
val band = laneBandColor()
LaneRoad(
lanes,
modifier.background(band).padding(horizontal = 16.dp, vertical = 10.dp).fillMaxWidth().height(52.dp),
scheme.primaryContainer,
scheme.onPrimaryContainer,
band,
)
}
/** Which lanes to use, in words, on the same band: "Use the left lane". Nothing when it can't say. */
@Composable
internal fun LaneHintLine(lanes: List<Lane>, modifier: Modifier = Modifier) {
val hint = laneHint(lanes) ?: return
Text(
hint,
modifier.background(laneBandColor()).padding(start = 16.dp, end = 16.dp, bottom = 10.dp),
style = ChromeType.label,
color = Color.White,
textAlign = TextAlign.Center,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
/**
* The road ahead, narrowing toward the top: solid edges, dashed lines between lanes, the
* recommended lanes filled with [fill] and every lane's arrows drawn from its directions, the
* recommended ones in [onFill] and the rest faded toward [ground], the colour behind the road.
* The arrows are painted on the road, so they narrow with it. Lanes run left to right, as the app
* sends them.
*/
@Composable
internal fun LaneRoad(lanes: List<Lane>, modifier: Modifier, fill: Color, onFill: Color, ground: Color, line: Color = Color.White) {
val named = lanes.map {
stringResource(if (it.recommended()) R.string.nav_lane_recommended else R.string.nav_lane)
}.joinToString(", ")
Canvas(modifier.semantics { contentDescription = named }) {
val n = lanes.size
if (n == 0) return@Canvas
val h = size.height
// Each lane at most a little wider than the road is tall, so two lanes don't fill a whole card.
val bottomWidth = minOf(size.width, h * 1.6f * n)
val topWidth = bottomWidth * 0.86f
fun width(y: Float) = topWidth + (bottomWidth - topWidth) * (y / h)
// The x of lane edge [edge] (0 to n, fractions for the middle of a lane) at height [y].
fun x(edge: Float, y: Float): Float = size.width / 2 - width(y) / 2 + width(y) * edge / n
lanes.forEachIndexed { i, lane ->
if (!lane.recommended()) return@forEachIndexed
drawPath(
Path().apply {
moveTo(x(i.toFloat(), 0f), 0f)
lineTo(x(i + 1f, 0f), 0f)
lineTo(x(i + 1f, h), h)
lineTo(x(i.toFloat(), h), h)
close()
},
fill,
)
}
val edge = 2.dp.toPx()
drawLine(line, Offset(x(0f, 0f), 0f), Offset(x(0f, h), h), edge)
drawLine(line, Offset(x(n.toFloat(), 0f), 0f), Offset(x(n.toFloat(), h), h), edge)
val dashes = PathEffect.dashPathEffect(floatArrayOf(h * 0.22f, h * 0.14f))
for (i in 1 until n) {
drawLine(lerp(ground, line, 0.7f), Offset(x(i.toFloat(), 0f), 0f), Offset(x(i.toFloat(), h), h), edge, pathEffect = dashes)
}
val top = h * 0.12f
val bottom = h * 0.9f
// Half the room across a lane the arrows may use, as at the bottom: well inside the lines
// even where the lane narrows at the top.
val room = bottomWidth / n * 0.31f
lanes.forEachIndexed { i, lane ->
val directions = lane.directions.orEmpty().ifEmpty { listOf(null) }
// Faded first, so the one to take draws on top where they share a stem.
val arrows = directions.sortedBy { it?.isRecommended == true }
.map { it to laneArrow(it?.shape ?: LaneDirection.SHAPE_STRAIGHT, top, bottom) }
// The lane's arrows as one shape: centred in the lane, and shrunk toward the stem's
// foot when they'd reach past [room].
val xs = arrows.flatMap { (_, a) -> a.line.map { it.x } + a.head.map { it.x } }
val shift = -(xs.min() + xs.max()) / 2
val scale = minOf(1f, room / ((xs.max() - xs.min()) / 2))
val lift = (bottom - top) * (1 - scale) / 2
// Flat to the road: centred, scaled, then narrowed with the lane toward the top.
val onRoad = { p: Offset ->
val y = bottom - (bottom - p.y) * scale - lift
Offset(x(i + 0.5f, y) + (p.x + shift) * scale * width(y) / bottomWidth, y)
}
val stroke = (bottom - top) * 0.085f * scale * width((top + bottom) / 2) / bottomWidth
val behind = if (lane.recommended()) fill else ground
val on = if (lane.recommended()) onFill else line
arrows.forEach { (d, arrow) ->
// Solid colours, faded by mixing rather than transparency, so where a stem runs
// into its head nothing shows through twice.
val color = if (d?.isRecommended == true) on else lerp(behind, on, 0.38f)
val points = arrow.line.map(onRoad)
drawPath(
Path().apply {
moveTo(points[0].x, points[0].y)
points.drop(1).forEach { lineTo(it.x, it.y) }
},
color,
style = Stroke(stroke, cap = StrokeCap.Round, join = StrokeJoin.Round),
)
val corners = arrow.head.map(onRoad)
drawPath(
Path().apply {
moveTo(corners[0].x, corners[0].y)
lineTo(corners[1].x, corners[1].y)
lineTo(corners[2].x, corners[2].y)
close()
},
color,
)
}
}
}
}
/** An arrow laid out flat: the stem as points, and the head's tip and two back corners. */
private class LaneArrow(val line: List<Offset>, val head: List<Offset>)
/**
* One direction's arrow, flat, its stem rising from x 0 at [bottom]: on straight to [top], bending
* off to a side from a little above the middle, or looping back for a U-turn. A lane's arrows
* share the stem, so a straight-and-right lane reads as one fork.
*/
private fun laneArrow(shape: Int, top: Float, bottom: Float): LaneArrow {
val tall = bottom - top
val head = tall * 0.17f
val start = Offset(0f, bottom)
val degrees = when (shape) {
LaneDirection.SHAPE_SLIGHT_LEFT -> -45f
LaneDirection.SHAPE_SLIGHT_RIGHT -> 45f
LaneDirection.SHAPE_NORMAL_LEFT -> -90f
LaneDirection.SHAPE_NORMAL_RIGHT -> 90f
LaneDirection.SHAPE_SHARP_LEFT -> -135f
LaneDirection.SHAPE_SHARP_RIGHT -> 135f
LaneDirection.SHAPE_U_TURN_LEFT -> -180f
LaneDirection.SHAPE_U_TURN_RIGHT -> 180f
else -> 0f
}
val side = if (degrees < 0) -1f else 1f
val line = mutableListOf(start)
val end: Offset
val dir: Offset
when {
degrees == 0f -> {
end = Offset(0f, top + head)
line += end
dir = Offset(0f, -1f)
}
abs(degrees) == 180f -> {
// Up, over the top and back down beside the stem.
val r = tall * 0.2f
line += Offset(0f, top + r)
line += curve(Offset(0f, top + r), Offset(0f, top), Offset(side * r, top))
line += curve(Offset(side * r, top), Offset(side * 2 * r, top), Offset(side * 2 * r, top + r))
end = Offset(side * 2 * r, top + tall * 0.5f)
line += end
dir = Offset(0f, 1f)
}
else -> {
val rad = degrees / 180f * PI.toFloat()
val d = Offset(sin(rad), -cos(rad))
val length = tall * 0.32f
val fork = Offset(0f, top + tall * 0.6f)
val bend = Offset(0f, fork.y - length * 0.45f)
end = bend + d * length
line += fork
line += curve(fork, bend, end)
dir = d
}
}
val tip = end + dir * head
val base = tip - dir * (head * 1.5f)
val across = Offset(-dir.y, dir.x) * (head * 0.9f)
return LaneArrow(line, listOf(tip, base + across, base - across))
}
/** Points along a quadratic curve from [a] to [c] bending toward [b], [a] itself left out. */
private fun curve(a: Offset, b: Offset, c: Offset): List<Offset> = (1..10).map { k ->
val t = k / 10f
a * ((1 - t) * (1 - t)) + b * (2 * (1 - t) * t) + c * (t * t)
}
/**
* Which lanes to be in, in words, when the recommended ones sit together: the left or right
* lanes, the middle one, a lane counted from the nearer side, or any. Null when nothing is
* recommended or the lanes to use aren't next to each other.
*/
@Composable
internal fun laneHint(lanes: List<Lane>): String? {
val n = lanes.size
val use = lanes.indices.filter { lanes[it].recommended() }
if (n < 2 || use.isEmpty()) return null
if (use.size == n) return stringResource(R.string.nav_lane_any)
if (use.last() - use.first() + 1 != use.size) return null
val resources = LocalContext.current.resources
val k = use.size
val only = use.first()
return when {
only == 0 -> resources.getQuantityString(R.plurals.nav_lane_left, k, k)
use.last() == n - 1 -> resources.getQuantityString(R.plurals.nav_lane_right, k, k)
k == 1 && n % 2 == 1 && only == n / 2 -> stringResource(R.string.nav_lane_middle)
k == 1 && only < n - 1 - only -> stringResource(R.string.nav_lane_from_left, only + 1)
k == 1 -> stringResource(R.string.nav_lane_from_right, n - only)
else -> stringResource(R.string.nav_lane_middle_lanes)
}
}
private fun Lane.recommended(): Boolean = directions.orEmpty().any { it.isRecommended }
@@ -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})") }
}
}
@@ -11,27 +11,35 @@ import android.os.SystemClock
* *
* They fade out after [IDLE_MS] with no touch anywhere on the car screen, to leave the map clear, * They fade out after [IDLE_MS] with no touch anywhere on the car screen, to leave the map clear,
* and any touch brings them back. [touch] is called for every pointer event and is cheap: it only * and any touch brings them back. [touch] is called for every pointer event and is cheap: it only
* writes state when the buttons were actually hidden. * writes state when the buttons were actually hidden. The nav bar, when set to hide, goes the
* same way but waits longer ([NAV_BAR_IDLE_MS]).
*/ */
class MapControlsWake { class MapControlsWake {
var awake by mutableStateOf(true) var awake by mutableStateOf(true)
private set private set
var navBarAwake by mutableStateOf(true)
private set
private var lastTouch = SystemClock.uptimeMillis() private var lastTouch = SystemClock.uptimeMillis()
fun touch() { fun touch() {
lastTouch = SystemClock.uptimeMillis() lastTouch = SystemClock.uptimeMillis()
if (!awake) awake = true if (!awake) awake = true
if (!navBarAwake) navBarAwake = true
} }
/** Called on a timer; hides the buttons once nothing has touched the screen for [IDLE_MS]. */ /** Called on a timer; hides the buttons once nothing has touched the screen for [IDLE_MS]. */
fun tick() { fun tick() {
if (awake && SystemClock.uptimeMillis() - lastTouch >= IDLE_MS) awake = false val idle = SystemClock.uptimeMillis() - lastTouch
if (awake && idle >= IDLE_MS) awake = false
if (navBarAwake && idle >= NAV_BAR_IDLE_MS) navBarAwake = false
} }
companion object { companion object {
const val IDLE_MS = 4_000L const val IDLE_MS = 4_000L
const val NAV_BAR_IDLE_MS = 8_000L
} }
} }
@@ -0,0 +1,111 @@
package app.seb3thehacker.gearslip.car
import android.os.Handler
import android.os.Looper
import android.view.KeyEvent
import app.seb3thehacker.gearslip.host.CarAppConnection
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlin.math.min
import kotlin.math.pow
/**
* The knob on a full-screen map, as in Android Auto: turning zooms, nudging pans, and pressing
* brings up the map's buttons for the knob to move between. Back puts them away and gives the
* knob back to the map. Issue #74: on a Mazda 3, every turn stepped through each map button.
*
* [width] and [height] are the app's surface in pixels; zooms centre on its middle.
* [clearFocus] drops focus off whatever button the knob was on.
*/
internal class MapKnob(
private val connection: CarAppConnection,
private val width: Int,
private val height: Int,
private val clearFocus: () -> Unit,
) : CarKeys.KnobTaker {
private val main = Handler(Looper.getMainLooper())
/** The press that opened the buttons lifts afterwards; that lift belongs to no button. */
@Volatile private var swallowLift = false
override fun take(event: CarKeys.KnobEvent): Boolean {
// A card over the map with buttons of its own (an app's alert, a call, a message, the
// assistant, Bluetooth pairing) needs the knob more than the map does.
if (overlayUp()) return false
if (_buttonsOpen.value) {
if (event is CarKeys.KnobEvent.Key && !event.down && swallowLift && event.keyCode in PRESS_KEYS) {
swallowLift = false
return true
}
if (event == CarKeys.KnobEvent.Back) {
main.post { close() }
return true
}
return false
}
when (event) {
is CarKeys.KnobEvent.Turn -> main.post { zoom(event.steps) }
is CarKeys.KnobEvent.Key -> when (event.keyCode) {
in PRESS_KEYS -> if (event.down) main.post { open() }
else -> if (event.down) main.post { pan(event.keyCode) }
}
CarKeys.KnobEvent.Back -> return false
}
return true
}
private fun overlayUp(): Boolean =
CarAlerts.shown.value != null ||
app.seb3thehacker.gearslip.call.CarCalls.state.value is app.seb3thehacker.gearslip.call.CallUiState.Ringing ||
app.seb3thehacker.gearslip.notify.CarNotifications.popup.value != null ||
CarAssistant.state.value.phase != CarAssistant.Phase.IDLE ||
CarBluetooth.prompt.value != CarBluetooth.Prompt.NONE
private fun zoom(steps: Int) {
val clicks = steps.coerceIn(-MAX_STEPS, MAX_STEPS)
connection.surfaceScale(width / 2f, height / 2f, ZOOM_STEP.pow(clicks))
}
/** One nudge moves the map a sixth of the screen. Scroll distances run previous minus current. */
private fun pan(keyCode: Int) {
val step = min(width, height) / 6f
when (keyCode) {
KeyEvent.KEYCODE_DPAD_UP -> connection.surfaceScroll(0f, -step)
KeyEvent.KEYCODE_DPAD_DOWN -> connection.surfaceScroll(0f, step)
KeyEvent.KEYCODE_DPAD_LEFT -> connection.surfaceScroll(-step, 0f)
KeyEvent.KEYCODE_DPAD_RIGHT -> connection.surfaceScroll(step, 0f)
}
}
private fun open() {
swallowLift = true
_buttonsOpen.value = true
// The map's buttons take focus when they see this. A map showing none gets a Tab, so the
// knob still lands somewhere.
if (controls == 0) CarKeys.focusSink?.let { it(KeyEvent.KEYCODE_TAB, true); it(KeyEvent.KEYCODE_TAB, false) }
}
private fun close() {
_buttonsOpen.value = false
clearFocus()
}
/** Puts the buttons away when the map leaves the screen. */
fun release() {
_buttonsOpen.value = false
}
companion object {
private val _buttonsOpen = MutableStateFlow(false)
/** The knob pressed on the map: its buttons show and take focus, until Back. */
val buttonsOpen: StateFlow<Boolean> = _buttonsOpen
/** How many stacks of map buttons are on screen, for [open] to know whether any will take focus. */
@Volatile var controls = 0
private const val ZOOM_STEP = 1.25f
private const val MAX_STEPS = 5
private val PRESS_KEYS = setOf(KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER)
}
}
@@ -0,0 +1,348 @@
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.RoundedCornerShape
import app.seb3thehacker.gearslip.car.theme.*
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.Arrangement
import android.provider.Settings
import android.content.Intent
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.aspectRatio
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.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.PathFillType
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.foundation.layout.widthIn
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.addPathNodes
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.seb3thehacker.gearslip.media.CarMedia
import app.seb3thehacker.gearslip.media.MediaApp
import app.seb3thehacker.gearslip.media.MediaArt
import app.seb3thehacker.gearslip.media.LyricsResult
import app.seb3thehacker.gearslip.media.Lyrics
import app.seb3thehacker.gearslip.media.MediaEntry
import app.seb3thehacker.gearslip.media.NowPlaying
import app.seb3thehacker.gearslip.media.QueueTrack
import android.media.session.PlaybackState
import kotlinx.coroutines.delay
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.PaddingValues
// The media screen's browse list, up-next queue, rows and icons. Split out of MediaScreen.kt.
@Composable
internal fun BrowsePanel(browse: app.seb3thehacker.gearslip.media.BrowseState, media: CarMedia, appLabel: String, modifier: Modifier) {
Column(modifier) {
// At the library root there's no trail and nowhere to go up to, so there's nothing this
// 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.
if (media.canGoUp || browse.trail.isNotEmpty()) {
Row(Modifier.padding(bottom = 12.dp), verticalAlignment = Alignment.CenterVertically) {
if (media.canGoUp) {
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 {
Text(
it,
style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold,
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
}
}
}
when {
browse.loading -> Notice(stringResource(R.string.media_loading), null)
browse.unavailable -> Notice(
stringResource(R.string.media_keeps_library, appLabel),
stringResource(R.string.media_browse_start),
)
browse.failed -> Notice(stringResource(R.string.media_load_failed), null)
browse.entries.isEmpty() -> Notice(stringResource(R.string.media_nothing_here), null)
else -> BrowseList(browse.entries, 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 -----------------------------------------------------------------------------------
/**
* Whatever the app has queued up, starting right after the track that's playing now - not the
* whole queue from the top, which would just repeat what the player beside it already shows. Apps
* that never publish a queue (many don't) show an empty notice rather than a permanently blank tab.
*/
@Composable
internal fun QueuePanel(queue: List<QueueTrack>, now: NowPlaying, media: CarMedia, modifier: Modifier) {
val upNext = remember(queue, now.activeQueueItemId) {
val current = queue.indexOfFirst { it.queueId == now.activeQueueItemId }
if (current >= 0) queue.drop(current + 1) else queue
}
Column(modifier) {
if (upNext.isEmpty()) {
Notice(
stringResource(R.string.media_nothing_queued),
stringResource(if (now.title.isEmpty()) R.string.media_queue_empty_idle else R.string.media_queue_empty_playing),
)
} else {
LazyColumn(
Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(upNext, key = { it.queueId }) { track -> QueueRow(track, media) }
}
}
}
}
@Composable
private fun QueueRow(track: QueueTrack, media: CarMedia) {
val context = LocalContext.current
val icon by produceState(track.iconBitmap, track.iconUri) {
value = track.iconBitmap ?: MediaArt.load(context, track.iconUri)
}
Surface(
onClick = { media.playQueueItem(track) },
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surfaceContainer,
modifier = Modifier.fillMaxWidth(),
) {
Row(Modifier.padding(8.dp), verticalAlignment = Alignment.CenterVertically) {
RowArt(icon, MediaIcons.MusicNote)
Column(Modifier.weight(1f).padding(horizontal = 14.dp)) {
Text(track.title, style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
if (track.subtitle.isNotEmpty()) {
Text(track.subtitle, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
Icon(Icons.Filled.PlayArrow, stringResource(R.string.media_play), tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
@Composable
internal fun EntryRow(entry: MediaEntry, media: CarMedia, onClick: () -> Unit = { media.select(entry) }) {
val context = LocalContext.current
val icon by produceState(entry.iconBitmap, entry.iconUri) {
value = entry.iconBitmap ?: MediaArt.load(context, entry.iconUri)
}
Surface(
onClick = onClick,
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surfaceContainer,
modifier = Modifier.fillMaxWidth(),
) {
Row(Modifier.padding(8.dp), verticalAlignment = Alignment.CenterVertically) {
RowArt(icon, if (entry.browsable) MediaIcons.Folder else MediaIcons.MusicNote)
Column(Modifier.weight(1f).padding(horizontal = 14.dp)) {
Text(entry.title, style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
if (entry.subtitle.isNotEmpty()) {
Text(entry.subtitle, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
if (entry.browsable && entry.playable) {
GsIconButton(Icons.Filled.PlayArrow, stringResource(R.string.media_play), { media.playAll(entry) })
}
if (entry.browsable) Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, null)
}
}
}
/** A row's artwork, or a folder or note in its place when the app sends none. */
@Composable
private fun RowArt(icon: android.graphics.Bitmap?, fallback: ImageVector) {
Box(
Modifier.size(56.dp).clip(RoundedCornerShape(12.dp)).background(MaterialTheme.colorScheme.surfaceContainerHigh),
contentAlignment = Alignment.Center,
) {
if (icon != null) Image(icon.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
else Icon(fallback, null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(30.dp))
}
}
/** Material's transport icons, from their published path data (they live in the extended set). */
internal object MediaIcons {
val MusicNote = icon("MusicNote", "M12,3v10.55c-0.59,-0.34 -1.27,-0.55 -2,-0.55 -2.21,0 -4,1.79 -4,4s1.79,4 4,4 4,-1.79 4,-4V7h4V3h-6z")
val Folder = icon("Folder", "M10,4H4c-1.1,0 -1.99,0.9 -1.99,2L2,18c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2V8c0,-1.1 -0.9,-2 -2,-2h-8l-2,-2z")
val Pause = icon("Pause", "M6,19h4L10,5L6,5v14zM14,5v14h4L18,5h-4z")
val Next = icon("SkipNext", "M6,18l8.5,-6L6,6v12zM16,6v12h2V6h-2z")
/** The 3x3 app grid; not in material-icons-core. */
val Apps = icon("Apps", "M4,8h4L8,4L4,4v4zM10,20h4v-4h-4v4zM4,20h4v-4L4,16v4zM4,14h4v-4L4,10v4zM10,14h4v-4h-4v4zM16,4v4h4L20,4h-4zM10,8h4L14,4h-4v4zM16,14h4v-4h-4v4zM16,20h4v-4h-4v4z")
val Previous = icon("SkipPrevious", "M6,6h2v12L6,18zM9.5,12l8.5,6V6z")
val Equalizer = icon("Equalizer", "M10,20h4L14,4h-4v16zM4,20h4v-8L4,12v8zM16,9v11h4L20,9h-4z")
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 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 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 FullscreenExit = icon("FullscreenExit", "M5,16h3v3h2v-5L5,14v2zM8,8L5,8v2h5L10,5L8,5v3zM14,19h2v-3h3v-2h-5v5zM16,8L16,5h-2v5h5L19,8h-3z")
val CallEnd = icon("CallEnd", "M12,9c-1.6,0 -3.15,0.25 -4.6,0.72v3.1c0,0.39 -0.23,0.74 -0.56,0.9 -0.98,0.49 -1.87,1.12 -2.66,1.85 -0.18,0.18 -0.43,0.28 -0.7,0.28 -0.28,0 -0.53,-0.11 -0.71,-0.29L0.29,13.08c-0.18,-0.17 -0.29,-0.42 -0.29,-0.7 0,-0.28 0.11,-0.53 0.29,-0.71C3.34,8.78 7.46,7 12,7s8.66,1.78 11.71,4.67c0.18,0.18 0.29,0.43 0.29,0.71 0,0.28 -0.11,0.53 -0.29,0.71l-2.48,2.48c-0.18,0.18 -0.43,0.29 -0.71,0.29 -0.27,0 -0.52,-0.11 -0.7,-0.28 -0.79,-0.74 -1.69,-1.36 -2.67,-1.85 -0.33,-0.16 -0.56,-0.5 -0.56,-0.9v-3.1C15.15,9.25 13.6,9 12,9z")
val Mic = icon("Mic", "M12,14c1.66,0 2.99,-1.34 2.99,-3L15,5c0,-1.66 -1.34,-3 -3,-3S9,3.34 9,5v6c0,1.66 1.34,3 3,3zM17.3,11c0,3 -2.54,5.1 -5.3,5.1S6.7,14 6.7,11L5,11c0,3.41 2.72,6.23 6,6.72L11,21h2v-3.28c3.28,-0.48 6,-3.3 6,-6.72h-1.7z")
val MicOff = icon("MicOff", "M19,11h-1.7c0,0.74 -0.16,1.43 -0.43,2.05l1.23,1.23c0.56,-0.98 0.9,-2.09 0.9,-3.28zM14.98,11.17c0,-0.06 0.02,-0.11 0.02,-0.17L15,5c0,-1.66 -1.34,-3 -3,-3S9,3.34 9,5v0.18l5.98,5.99zM4.27,3L3,4.27l6.01,6.01L9,11c0,1.66 1.33,3 2.99,3 0.22,0 0.44,-0.03 0.65,-0.08l1.66,1.66c-0.71,0.33 -1.5,0.52 -2.31,0.52 -2.76,0 -5.3,-2.1 -5.3,-5.1L5,11c0,3.41 2.72,6.23 6,6.72L11,21h2v-3.28c0.91,-0.13 1.77,-0.45 2.54,-0.9L19.73,21 21,19.73 4.27,3z")
private fun icon(name: String, path: String, fillType: PathFillType = PathFillType.NonZero) = ImageVector.Builder(
name, 24.dp, 24.dp, 24f, 24f,
).addPath(addPathNodes(path), pathFillType = fillType, fill = SolidColor(androidx.compose.ui.graphics.Color.Black)).build()
}
@@ -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
@@ -93,21 +102,22 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) {
// The connection outlives this screen: the home screen's player is the same one. // The connection outlives this screen: the home screen's player is the same one.
LaunchedEffect(app) { CarServices.openMedia(app) } LaunchedEffect(app) { CarServices.openMedia(app) }
androidx.compose.runtime.DisposableEffect(Unit) { onDispose { CarServices.mediaScreenClosed() } }
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.allow_on_phone) to {
action = "Open on phone" to {
val app = context.applicationContext val app = context.applicationContext
runCatching { runCatching {
app.startActivity( app.startActivity(
@@ -117,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,
@@ -152,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())
} }
} }
} }
@@ -211,15 +259,21 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) {
@Composable @Composable
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 }
val navigator = LocalCarNavigator.current
Row(modifier, verticalAlignment = Alignment.CenterVertically) { Row(modifier, verticalAlignment = Alignment.CenterVertically) {
GsIconButton(Icons.Filled.ArrowBack, "Back", onExit, size = 44.dp) // Shown on the same terms as every other screen's header: only when it leads somewhere
Spacer(Modifier.width(14.dp)) // the nav bar doesn't.
if (navigator.backLeadsSomewhere) {
GsIconButton(Icons.Filled.ArrowBack, stringResource(R.string.back), onExit)
Spacer(Modifier.width(12.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)))
Spacer(Modifier.width(10.dp)) Spacer(Modifier.width(10.dp))
} }
Text( Text(
app.label.lowercase().replaceFirstChar { it.titlecase() }, // As the app writes it: recasing turned NewPipe into "Newpipe" and TIDAL into "Tidal".
app.label,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1, maxLines = 1,
@@ -272,10 +326,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 = ""
@@ -286,12 +340,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) {
@@ -349,8 +403,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,
@@ -375,14 +429,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(
@@ -393,43 +450,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
},
) )
} }
} }
@@ -437,6 +540,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
@@ -444,7 +613,7 @@ private sealed interface LyricsState {
} }
@Composable @Composable
private fun Notice(title: String, detail: String?, action: Pair<String, () -> Unit>? = null) { internal fun Notice(title: String, detail: String?, action: Pair<String, () -> Unit>? = null) {
Column( Column(
Modifier.fillMaxSize().padding(horizontal = 24.dp), Modifier.fillMaxSize().padding(horizontal = 24.dp),
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
@@ -467,31 +636,103 @@ private fun Notice(title: String, detail: String?, action: Pair<String, () -> Un
// --- 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)),
) { ) {
art?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) } if (art != null) {
Image(art.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
} else {
// No cover (or nothing playing): a note, so the square reads as a cover, not a hole.
Box(contentAlignment = Alignment.Center) {
Icon(MediaIcons.MusicNote, null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.fillMaxSize(0.35f))
}
}
}
}
}
/**
* 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)
}
}
} }
} }
} }
@@ -502,194 +743,80 @@ private fun CoverArt(art: android.graphics.Bitmap?, modifier: Modifier) {
*/ */
@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(
Text( now.title.ifEmpty { stringResource(R.string.media_nothing_playing) },
listOf(now.artist, now.album).filter { it.isNotEmpty() }.joinToString(" - "), style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis,
maxLines = 1, overflow = TextOverflow.Ellipsis, )
) Text(
now.error?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error, maxLines = 2) } 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())
} }
} }
// --- browsing -------------------------------------------------------------------------------- // --- browsing --------------------------------------------------------------------------------
@Composable
private fun BrowsePanel(browse: app.seb3thehacker.gearslip.media.BrowseState, media: CarMedia, appLabel: String, modifier: Modifier) {
Column(modifier) {
// At the library root there's no trail and nowhere to go up to, so there's nothing this
// 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.
if (media.canGoUp || browse.trail.isNotEmpty()) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (media.canGoUp) {
GsIconButton(Icons.Filled.ArrowBack, "Up", media::up)
}
browse.trail.lastOrNull()?.let {
Text(
it,
style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold,
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
}
}
}
when {
browse.loading -> Notice("Loading…", null)
browse.unavailable -> Notice(
"$appLabel keeps its library to itself.",
"Start music in the app on your phone. Play, pause, skip and seek work from here.",
)
browse.failed -> Notice("Could not load this list.", null)
browse.entries.isEmpty() -> Notice("Nothing here.", null)
else -> LazyColumn(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(browse.entries, key = { it.id }) { entry -> EntryRow(entry, media) }
}
}
}
}
// --- up next -----------------------------------------------------------------------------------
/**
* Whatever the app has queued up, starting right after the track that's playing now - not the
* whole queue from the top, which would just repeat what the player beside it already shows. Apps
* that never publish a queue (many don't) show an empty notice rather than a permanently blank tab.
*/
@Composable
private fun QueuePanel(queue: List<QueueTrack>, now: NowPlaying, media: CarMedia, modifier: Modifier) {
val upNext = remember(queue, now.activeQueueItemId) {
val current = queue.indexOfFirst { it.queueId == now.activeQueueItemId }
if (current >= 0) queue.drop(current + 1) else queue
}
Column(modifier) {
if (upNext.isEmpty()) {
Notice(
"Nothing queued.",
"${if (now.title.isEmpty()) "Nothing playing" else "Only what's already playing"} - " +
"not every app tells Gearslip what comes next.",
)
} else {
LazyColumn(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(upNext, key = { it.queueId }) { track -> QueueRow(track, media) }
}
}
}
}
@Composable
private fun QueueRow(track: QueueTrack, media: CarMedia) {
val context = LocalContext.current
val icon by produceState(track.iconBitmap, track.iconUri) {
value = track.iconBitmap ?: MediaArt.load(context, track.iconUri)
}
Surface(
onClick = { media.playQueueItem(track) },
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.fillMaxWidth(),
) {
Row(Modifier.padding(10.dp), verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.size(62.dp).clip(MaterialTheme.shapes.medium).background(MaterialTheme.colorScheme.surface)) {
icon?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) }
}
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
Text(track.title, style = MaterialTheme.typography.titleSmall, maxLines = 1, overflow = TextOverflow.Ellipsis)
if (track.subtitle.isNotEmpty()) {
Text(track.subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
Icon(Icons.Filled.PlayArrow, "Play", tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
@Composable
private fun EntryRow(entry: MediaEntry, media: CarMedia, onClick: () -> Unit = { media.select(entry) }) {
val context = LocalContext.current
val icon by produceState(entry.iconBitmap, entry.iconUri) {
value = entry.iconBitmap ?: MediaArt.load(context, entry.iconUri)
}
Surface(
onClick = onClick,
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.fillMaxWidth(),
) {
Row(Modifier.padding(10.dp), verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.size(62.dp).clip(MaterialTheme.shapes.medium).background(MaterialTheme.colorScheme.surface)) {
icon?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) }
}
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
Text(entry.title, style = MaterialTheme.typography.titleSmall, maxLines = 1, overflow = TextOverflow.Ellipsis)
if (entry.subtitle.isNotEmpty()) {
Text(entry.subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
if (entry.browsable && entry.playable) {
GsIconButton(Icons.Filled.PlayArrow, "Play", { media.playAll(entry) })
}
if (entry.browsable) Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, null)
}
}
}
/** Material's transport icons, from their published path data (they live in the extended set). */
internal object MediaIcons {
val Pause = icon("Pause", "M6,19h4L10,5L6,5v14zM14,5v14h4L18,5h-4z")
val Next = icon("SkipNext", "M6,18l8.5,-6L6,6v12zM16,6v12h2V6h-2z")
/** The 3x3 app grid; not in material-icons-core. */
val Apps = icon("Apps", "M4,8h4L8,4L4,4v4zM10,20h4v-4h-4v4zM4,20h4v-4L4,16v4zM4,14h4v-4L4,10v4zM10,14h4v-4h-4v4zM16,4v4h4L20,4h-4zM10,8h4L14,4h-4v4zM16,14h4v-4h-4v4zM16,20h4v-4h-4v4z")
val Previous = icon("SkipPrevious", "M6,6h2v12L6,18zM9.5,12l8.5,6V6z")
val Equalizer = icon("Equalizer", "M10,20h4L14,4h-4v16zM4,20h4v-8L4,12v8zM16,9v11h4L20,9h-4z")
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 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)
private fun icon(name: String, path: String, fillType: PathFillType = PathFillType.NonZero) = ImageVector.Builder(
name, 24.dp, 24.dp, 24f, 24f,
).addPath(addPathNodes(path), pathFillType = fillType, fill = SolidColor(androidx.compose.ui.graphics.Color.Black)).build()
}
Loaded 100 of 217 files, more files were not shown because too many files have changed in this diff. Show more