Author SHA1 Message Date
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
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
152 changed files with 23286 additions and 3768 deletions

No files matched your search

+20 -11
View File
@@ -1,15 +1,24 @@
Gearslip 0.3.01 connects wirelessly on more phones and dongles.
Gearslip 0.3.31 brings saved places, a new music player and sixteen languages.
## Fixed
- Wireless works with dongles that send their Wi-Fi details before their address.
- A VPN no longer stops a wireless drive without a word. Gearslip tries the phone's ordinary route around it, and if the VPN still blocks the car, Gearslip says so. To fix it for good, exclude Gearslip in your VPN app.
- When Android won't ask for the Nearby devices permission, a button opens Gearslip's settings, where you can turn it on.
## New
- Save Home, Work and three places of your own under Saved places in the Car card on the phone's home screen. Say "navigate home" or "take me to work", and your map app gets the address.
- Calls show on the car's own screens, and the car's phone button answers them.
- A new music player: Now playing with a big cover, Browse with covers, and Up next. Beside the map, the player floats as a card; drag its edge to resize it.
- Lyrics come from BetterLyrics, LRCLIB or KuGou, whichever has the song.
- A Dark mode setting: black, black and colour, deep colour or grey. Black is the default.
- Gearslip speaks Arabic, Chinese, Czech, Dutch, French, German, Italian, Japanese, Korean, Polish, Portuguese, Russian, Spanish, Swedish, Turkish and Ukrainian. Most started as machine translations, so fix your language on [Weblate](https://hosted.weblate.org/engage/gearslip/).
- With experimental features on, car apps get the car's speed, fuel, range and compass, and cars with an instrument cluster screen get a turn card there.
- A Manage storage screen in App info clears lyrics, voice models and old logs.
## Changed
- An app that someone else has re-signed, such as a modified Waze, no longer shows the red X when it accepts Gearslip.
- Support Gearslip through Liberapay, Buy Me a Coffee, or Monero, in Settings.
- Gearslip now belongs to Twink Software LLC, and its code lives at github.com/twinksoftware/gearslip. Old links still work.
- The README lists Gearslip's signing certificate, so you can check your copy with AppVerifier.
- The nav bar floats on every screen.
- The car's settings all sit under the Car card on the phone's home screen.
- The Light app theme makes the phone app light too.
- With the player beside the map, the map app's own buttons fold behind one button, so everything fits.
- The USB audio choice is now called Through Gearslip.
- Setup and Settings explain that some newer cars reject the built-in certificate and need your own.
## Known issues
- Phone calls are unstable right now and may drop or fail to connect.
## Fixed
- Older head units that take only AAC audio no longer drop the connection when music starts.
- Long screens such as Settings, Messages and Vehicle data scroll all the way down.
- Settings opens faster.
+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,
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
on the [Seb3thehacker GitHub profile](https://github.com/Seb3thehacker).
+13
View File
@@ -38,6 +38,19 @@ 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
permissions and on-device consent dialogs.
## Translating
Translations happen on [Weblate](https://hosted.weblate.org/engage/gearslip/), not 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.
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
Text that users read, such as screens, release notes, and docs, follows Strunk's
+44 -25
View File
@@ -9,13 +9,20 @@ 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)
[![Translation status](https://hosted.weblate.org/widget/gearslip/app/svg-badge.svg)](https://hosted.weblate.org/engage/gearslip/)
Gearslip speaks English, Arabic, Chinese, Czech, Dutch, French, German, Italian, Japanese,
Korean, Polish, Portuguese, Russian, Spanish, Swedish, Turkish, and Ukrainian. Most of these
started as machine translations, so some read awkwardly.
[Fix or add your language on Weblate](https://hosted.weblate.org/engage/gearslip/).
Questions, ideas?
[Join the discussion on Matrix](https://matrix.to/#/!mf0_mgBd5KqKpc9FfX_wHp1jFCcwAtPrndIn_oGIyMk?via=matrix.org).
## Which cars work
Most cars work. Starting around 2020, some cars added newer security standards that reject
Gearslip's certificate, and Gearslip can't run in those cars. [CARS.md](CARS.md) lists the
Most cars work. Starting around 2020, some cars added newer security checks that reject
the aasdk certificate, and in those cars Gearslip needs a certificate of your own. [CARS.md](CARS.md) lists the
cars drivers have confirmed.
## What Gearslip is
@@ -31,25 +38,27 @@ screen-sharing mode.
|---|---|
| ![The launcher, with every car app on the phone and the route in the bar below](docs/screenshots/launcher.png) | ![Vela Maps showing the route, with the music player beside the map](docs/screenshots/navigation.png) |
| Your car apps in one place. A red X marks the ones that don't work yet. | Vela Maps navigating, with the player beside it. |
| ![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) |
| ViVi Music in Gearslip's player. | A new message, read aloud at a tap, over the map. |
| ![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) |
| The weather, your next event, and what you missed. | Browse the web while parked. |
| ![The voice assistant listening over Organic Maps](docs/screenshots/assistant.png) | ![Coffee shops near you in Vela Maps, with the music player beside the map](docs/screenshots/places.png) |
| Ask for a place, a song, or an app. | Find coffee nearby without leaving the map. |
| ![Metrolist's song, art, and controls in Gearslip's player](docs/screenshots/music.png) | ![A new message card over Vela Maps](docs/screenshots/message.png) |
| Metrolist in Gearslip's player. | A new message, read aloud at a tap, over the map. |
| ![The dashboard, with the weather, the week ahead, and recent notifications](docs/screenshots/dashboard.png) | ![DuckDuckGo open in Gearslip's web browser](docs/screenshots/web.png) |
| The weather, your week, and what you missed. | Browse the web while parked. |
| ![The voice assistant listening over Vela Maps](docs/screenshots/assistant.png) | ![Three routes to a coffee shop in Vela Maps, each with its time and distance](docs/screenshots/places.png) |
| Ask for a place, a song, or an app. | Pick a route to coffee nearby. Each one shows its time and distance. |
## Features
- **Phone calls.** Dial from a keypad, or search your contacts, then confirm before the
call goes out. When a call comes in, answer or decline it on the car screen.
call goes out. When a call comes in, answer or decline it on the car screen. During a
call, the nav bar shows who you're talking to, and you mute or hang up from there.
- **Navigation.** Gearslip hosts real Car App Library map apps on the car screen.
Organic Maps, OsmAnd, and Flitsmeister work today. Vela runs, with rough
edges.
Organic Maps, Vela, OsmAnd, Flitsmeister, MAPS.ME, and MapQuest work today. While you
navigate, a big turn card on the left shows the arrow, the distance, and your lane, and
the map keeps the right side.
- **Music.** Browse, play, and queue tracks from a car-enabled media app, with a Now
Playing view, an Up Next queue, and lyrics. Type a search, and apps that accept one
play the best match.
- **The web.** A browser template with its own on-screen keyboard, for anything that
doesn't ship a car interface of its own.
- **The web.** A browser with an address bar, back and forward, and its own on-screen
keyboard, for anything that doesn't ship a car interface of its own.
- **Weather and the day ahead.** A dashboard screen shows the week's calendar next to
the current and coming weather.
- **Voice.** Tap the mic, or press the talk button on the wheel, and say "navigate to the
@@ -63,33 +72,40 @@ screen-sharing mode.
- **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.
- **Your phone's colors.** The car screen and the phone app take their colors from your
phone's Material You palette, and every card and button matches.
- **Themes.** Choose flat buttons or skeuomorphic ones that look like real keys.
- **Settings on the phone.** The car's settings sit on the phone's home screen too, so you
can set things up before you drive. A change on either screen shows on both.
## Status
- **Wired or wireless.** Plug the phone into the car's USB port, or, in a car with wireless
Android Auto, tap Connect wirelessly and pick the car. Pair the phone with the car over
Bluetooth first.
Bluetooth first. After the first wireless drive, Android asks once whether Gearslip may
watch for that car; from then on, Gearslip connects whenever the car's Bluetooth does.
- **Setup walks you through it.** First launch asks for the Android Auto certificate,
then one permission at a time, each with a plain reason and an Allow or Skip. A help
screen inside the car answers anything setup didn't cover.
- **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.
It has been tested against several real navigation and media apps.
- **Templated apps work.** Gearslip renders the Car App Library's templates on the car
screen, and it draws its own keyboard for search, sign-in, and text fields. It has been
tested against several real navigation and media apps.
- **Gearslip works with [LIVI](https://github.com/f-io/LIVI),** the open-source head unit
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.
Gearslip's own player. TIDAL lets only approved apps browse its library.
- **Calls use the car's Bluetooth,** as they do in Android Auto. Pair the phone with the
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.
- **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 USB audio, setup offers two
Android permission or an on-device consent dialog. For audio through Gearslip, setup offers two
optional adb commands: one removes the audio dialog for good, and the other keeps
notifications visible while audio plays.
- **Updates come from GitHub.** Once a day, Gearslip checks this repository's latest
@@ -119,7 +135,7 @@ turn them on, in setup or in Settings.
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
size, and connection result, with whether that drive used the cable or wireless and
whether music went over USB or Bluetooth. If you tick it, how each drive went: how far
whether music went through Gearslip or over Bluetooth. If you tick it, how each drive went: how far
the connection got, how long it lasted, and how it ended. Settings shows the exact note. Untick a phone detail, and
the server drops the copy it kept.
- **What they leave out.** Your name, accounts, location, contacts, messages, and
@@ -146,6 +162,9 @@ 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 help translate Gearslip into your language, join
[Gearslip on Weblate](https://hosted.weblate.org/engage/gearslip/). No coding needed.
## Building
```
Binary file not shown.

Before

Width:  |  Height:  |  Size: 421 KiB

After

Width:  |  Height:  |  Size: 523 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 357 KiB

After

Width:  |  Height:  |  Size: 373 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 KiB

After

Width:  |  Height:  |  Size: 351 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 538 KiB

After

Width:  |  Height:  |  Size: 397 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 569 KiB

After

Width:  |  Height:  |  Size: 586 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 116 KiB

+24 -8
View File
@@ -38,9 +38,9 @@ android {
applicationId = "app.seb3thehacker.gearslip"
minSdk = 31
targetSdk = 37
versionCode = 10
versionCode = 13
// -PgearslipVersionName=0.1.99-test makes a build that thinks it's older, to try the updater.
versionName = project.findProperty("gearslipVersionName")?.toString() ?: "0.3.01-alpha"
versionName = project.findProperty("gearslipVersionName")?.toString() ?: "0.3.31-alpha"
manifestPlaceholders["appLabel"] = "Gearslip"
// Where opted-in phones send their daily usage note: the stats-worker/ Worker's address,
// set as gearslipStatsUrl in gradle.properties. Left empty, nothing is ever sent.
@@ -74,8 +74,12 @@ android {
applicationIdSuffix = ".dev"
signingConfigs.findByName("release")?.let { signingConfig = it }
manifestPlaceholders["appLabel"] = "Gearslip Debug"
// -Pr8 shrinks a debug build like a release one, to test R8 in the debug install.
if (project.hasProperty("r8")) {
// English (XA) and Arabic (XB) under the phone's languages: stretched, accented and
// right-to-left text, which shows what's still hardcoded and what doesn't fit.
isPseudoLocalesEnabled = true
// Shrunk with R8 like a release, so a debug install behaves like what ships (a keep
// rule missing for release shows up here first). -PnoR8 skips it for a quicker build.
if (!project.hasProperty("noR8")) {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
@@ -87,6 +91,18 @@ android {
buildConfig = true
}
// Lists each language that has a translation in the app's locale config, so Android 13+
// offers it under the system's per-app languages. English is the unqualified default
// (res/resources.properties).
androidResources {
generateLocaleConfig = true
}
lint {
// Weblate fills languages in a string at a time; the rest fall back to English.
disable += "MissingTranslation"
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
@@ -105,9 +121,10 @@ android {
}
// Renames the produced APK and moves it to the root build/ folder (<root>/build/Gearslip-debug.apk)
// after the fact rather than fighting AGP's variant-output API across versions - simpler and
// doesn't break when that API moves again.
// Copies the produced APK to the root build/ folder (<root>/build/Gearslip-debug.apk) after the
// fact rather than fighting AGP's variant-output API across versions - simpler and doesn't break
// 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") {
doLast {
val rootBuildDir = rootProject.layout.buildDirectory.get().asFile
@@ -117,7 +134,6 @@ tasks.register("renameApk") {
.forEach { apk ->
val renamed = File(rootBuildDir, "Gearslip-${apk.parentFile.name}.apk")
apk.copyTo(renamed, overwrite = true)
apk.delete()
}
}
}
+26
View File
@@ -25,6 +25,9 @@
hands over its Wi-Fi details; Gearslip then joins that network to reach the car.
-->
<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.NEARBY_WIFI_DEVICES"
@@ -123,6 +126,7 @@
android:roundIcon="@mipmap/ic_launcher"
android:label="${appLabel}"
android:supportsRtl="true"
android:manageSpaceActivity="app.seb3thehacker.gearslip.ui.ManageSpaceActivity"
android:theme="@style/Theme.Gearslip">
<provider
@@ -159,6 +163,18 @@
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.
-->
<!-- App info › Storage › Manage space: clears lyrics, voice models and logs one at a time. -->
<activity
android:name=".ui.ManageSpaceActivity"
android:exported="true"
android:label="Gearslip storage" />
<activity
android:name=".PermissionActivity"
android:exported="false"
android:excludeFromRecents="true"
android:theme="@style/Theme.Gearslip.Invisible" />
<activity-alias
android:name=".GearslipUsbAccessory"
android:targetActivity=".GearslipActivity"
@@ -244,5 +260,15 @@
<action android:name="android.telecom.CallScreeningService" />
</intent-filter>
</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>
</manifest>
@@ -0,0 +1,164 @@
package app.seb3thehacker.gearslip
import android.content.Context
import android.os.Handler
import android.os.Looper
import app.seb3thehacker.gearslip.car.ClusterScreen
/**
* Gearslip's picture on the car's instrument-cluster screen: a second video sink, for a car that
* offers one, showing the turn card ([ClusterScreen]). The same exchange as the main screen's,
* on its own channel, with its own encoder and its own private display:
*
* -> CHANNEL_OPEN_REQUEST <- CHANNEL_OPEN_RESPONSE
* -> Setup { H.264 } <- Config { status, max_unacked, configuration_indices }
* -> VideoFocusRequest <- VideoFocusNotification { projected }
* -> Start, CodecConfig, Data... <- Ack
*
* No touch: a cluster has none. Experimental, behind [car.CarSettings.clusterVideoEnabled], and
* untested in a car: none in the notes has a cluster sink.
*/
internal class ClusterVideo(
private val context: Context,
private val service: ServiceDiscovery.VideoService,
private val send: (messageId: Int, body: ByteArray) -> Unit,
) {
private val log = GearslipLog.tagged("CLUSTER")
private val main = Handler(Looper.getMainLooper())
val channelId get() = service.serviceId
private var configIndex = 0
@Volatile private var started = false
@Volatile private var running = true
@Volatile private var source: VideoSource? = null
private var projector: ScreenProjector? = null
private val ackLock = Object()
@Volatile private var inFlight = 0
@Volatile private var maxUnacked = 1
@Volatile private var lastAckAt = 0L
private var frames = 0
fun onMessage(messageId: Int, body: ByteArray) {
when (messageId) {
GearslipRunner.MSG_CHANNEL_OPEN_RESPONSE -> {
val status = Protobuf.readInt32Field(body, 1) ?: 0
log.i("<- ChannelOpenResponse: status=$status")
if (status != 0) return
send(GearslipRunner.MSG_MEDIA_SETUP, Protobuf.varintField(1, GearslipRunner.CODEC_H264_BP.toLong()))
log.i("-> Setup(codec=VIDEO_H264_BP)")
}
GearslipRunner.MSG_MEDIA_CONFIG -> {
val fields = Wire.fields(body)
Wire.varint(fields, 2)?.toInt()?.let { maxUnacked = maxOf(1, it) }
val offered = fields.filter { it.number == 3 && it.wireType == 0 }.map { it.varint.toInt() }
// The cluster gets the smallest picture it offers: a turn card needs no more.
configIndex = offered.minByOrNull { service.configs.getOrNull(it)?.pixelCount() ?: Int.MAX_VALUE } ?: 0
log.i("<- Config status=${Wire.varint(fields, 1)} max_unacked=$maxUnacked offered=$offered - using $configIndex")
val focus = Protobuf.varintField(2, GearslipRunner.VIDEO_FOCUS_PROJECTED.toLong()) + Protobuf.varintField(3, 1L)
send(GearslipRunner.MSG_VIDEO_FOCUS_REQUEST, focus)
log.i("-> VideoFocusRequest(mode=PROJECTED)")
}
GearslipRunner.MSG_VIDEO_FOCUS_NOTIFICATION -> {
val mode = Wire.varint(Wire.fields(body), 1)?.toInt() ?: GearslipRunner.VIDEO_FOCUS_PROJECTED
log.i("<- VideoFocusNotification: mode=$mode")
val projected = mode == GearslipRunner.VIDEO_FOCUS_PROJECTED || mode == GearslipRunner.VIDEO_FOCUS_PROJECTED_NO_INPUT
if (projected && !started) startStream()
}
GearslipRunner.MSG_MEDIA_ACK -> {
val acked = Wire.varint(Wire.fields(body), 2)?.toInt() ?: 1
synchronized(ackLock) {
inFlight = maxOf(0, inFlight - maxOf(1, acked))
lastAckAt = System.currentTimeMillis()
ackLock.notifyAll()
}
}
else -> log.i("<- unhandled message id=$messageId (${body.size} bytes)")
}
}
private fun startStream() {
val config = service.configs.getOrNull(configIndex) ?: service.configs.firstOrNull() ?: return
val (width, height) = config.pixelSize() ?: run {
log.w("unsupported cluster resolution ${config.resolutionName}")
return
}
started = true
send(
GearslipRunner.MSG_MEDIA_START,
Protobuf.varintField(1, GearslipRunner.SESSION_ID.toLong()) + Protobuf.varintField(2, configIndex.toLong()),
)
log.i("-> Start(config=$configIndex -> $config)")
lastAckAt = System.currentTimeMillis()
val video = VideoSource(
width = width,
height = height,
frameRate = FRAME_RATE,
mode = VideoSource.Mode.SURFACE,
wireless = true, // a small share of the link: the main screen comes first
onCodecConfig = { csd ->
send(GearslipRunner.MSG_MEDIA_CODEC_CONFIG, csd)
log.i("-> CodecConfig (${csd.size} bytes)")
},
onFrame = { data, pts, _ -> sendFrame(data, pts) },
)
source = video
runCatching { video.start() }.onFailure {
log.e("cluster encoder failed to start", it)
return
}
val surface = video.inputSurface ?: return
val density = config.density.takeIf { it > 0 } ?: 160
main.post {
if (!running) return@post
projector = ScreenProjector(context).also {
it.start(surface, width, height, density, name = "gearslip-cluster") {
// The margins the car crops off are kept clear, as on the main screen.
ClusterScreen(config.widthMargin, config.heightMargin)
}
}
}
}
/** As on the main screen: wait for the car's acks rather than drop a frame. */
private fun sendFrame(data: ByteArray, presentationTimeUs: Long) {
synchronized(ackLock) {
while (running && inFlight >= maxUnacked) {
val since = System.currentTimeMillis() - lastAckAt
if (since > GearslipRunner.ACK_STALL_MS) {
inFlight = 0
lastAckAt = System.currentTimeMillis()
break
}
ackLock.wait(GearslipRunner.ACK_STALL_MS - since + 1)
}
}
if (!running) return
val timestamp = ByteArray(8)
for (i in 0 until 8) timestamp[i] = ((presentationTimeUs shr ((7 - i) * 8)) and 0xFF).toByte()
synchronized(ackLock) { inFlight++ }
runCatching { send(GearslipRunner.MSG_MEDIA_DATA, timestamp + data) }.onFailure { running = false }
if (++frames <= 2) log.i("-> Data frame #$frames (${data.size} bytes)")
}
fun stop() {
running = false
synchronized(ackLock) { ackLock.notifyAll() }
main.post {
projector?.stop()
projector = null
}
source?.stop()
source = null
}
private companion object {
// A turn card changes a few times a minute; the encoder repeats the last frame between.
const val FRAME_RATE = 15
}
}
@@ -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
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.io.OutputStream
/**
* 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
*/
/** Android Auto framing with plaintext fragmentation before per-frame encryption. */
object Frames {
const val CHANNEL_CONTROL = 0
@@ -36,8 +26,11 @@ object Frames {
const val MESSAGE_SPECIFIC = 0
const val MESSAGE_CONTROL = 1 shl 2
/** aasdk splits at this size (MessageOutStream.hpp:62). Nothing the spike sends is close. */
const val MAX_FRAME_PAYLOAD = 0x4000
// Android Auto's default fragment budget includes the framing header. TLS adds overhead.
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(
val channel: Int,
@@ -45,6 +38,7 @@ object Frames {
val encrypted: Boolean,
val messageType: Int,
val payload: ByteArray,
val totalSize: Int? = null,
)
/**
@@ -60,6 +54,11 @@ object Frames {
frameType: Int = TYPE_BULK,
totalSize: Int? = null,
): 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 header = byteArrayOf(
channel.toByte(),
@@ -67,7 +66,7 @@ object Frames {
((payload.size shr 8) 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(
((totalSize shr 24) and 0xFF).toByte(),
((totalSize shr 16) and 0xFF).toByte(),
@@ -77,6 +76,43 @@ object Frames {
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.
*
@@ -87,6 +123,8 @@ object Frames {
private var pending = ByteArray(0)
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)
System.arraycopy(pending, 0, grown, 0, pending.size)
System.arraycopy(data, 0, grown, pending.size, length)
@@ -106,30 +144,53 @@ object Frames {
val total = headerLength + frameSize
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)
pending = pending.copyOfRange(total, pending.size)
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 {
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) {
TYPE_BULK -> payload
TYPE_FIRST -> {
partial[frame.channel] = ByteArrayOutputStream().apply { write(payload) }
null
fun offer(frame: Frame, payload: ByteArray): ByteArray? {
if (frame.frameType == TYPE_BULK) {
if (partial.containsKey(frame.channel)) fail("Complete message interrupted fragments")
return payload
}
TYPE_MIDDLE -> {
partial[frame.channel]?.write(payload)
null
}
TYPE_LAST -> partial.remove(frame.channel)?.apply { write(payload) }?.toByteArray()
else -> null
val message = if (frame.frameType == TYPE_FIRST) {
if (frame.totalSize == null || partial.containsKey(frame.channel)) fail("Invalid first fragment")
Partial(frame).also { partial[frame.channel] = it }
} else partial[frame.channel] ?: fail("Fragment without a first frame")
val first = message.first
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.view.MotionEvent
import android.view.Surface
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.runtime.collectAsState
@@ -117,6 +116,17 @@ class GearslipActivity : ComponentActivity(), Projection {
}
}
/** Android's one-time "Allow" for a car to connect by itself; see [WirelessAuto]. */
private var approvingCar: String? = null
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. */
private val callScreeningRole = registerForActivityResult(StartActivityForResult()) { result ->
if (result.resultCode != RESULT_OK) GearslipLog.w("calls: call screening role declined")
@@ -130,7 +140,6 @@ class GearslipActivity : ComponentActivity(), Projection {
// 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.
enableEdgeToEdge()
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
GearslipLog.init(this)
GearslipLog.installCrashHandler()
@@ -138,6 +147,12 @@ class GearslipActivity : ComponentActivity(), Projection {
app.seb3thehacker.gearslip.wireless.WirelessLink.onConnected = { socket ->
runOnUiThread { startWireless(socket) }
}
app.seb3thehacker.gearslip.wireless.WirelessAuto.showApproval = { chooser, address ->
runOnUiThread {
approvingCar = address
carApproval.launch(androidx.activity.result.IntentSenderRequest.Builder(chooser).build())
}
}
CarSettings.init(this)
AppSettings.applyGearslipEnabled(this)
// A capture that died without tidying up would leave notifications exposed to every
@@ -216,6 +231,7 @@ class GearslipActivity : ComponentActivity(), Projection {
override fun onDestroy() {
super.onDestroy()
app.seb3thehacker.gearslip.wireless.WirelessLink.onConnected = null
app.seb3thehacker.gearslip.wireless.WirelessAuto.showApproval = null
runCatching { unregisterReceiver(detachReceiver) }
PhoneMirror.onStartRequested = null
PhoneMirror.onTokenReady = null
@@ -304,6 +320,12 @@ class GearslipActivity : ComponentActivity(), Projection {
}
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) {
connectToNearbyCar(intent.getStringExtra(app.seb3thehacker.gearslip.wireless.WirelessAuto.EXTRA_ADDRESS))
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
// does, leave the accessory alone.
@@ -321,6 +343,39 @@ class GearslipActivity : ComponentActivity(), Projection {
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) {
// 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
@@ -52,6 +52,46 @@ object GearslipLog {
file = File(dir, "gearslip-$name.log")
i("log file: ${file?.absolutePath}")
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() } }
}
private const val KEEP_FILES = 30
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,
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() }
@@ -2,6 +2,7 @@ package app.seb3thehacker.gearslip
import android.os.Build
import app.seb3thehacker.gearslip.audio.AudioLink
import app.seb3thehacker.gearslip.audio.CarAudio
import app.seb3thehacker.gearslip.audio.CarMic
import app.seb3thehacker.gearslip.media.CarMediaStatus
import app.seb3thehacker.gearslip.car.BluetoothAudioCheck
@@ -12,12 +13,18 @@ import app.seb3thehacker.gearslip.car.DirectionsVoice
import app.seb3thehacker.gearslip.car.CarAssistant
import app.seb3thehacker.gearslip.car.CarEnvironment
import app.seb3thehacker.gearslip.car.CarKeys
import app.seb3thehacker.gearslip.car.ScreenFit
import app.seb3thehacker.gearslip.car.CarSensors
import app.seb3thehacker.gearslip.car.CarServices
import app.seb3thehacker.gearslip.car.CarSettings
import java.io.IOException
import app.seb3thehacker.gearslip.protocol.InputProtocol
import java.io.InputStream
import java.io.OutputStream
import java.util.concurrent.Executors
import java.util.concurrent.FutureTask
import java.util.concurrent.RejectedExecutionException
import java.util.concurrent.TimeUnit
/**
* Drives the phone side of the control-channel handshake far enough to answer one question:
@@ -59,6 +66,7 @@ class GearslipRunner(
/** Waits for the certificate only when TLS is first needed; see [run]. */
private val tls: PhoneTls by lazy { identityTask.get() }
private var authenticationFailed = false
private var drainWriterOnExit = false
@Volatile private var stopped = false
@Volatile private var state = State.WAIT_VERSION
@@ -71,6 +79,12 @@ class GearslipRunner(
private var vehicleProfile: VehicleProfile? = null
@Volatile private var videoStarted = false
private var videoSource: VideoSource? = null
/** Between the car UI and the encoder when the car squashes the frame (see [screenShape]). */
private var stretcher: FrameStretcher? = null
/** What the car UI draws at when [stretcher] is on; touches are scaled into it. */
@Volatile private var drawSize: Pair<Int, Int>? = null
@Volatile private var inFlight = 0
/** Guards [inFlight]: the encoder thread waits on it for the head unit's acks. */
private val ackLock = Object()
@@ -82,6 +96,8 @@ class GearslipRunner(
@Volatile private var inputKeycodes: List<Int> = emptyList()
@Volatile private var sensorChannelId: Int = -1
@Volatile private var mediaStatus: CarMediaStatus? = null
@Volatile private var phoneStatus: app.seb3thehacker.gearslip.call.CarPhoneStatus? = null
@Volatile private var clusterVideo: ClusterVideo? = null
@Volatile private var clusterNav: ClusterNav? = null
private var sensorTypes: List<Int> = emptyList()
@Volatile private var audioLink: AudioLink? = null
@@ -146,6 +162,8 @@ class GearslipRunner(
reportDisconnect()
} finally {
running = false
writer.shutdown()
if (drainWriterOnExit) writer.awaitTermination(2, TimeUnit.SECONDS)
writer.shutdownNow()
log.flush()
}
@@ -160,17 +178,25 @@ class GearslipRunner(
synchronized(ackLock) { ackLock.notifyAll() } // free an encoder waiting on an ack
videoSource?.stop()
videoSource = null
stretcher?.release()
stretcher = null
CarAudio.openSink = null
audioLink?.close()
audioLink = null
guidanceLink?.close()
guidanceLink = null
CarSensors.clear()
app.seb3thehacker.gearslip.car.CarData.clear()
CarMic.current = null
CarBluetooth.onCarDisconnected()
BluetoothAudioCheck.onCarDisconnected()
LocationCheck.onCarDisconnected()
mediaStatus?.stop()
mediaStatus = null
phoneStatus?.stop()
phoneStatus = null
clusterVideo?.stop()
clusterVideo = null
clusterNav?.stop()
clusterNav = null
lastSensorNight = null
@@ -181,7 +207,7 @@ class GearslipRunner(
private fun handleFrame(frame: Frames.Frame) {
// A rejected handshake cannot consume later messages as if authentication succeeded.
if (authenticationFailed || stopped) return
if (authenticationFailed || stopped || !running) return
val payload = if (frame.encrypted) {
if (!tls.handshakeComplete) {
log.w("encrypted frame arrived before the handshake finished - ignoring")
@@ -192,7 +218,10 @@ class GearslipRunner(
frame.payload
}
val message = assembler.offer(frame, payload) ?: return
val message = if (frame.messageType == Frames.MESSAGE_CONTROL) {
if (frame.frameType != Frames.TYPE_BULK) throw IOException("Fragmented channel control message")
payload
} else assembler.offer(frame, payload) ?: return
if (message.size < 2) {
log.w("runt message on channel ${frame.channel}")
return
@@ -204,6 +233,7 @@ class GearslipRunner(
lastProgress = System.currentTimeMillis()
if (frame.channel != Frames.CHANNEL_CONTROL) {
if (state != State.DONE || !frame.encrypted) return
if (frame.channel == videoChannelId) {
onVideoMessage(messageId, body)
} else if (frame.channel == inputChannelId) {
@@ -214,6 +244,10 @@ class GearslipRunner(
clusterNav?.onMessage(messageId, body)
} else if (frame.channel == mediaStatus?.channelId) {
mediaStatus?.onMessage(messageId, body)
} else if (frame.channel == phoneStatus?.channelId) {
phoneStatus?.onMessage(messageId, body)
} else if (frame.channel == clusterVideo?.channelId) {
clusterVideo?.onMessage(messageId, body)
} else if (frame.channel == guidanceLink?.channelId) {
guidanceLink?.onMessage(messageId, body)
} else if (frame.channel == CarBluetooth.channelId) {
@@ -239,6 +273,9 @@ class GearslipRunner(
}
return
}
if (messageId !in setOf(MSG_VERSION_REQUEST, MSG_ENCAPSULATED_SSL, MSG_AUTH_COMPLETE,
MSG_BYEBYE_REQUEST, MSG_UNEXPECTED_MESSAGE, MSG_FRAMING_ERROR) &&
(!frame.encrypted || state != State.DONE && state != State.WAIT_SDR)) return
when (messageId) {
MSG_VERSION_REQUEST -> onVersionRequest(body)
@@ -247,11 +284,25 @@ class GearslipRunner(
MSG_SERVICE_DISCOVERY_RESPONSE -> onServiceDiscoveryResponse(body)
MSG_PING_REQUEST -> onPingRequest(body)
MSG_BYEBYE_REQUEST -> onByeByeRequest(body)
AudioLink.MSG_FOCUS_RESPONSE -> audioLink?.onFocus(Protobuf.readInt32Field(body, 1) ?: 0)
MSG_UNEXPECTED_MESSAGE, MSG_FRAMING_ERROR -> onProtocolError(messageId)
AudioLink.MSG_FOCUS_RESPONSE -> {
// One answer for both sinks: it is the directions' while they wait on one.
val state = Protobuf.readInt32Field(body, 1) ?: 0
val guidance = guidanceLink
if (guidance != null && guidance.expectsFocusAnswer()) guidance.onFocus(state) else audioLink?.onFocus(state)
}
else -> log.i("unhandled control message id=$messageId (${body.size} bytes) - continuing")
}
}
private fun onProtocolError(messageId: Int) {
val detail = if (messageId == MSG_FRAMING_ERROR) "framing error" else "unexpected message"
log.w("head unit terminated the protocol: $detail")
SessionReport.fail(SessionReport.Category.UNKNOWN, detail, state.name, "protocol $messageId")
SessionStatus.failed("Protocol error", "The head unit reported a $detail.")
running = false
}
/**
* The head unit asking to end the session. It drops USB a few seconds later whether or not
* we answer, so the point here is the reason: an Audi MIB2+ sent this about a second after
@@ -265,16 +316,23 @@ class GearslipRunner(
3 -> "NOT_SUPPORTED"
4 -> "NOT_CURRENTLY_SUPPORTED"
5 -> "PROBE_SUPPORTED"
6 -> "WIRELESS_PROJECTION_DISABLED"
7 -> "POWER_DOWN"
8 -> "USER_PROFILE_SWITCH"
else -> "unknown"
}
log.w("<- ByeByeRequest: reason=$reason ($name) in state $state")
log.hex(" byebye raw", body)
SessionReport.fail(SessionReport.Category.BYEBYE, "ByeByeRequest reason=$reason ($name)", state.name, "bye $reason")
send(MSG_BYEBYE_RESPONSE, ByteArray(0), encrypted = true)
send(MSG_BYEBYE_RESPONSE, ByteArray(0), encrypted = state == State.WAIT_SDR || state == State.DONE)
log.i("-> ByeByeResponse")
drainWriterOnExit = true
running = false
SessionStatus.disconnected()
}
private fun onVersionRequest(body: ByteArray) {
if (state != State.WAIT_VERSION) return
if (body.size < 4) {
log.w("VersionRequest too short (${body.size} bytes)")
return
@@ -284,24 +342,16 @@ class GearslipRunner(
log.i("<- VersionRequest: head unit speaks $major.$minor")
SessionReport.protocolVersion(major, minor)
// Always 1.3, on purpose. Every car that accepts Gearslip was tested this way, 1.5 units
// included. Echoing the car's own version was tried too: a 4.3 unit rejected the
// certificate either way, so the version doesn't decide acceptance. The log says so, since
// a car offering 1.5 and getting 1.3 back looks like a bug to anyone reading it.
val claim = CLAIM_VERSION ?: (major to minor)
val response = byteArrayOf(
((claim.first shr 8) and 0xFF).toByte(), (claim.first and 0xFF).toByte(),
((claim.second shr 8) and 0xFF).toByte(), (claim.second and 0xFF).toByte(),
0, 0, // STATUS_SUCCESS
)
send(MSG_VERSION_RESPONSE, response, encrypted = false)
log.i(
"-> VersionResponse: ${claim.first}.${claim.second} status=0 (STATUS_SUCCESS)" +
if (CLAIM_VERSION != null && claim != (major to minor)) {
" - intended: Gearslip always answers ${claim.first}.${claim.second}, which cars " +
"offering $major.$minor accept too"
} else "",
)
val response = ProtocolVersion.respond(major, minor)
send(MSG_VERSION_RESPONSE, response.bytes, encrypted = false)
log.i("-> VersionResponse: ${response.major}.${response.minor}, supported=${response.supported}")
if (!response.supported) {
authenticationFailed = true
drainWriterOnExit = true
running = false
SessionStatus.failed("Unsupported protocol", "The head unit requested protocol $major.$minor.")
return
}
state = State.TLS_HANDSHAKE
SessionStatus.connecting("Securing the link")
log.i("awaiting ClientHello - the head unit is the TLS client, we are the server")
@@ -318,17 +368,13 @@ class GearslipRunner(
if (stopped) return
log.e("TLS handshake threw", t)
SessionReport.fail(SessionReport.Category.TLS, "handshake threw", t, state.name)
log.verdict(
"NOT VIABLE (TLS rejected)",
"The head unit aborted the TLS handshake. A bad_certificate / unknown_ca / " +
"handshake_failure alert here means it validates the phone's certificate.",
)
SessionStatus.failed(
"Head unit rejected the certificate",
"The head unit aborted the secure handshake. It validates the phone's certificate.",
"Secure connection failed",
"The secure handshake could not be completed. Check the session log for details.",
)
state = State.DONE
authenticationFailed = true
running = false
SessionReport.print()
return
}
@@ -345,7 +391,7 @@ class GearslipRunner(
private fun onAuthComplete(body: ByteArray) {
val status = Protobuf.readInt32Field(body, 1)
if (state != State.WAIT_AUTH && !(state == State.TLS_HANDSHAKE && status in listOf(-2, -3))) return
if (state != State.WAIT_AUTH && !(state == State.TLS_HANDSHAKE && status != null && status != 0)) return
log.i("<- AuthComplete: status=$status")
status?.let { SessionReport.auth(it) }
@@ -356,17 +402,17 @@ class GearslipRunner(
SessionStatus.connecting("Certificate accepted - discovering services")
sendServiceDiscoveryRequest()
}
-2 -> {
SessionReport.fail(SessionReport.Category.CERTIFICATE, "AuthComplete status -2 (certificate error)", state.name, "auth -2")
log.verdict(
"NOT VIABLE (STATUS_CERTIFICATE_ERROR)",
"The head unit explicitly rejected the phone's self-signed certificate. " +
"This is a definitive no - it validates the phone against a trust chain " +
"we cannot issue from.",
)
-2, -23, -24 -> {
val reason = when (status) {
-23 -> "The phone's certificate is not valid yet."
-24 -> "The phone's certificate has expired."
else -> "The head unit refused the phone's certificate."
}
SessionReport.fail(SessionReport.Category.CERTIFICATE, "AuthComplete status $status", state.name, "auth $status")
log.w("certificate rejected: $reason")
SessionStatus.failed(
"Head unit rejected the certificate",
"The head unit refused the phone's certificate (certificate error).",
reason,
)
state = State.DONE
authenticationFailed = true
@@ -397,6 +443,7 @@ class GearslipRunner(
authenticationFailed = true
}
}
if (authenticationFailed) running = false
}
private fun sendServiceDiscoveryRequest() {
@@ -407,6 +454,7 @@ class GearslipRunner(
}
private fun onServiceDiscoveryResponse(body: ByteArray) {
if (state != State.WAIT_SDR) return
log.i("<- ServiceDiscoveryResponse (${body.size} bytes, decrypted successfully)")
// Field 5 is the car's serial number, and the Bluetooth service carries the car's
// Bluetooth address. Either one identifies the car, and logs get shared, so both stay out.
@@ -425,6 +473,7 @@ class GearslipRunner(
log.i("head unit: $info")
SessionReport.headUnit(info.toString())
SessionReport.headUnitInfo = info
app.seb3thehacker.gearslip.car.CarData.onModel(info.carModel, info.carYear)
vehicleProfile = runCatching { vehicleProfileFor(info) }.getOrNull()
val profile = vehicleProfile
SessionReport.profile(profile?.name)
@@ -475,6 +524,8 @@ class GearslipRunner(
startSensorChannel(serviceDiscoveryResponse)
startMicChannel(serviceDiscoveryResponse)
startMediaStatusChannel(serviceDiscoveryResponse)
startPhoneStatusChannel(serviceDiscoveryResponse)
startClusterVideo(serviceDiscoveryResponse)
startClusterNavChannel(serviceDiscoveryResponse)
startBluetooth(serviceDiscoveryResponse)
startVendorChannel(serviceDiscoveryResponse)
@@ -527,7 +578,17 @@ class GearslipRunner(
// as Android Auto does, instead of letting it play on to no one.
onTakenBack = { CarServices.pausePhoneMedia("the car switched to another source") },
)
openChannel(media.serviceId)
if (CarSettings.pipeAudio.value) {
openChannel(media.serviceId)
} else {
// Many cars switch their source to Android Auto the moment this channel opens, which
// cut off music the driver chose to play over Bluetooth. Open it only if they switch.
log.i("music goes over Bluetooth: the media channel stays closed, so the car keeps its own source")
CarAudio.openSink = {
CarAudio.openSink = null
openChannel(media.serviceId)
}
}
}
/** The car's directions channel, where [DirectionsVoice] reads out each turn. */
@@ -545,7 +606,7 @@ class GearslipRunner(
tag = "guidance",
sessionId = GUIDANCE_SESSION_ID,
asksFocus = false,
onReady = { DirectionsVoice.attach(it) },
onReady = { DirectionsVoice.attach(it) { audioLink?.streaming == true } },
onClose = { DirectionsVoice.detach(it) },
)
openChannel(guidance.serviceId)
@@ -581,6 +642,32 @@ class GearslipRunner(
openChannel(channel)
}
/** The turn card on the instrument cluster, for a car with a cluster screen: experimental, off by default. */
private fun startClusterVideo(serviceDiscoveryResponse: ByteArray) {
val sink = ServiceDiscovery.findClusterVideoService(serviceDiscoveryResponse) ?: return
log.i("cluster video sink: channel=${sink.serviceId} display=${sink.displayId} type=${sink.displayType}")
if (!CarSettings.experimentalFeaturesEnabled.value || !CarSettings.clusterVideoEnabled.value) {
log.i("cluster screen is off in the car's Settings (experimental) - leaving that sink alone")
return
}
val context = app.seb3thehacker.gearslip.car.CarEnvironment.appContext ?: return
clusterVideo = ClusterVideo(context, sink) { id, body -> send(id, body, encrypted = true, channel = sink.serviceId) }
openChannel(sink.serviceId)
}
/** Calls, for the car's own screens: the cluster, the steering wheel display. */
private fun startPhoneStatusChannel(serviceDiscoveryResponse: ByteArray) {
val channel = ServiceDiscovery.findPhoneStatusChannel(serviceDiscoveryResponse) ?: run {
log.i("no phone status channel - the car's own screens won't show calls")
return
}
log.i("phone status channel: $channel")
phoneStatus = app.seb3thehacker.gearslip.call.CarPhoneStatus(channel) { id, body ->
send(id, body, encrypted = true, channel = channel)
}
openChannel(channel)
}
/** Bluetooth pairing, for calls: the channel only opens if the driver asks to pair. */
/**
* GM's own channel ("com.gm.vip"), opened in GM cars only. Issue #18: a 2021 Bolt ends every
@@ -619,12 +706,15 @@ class GearslipRunner(
/** Turn-by-turn for the instrument cluster: the next turn, its road, and how far. */
private fun startClusterNavChannel(serviceDiscoveryResponse: ByteArray) {
val channel = ServiceDiscovery.findNavStatusChannel(serviceDiscoveryResponse) ?: run {
val navigation = ServiceDiscovery.findNavStatusService(serviceDiscoveryResponse) ?: run {
log.i("no navigation status channel - the cluster won't show turns")
return
}
val channel = navigation.serviceId
log.i("navigation status channel: $channel")
clusterNav = ClusterNav(channel) { id, body -> send(id, body, encrypted = true, channel = channel) }
clusterNav = ClusterNav(channel, navigation.minimumIntervalMs.toLong()) { id, body ->
send(id, body, encrypted = true, channel = channel)
}
openChannel(channel)
}
@@ -680,7 +770,7 @@ class GearslipRunner(
*/
private fun onNightSensor(body: ByteArray) {
val night = Wire.bytes(Wire.fields(body), SENSOR_NIGHT) ?: return
val isNight = Wire.varint(Wire.fields(night), 1) == 1L
val isNight = (Wire.varint(Wire.fields(night), 1) ?: 0L) != 0L
if (isNight != lastSensorNight) {
lastSensorNight = isNight
log.i("car says it's ${if (isNight) "night" else "day"}")
@@ -716,6 +806,7 @@ class GearslipRunner(
MSG_SENSOR_EVENT_INDICATION -> {
CarSensors.onEvent(body)
app.seb3thehacker.gearslip.car.CarData.onEvent(body)
logSensorEvent(body)
onNightSensor(body)
}
@@ -763,59 +854,60 @@ class GearslipRunner(
MSG_KEY_BINDING_RESPONSE ->
log.i("<- KeyBindingResponse: status=${Protobuf.readInt32Field(body, 1)}")
MSG_INPUT_REPORT -> {
val report = Wire.fields(body)
// The 2018 Uconnect agrees to Exit but keeps showing our last frame and sending
// touches, which looked like a crash. A touch means our screen is still up there.
if (videoStarted && !videoProjected && Wire.bytes(report, 3) != null) {
log.w("the car said it took its screen back but still sends touches - showing ours again")
onVideoFocus(VIDEO_FOCUS_PROJECTED)
}
Wire.bytes(report, 4)?.let(::onKeyEvent)
Wire.bytes(report, 6)?.let(::onRelativeEvent)
val touch = Wire.bytes(report, 3) ?: Wire.bytes(report, 7)
if (touch == null) {
return
}
val touchFields = Wire.fields(touch)
val action = Wire.varint(touchFields, 3)?.toInt() ?: 0
val pointers = Wire.allBytes(touchFields, 1)
if (pointers.isEmpty()) return
val config = videoConfigs.getOrNull(selectedConfigIndex ?: 0)
val size = config?.pixelSize()
// The car crops its margin off the frame and measures taps from the corner of what
// it shows, so they come in short by half the margin: the 2024 VW (margin 240) sent
// y=6, which is inside the cropped band, and the Uconnect's never passed 351 on a
// 480-high frame with a 130 margin. Without this every tap landed 120 px too high
// and the VW couldn't press past its first screen.
val shift = config?.marginInsets() ?: Insets.NONE
val points = pointers.mapIndexedNotNull { index, pointer ->
val fields = Wire.fields(pointer)
val rawX = Wire.varint(fields, 1)?.toInt() ?: return@mapIndexedNotNull null
val rawY = Wire.varint(fields, 2)?.toInt() ?: return@mapIndexedNotNull null
val id = Wire.varint(fields, 3)?.toInt() ?: index
TouchPoint(id, clamp(rawX + shift.left, size?.first), clamp(rawY + shift.top, size?.second))
}
if (points.isEmpty()) return
val actionIndex = (Wire.varint(touchFields, 2)?.toInt() ?: 0).coerceIn(0, points.size - 1)
if (points.size > 1 && touchesSeen < 3) {
log.i("multi-touch: ${points.size} pointers, action=$action index=$actionIndex")
}
touchesSeen++
if (touchesSeen <= 10 || action == ACTION_DOWN || action == ACTION_UP) {
val at = points[actionIndex]
log.i("<- Touch action=$action at (${at.x.toInt()},${at.y.toInt()}) pointers=${points.size}")
}
// Time the next frame against this tap: see [reportTouchLatency].
if (action == ACTION_DOWN || action == ACTION_UP) touchAtUs = System.nanoTime() / 1000
projection?.onTouch(action, actionIndex, points)
}
MSG_INPUT_REPORT -> onInputReport(body)
else -> log.i("<- unhandled input message id=$messageId (${body.size} bytes)")
}
}
private fun onInputReport(body: ByteArray) {
val report = Wire.fields(body)
if (Wire.varint(report, 1) == null) return
Wire.bytes(report, 4)?.let(::onKeyEvent)
Wire.bytes(report, 6)?.let(::onRelativeEvent)
// Touchpads (field 7) need a separate gesture pipeline; their coordinates are not
// positions on the projected screen.
val touch = Wire.bytes(report, 3)
if (touch == null) {
return
}
// The 2018 Uconnect agrees to Exit but keeps showing our last frame and sending
// touches, which looked like a crash. A touch means our screen is still up there.
if (videoStarted && !videoProjected) {
log.w("the car said it took its screen back but still sends touches - showing ours again")
onVideoFocus(VIDEO_FOCUS_PROJECTED)
}
val config = videoConfigs.getOrNull(selectedConfigIndex ?: 0)
// The car crops its margin off the frame and measures taps from the corner of what it
// shows, so they come in short by half the margin: the 2024 VW (margin 240) sent y=6,
// inside the cropped band, and the Uconnect's never passed 351 on a 480-high frame with a
// 130 margin. Without this every tap landed 120 px too high.
val shift = config?.let(::marginsFor) ?: Insets.NONE
val size = config?.pixelSize()
// A stretched frame (see [screenShape]): the car's tap is on the frame, the UI is drawn smaller.
val drawn = drawSize
val stretched = drawn != null && size != null
val (action, actionIndex, raw) =
(if (stretched) InputProtocol.touch(touch, size) else InputProtocol.touch(touch, size, shift.left, shift.top)) ?: return
val points = if (drawn != null && size != null) {
raw.map { TouchPoint(it.id, it.x * drawn.first / size.first, it.y * drawn.second / size.second) }
} else {
raw
}
if (points.size > 1 && touchesSeen < 3) {
log.i("multi-touch: ${points.size} pointers, action=$action index=$actionIndex")
}
touchesSeen++
if (touchesSeen <= 10 || action == ACTION_DOWN || action == ACTION_UP) {
val at = points[actionIndex]
log.i("<- Touch action=$action at (${at.x.toInt()},${at.y.toInt()}) pointers=${points.size}")
}
// Time the next frame against this tap: see [reportTouchLatency].
if (action == ACTION_DOWN || action == ACTION_UP) touchAtUs = System.nanoTime() / 1000
projection?.onTouch(action, actionIndex, points)
}
/**
* Asks the head unit to forward its buttons. Many units send nothing from the steering wheel
* until the phone names the keys it wants. What the unit offered is asked for; a unit that
@@ -967,6 +1059,37 @@ class GearslipRunner(
return wide.minByOrNull(::pixels) ?: known.maxByOrNull(::pixels)
}
/**
* The part of a [frameWidth] x [frameHeight] frame shaped like the screen size set in
* Settings, centred and in even pixels; null when none is set or it is the frame's own shape.
* Some head units report the wrong size, then crop or squash the frame onto their real screen.
*/
private fun screenShape(frameWidth: Int, frameHeight: Int): Pair<Int, Int>? {
val screen = CarSettings.screenSize.value ?: return null
val wider = screen.width.toLong() * frameHeight >= screen.height.toLong() * frameWidth
val shape = if (wider) {
frameWidth to (frameWidth.toLong() * screen.height / screen.width).toInt()
} else {
(frameHeight.toLong() * screen.width / screen.height).toInt() to frameHeight
}
val even = (shape.first / 2 * 2).coerceAtLeast(2) to (shape.second / 2 * 2).coerceAtLeast(2)
return even.takeIf { it != (frameWidth to frameHeight) }
}
/**
* The strips of the frame the car doesn't show: the head unit's own margins, or, when a screen
* size is set to crop, whatever lies outside [screenShape]. The UI keeps out of them and taps
* are measured from inside them.
*/
private fun marginsFor(config: ServiceDiscovery.VideoConfig): Insets {
val (width, height) = config.pixelSize() ?: return config.marginInsets()
if (CarSettings.screenFit.value != ScreenFit.CROP) return config.marginInsets()
val (w, h) = screenShape(width, height) ?: return config.marginInsets()
val sideX = width - w
val sideY = height - h
return Insets(top = sideY / 2, bottom = sideY - sideY / 2, left = sideX / 2, right = sideX - sideX / 2)
}
private fun startVideoSource() {
lastKeyframeSentAt = System.currentTimeMillis() // frame #1 is always a keyframe
val index = selectedConfigIndex ?: 0
@@ -987,18 +1110,37 @@ class GearslipRunner(
log.i("-> Start(session_id=$SESSION_ID, configuration_index=$index -> $config)")
// The car crops its margins off the frame, so keep the UI out of them unless a vehicle
// profile has its own tuned insets.
if (vehicleProfile?.insets.let { it == null || it == Insets.NONE }) {
val insets = config.marginInsets()
stretcher?.release()
stretcher = null
drawSize = null
val shape = screenShape(width, height)
val squash = shape != null && CarSettings.screenFit.value == ScreenFit.SQUASH
if (shape != null) {
log.i(
"screen size set to ${CarSettings.screenSize.value} in Settings: " +
"${if (squash) "drawing" else "cropping to"} ${shape.first}x${shape.second} of the ${width}x$height frame",
)
}
if (squash) {
CarEnvironment.setInsets(Insets.NONE)
} else if (vehicleProfile?.insets.let { it == null || it == Insets.NONE }) {
val insets = marginsFor(config)
CarEnvironment.setInsets(insets)
if (insets != Insets.NONE) log.i("keeping the UI clear of the car's margins: $insets")
}
val useProjection = projection != null
// 60fps only over a cable: over Wi-Fi it doubles the data for little the driver sees.
videoFrameRate = if (config.frameRate == 1 && !wireless) 60 else 30
smoothWindows = 0
lastCut = null
decoderBound = false
adaptiveVideo = CarSettings.experimentalFeaturesEnabled.value && CarSettings.adaptiveVideoEnabled.value
if (adaptiveVideo) log.i("video: adaptive bitrate on (experimental)")
videoSource = VideoSource(
width = width,
height = height,
// 60fps only over a cable: over Wi-Fi it doubles the data for little the driver sees.
frameRate = if (config.frameRate == 1 && !wireless) 60 else 30,
frameRate = videoFrameRate,
mode = if (useProjection) VideoSource.Mode.SURFACE else VideoSource.Mode.TEST_CARD,
wireless = wireless,
onCodecConfig = { csd ->
@@ -1015,7 +1157,16 @@ class GearslipRunner(
source.start()
val surface = source.inputSurface
if (projection != null && surface != null) {
projection.onSurfaceReady(surface, width, height, config.density)
val stretch = shape?.takeIf { squash }?.let { (w, h) ->
FrameStretcher(surface, w, h, width, height).takeIf { it.start() }
}
stretcher = stretch
if (stretch != null) {
drawSize = stretch.width to stretch.height
projection.onSurfaceReady(stretch.input, stretch.width, stretch.height, config.density)
} else {
projection.onSurfaceReady(surface, width, height, config.density)
}
SessionStatus.projecting()
BluetoothAudioCheck.onProjecting()
LocationCheck.onProjecting()
@@ -1156,18 +1307,15 @@ class GearslipRunner(
log.i("touch -> frame: ${draw + encode}ms (draw ${draw}ms, encode and wait ${encode}ms)")
}
private fun clamp(value: Int, limit: Int?): Float = when {
limit == null -> value.toFloat()
value < 0 -> 0f
value >= limit -> (limit - 1).toFloat()
else -> value.toFloat()
}
private fun onPingRequest(body: ByteArray) {
// The timestamp is an int64 and has to come back unchanged. Read as an int32 it was cut
// short, and a car that checks the echo (an Audi MIB2+) gave up at its second ping.
val timestamp = Protobuf.readInt64Field(body, 1) ?: System.nanoTime()
send(MSG_PING_RESPONSE, Protobuf.varintField(1, timestamp), encrypted = tls.handshakeComplete)
val fields = Wire.fields(body)
val timestamp = Wire.varint(fields, 1) ?: return
val payload = Wire.bytes(fields, 3)
val response = Protobuf.varintField(1, timestamp) +
(payload?.let { Protobuf.bytesField(2, it) } ?: byteArrayOf())
send(MSG_PING_RESPONSE, response, encrypted = tls.handshakeComplete)
log.i("<- PingRequest(timestamp=$timestamp) / -> PingResponse (keeping the session alive)")
}
@@ -1189,41 +1337,16 @@ class GearslipRunner(
try {
writer.execute {
try {
if (payload.size <= Frames.MAX_FRAME_PAYLOAD) {
val framePayload = if (encrypted) tls.encrypt(payload) else payload
output.write(Frames.build(channel, encrypted, framePayload, messageType))
} else {
// Split exactly as aasdk does: chunk the *plaintext*, encrypt each chunk
// separately, and carry the total plaintext length in the FIRST frame.
// Video keyframes for anything busier than a test card exceed 16 KB routinely.
var offset = 0
while (offset < payload.size) {
val size = minOf(Frames.MAX_FRAME_PAYLOAD, payload.size - offset)
val chunk = payload.copyOfRange(offset, offset + size)
val frameType = when {
offset == 0 -> Frames.TYPE_FIRST
offset + size >= payload.size -> Frames.TYPE_LAST
else -> Frames.TYPE_MIDDLE
}
val framePayload = if (encrypted) tls.encrypt(chunk) else chunk
output.write(
Frames.build(
channel, encrypted, framePayload, messageType, frameType,
if (frameType == Frames.TYPE_FIRST) payload.size else null,
),
)
offset += size
}
splitMessages++
}
val frameCount = Frames.writeMessage(output, channel, encrypted, messageType, payload) { tls.encrypt(it) }
if (frameCount > 1) splitMessages++
output.flush()
} catch (e: java.io.IOException) {
} catch (e: IOException) {
if (running) log.w("send failed, stopping: ${e.message}")
running = false
videoSource?.stop()
}
}
} catch (e: java.util.concurrent.RejectedExecutionException) {
} catch (e: RejectedExecutionException) {
// The session is over; nothing more goes out.
}
}
@@ -1302,8 +1425,10 @@ class GearslipRunner(
SessionReport.stream("${"%.1f".format(fps)} fps, sent=$sent acked=$acksSeen")
log.i(
"video: ${"%.1f".format(fps)} fps over 5s | sent=$sent waited=${waited}ms " +
"acked=$acksSeen inFlight=$inFlight split=$splitMessages",
"acked=$acksSeen inFlight=$inFlight split=$splitMessages " +
"kbps=${(videoSource?.bitRate ?: 0) / 1000}",
)
adaptBitRate(fps, waited)
// Self-healing safety net: force a fresh keyframe periodically regardless
// of whether we think one is owed. Protects against any future case where
@@ -1318,13 +1443,65 @@ class GearslipRunner(
}.apply { isDaemon = true }.start()
}
private companion object {
/**
* The protocol version Gearslip answers every car with, as (major, minor), or null to echo
* the head unit's. Kept at 1.3 because every working car was tested with it; see
* onVersionRequest.
*/
val CLAIM_VERSION: Pair<Int, Int>? = 1 to 3
/** The frame rate the stream was set up for, which adaptive bitrate measures against. */
@Volatile private var videoFrameRate = 30
/** Five-second windows in a row the stream kept up, towards climbing back up. */
private var smoothWindows = 0
/** The bitrate and frame rate before the last cut, to see whether cutting helped. */
private var lastCut: Pair<Int, Double>? = null
/** Experimental: read once per stream, so flipping the switch mid-drive changes nothing. */
@Volatile private var adaptiveVideo = false
/** Set once a cut didn't help: this car's decoder, not the link, sets the pace. */
private var decoderBound = false
/**
* Adaptive bitrate. A window where the encoder spent most of its time held back by the
* car's acks while the frame rate sagged means the car, or the Wi-Fi to it, can't take
* frames this size: a quarter comes off. Two easy windows in a row put a tenth back, up to
* where the stream started. A still screen (few frames, no waiting) changes nothing.
*/
private fun adaptBitRate(fps: Double, waitedMs: Long) {
if (!adaptiveVideo) return
val source = videoSource ?: return
val target = videoFrameRate.toDouble()
val behind = waitedMs > ADAPT_WINDOW_MS / 2 && fps < target * 0.8
val easy = waitedMs < ADAPT_WINDOW_MS / 10
when {
behind && !decoderBound -> {
smoothWindows = 0
val cut = lastCut
if (cut != null && fps < cut.second * 1.05) {
// The last cut bought no frames: the car's decoder is the limit, not the
// link, and smaller frames would only look worse. Undo it and stop here.
decoderBound = true
source.setBitRate(cut.first)
log.i("video: a lower bitrate didn't help the car keep up - back to ${cut.first / 1000} kbps, adapting off")
return
}
val before = source.bitRate
source.setBitRate((before * 0.75).toInt())
if (source.bitRate != before) {
lastCut = before to fps
log.i("video: car falling behind - bitrate ${before / 1000} -> ${source.bitRate / 1000} kbps")
}
}
easy && source.bitRate < source.fullBitRate -> {
lastCut = null
if (++smoothWindows >= 2) {
smoothWindows = 0
val before = source.bitRate
source.setBitRate((before * 1.1).toInt() + 1)
log.i("video: keeping up - bitrate ${before / 1000} -> ${source.bitRate / 1000} kbps")
}
}
else -> smoothWindows = 0
}
}
internal companion object {
/** The stats window adaptive bitrate reads, matching the watchdog's sleep. */
const val ADAPT_WINDOW_MS = 5_000L
// protobuf/aap_protobuf/service/control/ControlMessageType.proto:7-17
const val MSG_VERSION_REQUEST = 1
@@ -1338,6 +1515,8 @@ class GearslipRunner(
const val MSG_BYEBYE_REQUEST = 15
const val MSG_BYEBYE_RESPONSE = 16
const val MSG_VOICE_SESSION_NOTIFICATION = 17
const val MSG_UNEXPECTED_MESSAGE = 255
const val MSG_FRAMING_ERROR = 65535
const val MSG_CHANNEL_OPEN_REQUEST = 7
const val MSG_CHANNEL_OPEN_RESPONSE = 8
@@ -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}") }
}
}
}
@@ -1,5 +1,6 @@
package app.seb3thehacker.gearslip
import java.io.ByteArrayOutputStream
import java.net.Socket
import java.nio.ByteBuffer
import java.security.KeyStore
@@ -11,23 +12,10 @@ import javax.net.ssl.SSLEngine
import javax.net.ssl.SSLEngineResult
import javax.net.ssl.SSLEngineResult.HandshakeStatus
import javax.net.ssl.SSLPeerUnverifiedException
import javax.net.ssl.SSLException
import javax.net.ssl.X509ExtendedTrustManager
/**
* 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.
*/
/** TLS server for projection. Handshake records travel in control message 3, not a TLS socket. */
class PhoneTls(keyStore: KeyStore, password: CharArray) {
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 netBuffer = ByteBuffer.allocate(32 * 1024)
var handshakeComplete: Boolean = false
@Volatile var handshakeComplete: Boolean = false
private set
init {
@@ -52,6 +40,7 @@ class PhoneTls(keyStore: KeyStore, password: CharArray) {
useClientMode = false
// 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.
// Homemade head units may have none.
wantClientAuth = true
enabledProtocols = arrayOf("TLSv1.2") // aasdk pins TLS 1.2
}
@@ -88,7 +77,7 @@ class PhoneTls(keyStore: KeyStore, password: CharArray) {
if (netBuffer.hasRemaining()) {
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 -> {
@@ -100,7 +89,7 @@ class PhoneTls(keyStore: KeyStore, password: CharArray) {
appBuffer = ByteBuffer.allocate(appBuffer.capacity() * 2)
continue@loop
}
SSLEngineResult.Status.CLOSED -> break@loop
SSLEngineResult.Status.CLOSED -> throw SSLException("TLS closed during handshake")
else -> Unit
}
}
@@ -124,7 +113,7 @@ class PhoneTls(keyStore: KeyStore, password: CharArray) {
/** Wraps a control-channel payload for an ENCRYPTED frame. */
fun encrypt(plain: ByteArray): ByteArray {
val source = ByteBuffer.wrap(plain)
val out = java.io.ByteArrayOutputStream()
val out = ByteArrayOutputStream()
while (source.hasRemaining()) {
netBuffer.clear()
val result = engine.wrap(source, netBuffer)
@@ -132,6 +121,9 @@ class PhoneTls(keyStore: KeyStore, password: CharArray) {
netBuffer = ByteBuffer.allocate(netBuffer.capacity() * 2)
continue
}
if (result.status != SSLEngineResult.Status.OK || result.bytesConsumed() == 0) {
throw SSLException("TLS could not encrypt application data: ${result.status}")
}
netBuffer.flip()
val chunk = ByteArray(netBuffer.remaining())
netBuffer.get(chunk)
@@ -140,14 +132,10 @@ class PhoneTls(keyStore: KeyStore, password: CharArray) {
return out.toByteArray()
}
/**
* Unwraps one ENCRYPTED frame's payload. aasdk decrypts per frame
* (MessageOutStream::compoundFrame encrypts per frame), so each frame's payload is
* self-contained ciphertext.
*/
/** Each encrypted projection frame contains complete TLS records. */
fun decrypt(cipher: ByteArray): ByteArray {
val source = ByteBuffer.wrap(cipher)
val out = java.io.ByteArrayOutputStream()
val out = ByteArrayOutputStream()
while (source.hasRemaining()) {
appBuffer.clear()
val result = engine.unwrap(source, appBuffer)
@@ -157,10 +145,9 @@ class PhoneTls(keyStore: KeyStore, password: CharArray) {
continue
}
SSLEngineResult.Status.BUFFER_UNDERFLOW -> {
log.w("decrypt: record split across frames (${source.remaining()} bytes left over)")
break
throw SSLException("Incomplete TLS record in an encrypted frame")
}
SSLEngineResult.Status.CLOSED -> break
SSLEngineResult.Status.CLOSED -> throw SSLException("TLS peer closed the connection")
else -> Unit
}
appBuffer.flip()
@@ -2,14 +2,7 @@ package app.seb3thehacker.gearslip
import java.io.ByteArrayOutputStream
/**
* 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; }
*/
/** Protobuf field encoding and diagnostic rendering for the projection protocol. */
object Protobuf {
fun varint(value: Long): ByteArray {
@@ -41,29 +34,7 @@ object Protobuf {
fun readInt32Field(data: ByteArray, field: Int): Int? = readInt64Field(data, field)?.toInt()
/** Reads a varint field whole, for int64s such as a ping's timestamp. */
fun readInt64Field(data: ByteArray, field: Int): Long? {
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
}
fun readInt64Field(data: ByteArray, field: Int): Long? = Wire.varint(Wire.fields(data), field)
/**
* 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.
*/
fun describe(data: ByteArray, indent: String = " "): String {
if (indent.length > 32) return "$indent(nested message)\n"
val sb = StringBuilder()
var pos = 0
while (pos < data.size) {
@@ -89,6 +61,7 @@ object Protobuf {
}
2 -> {
val (len, afterLen) = readVarint(data, pos) ?: break
if (len < 0 || len > data.size - afterLen) break
val end = afterLen + len.toInt()
if (end > data.size) break
val body = data.copyOfRange(afterLen, end)
@@ -117,6 +90,7 @@ object Protobuf {
var pos = start
while (pos < data.size && shift <= 63) {
val b = data[pos].toInt() and 0xFF
if (shift == 63 && b > 1) return null
result = result or ((b and 0x7F).toLong() shl shift)
pos++
if (b and 0x80 == 0) return result to pos
@@ -135,45 +109,68 @@ object Wire {
val out = mutableListOf<Field>()
var pos = 0
while (pos < data.size) {
val tag = readVarint(data, pos) ?: break
val tag = readVarint(data, pos) ?: return emptyList()
pos = tag.second
val number = (tag.first ushr 3).toInt()
if (tag.first ushr 3 !in 1..0x1fff_ffffL) return emptyList()
when ((tag.first and 7L).toInt()) {
0 -> {
val v = readVarint(data, pos) ?: break
val v = readVarint(data, pos) ?: return emptyList()
pos = v.second
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 -> {
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()
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))
pos = end
}
5 -> { if (pos + 4 > data.size) break; pos += 4 }
else -> break
5 -> { if (data.size - pos < 4) return emptyList(); pos += 4 }
else -> return emptyList()
}
}
return out
}
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? =
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> =
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>? {
var result = 0L
var shift = 0
var pos = start
while (pos < data.size && shift <= 63) {
val b = data[pos].toInt() and 0xFF
if (shift == 63 && b > 1) return null
result = result or ((b and 0x7F).toLong() shl shift)
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)
}
@@ -51,7 +51,7 @@ class ScreenProjector(private val context: Context) {
* preview passes "gearslip-preview".
*/
fun start(
surface: Surface,
surface: Surface?,
width: Int,
height: Int,
densityDpi: Int,
@@ -219,6 +219,14 @@ class ScreenProjector(private val context: Context) {
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.
*/
fun setSurface(surface: Surface?) {
virtualDisplay?.surface = surface
}
fun stop() {
runCatching { presentation?.dismiss() }
runCatching { owner?.destroy() }
@@ -51,8 +51,17 @@ object ServiceDiscovery {
"$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)"
}
@@ -76,7 +85,7 @@ object ServiceDiscovery {
fun findAudioServices(response: ByteArray): List<AudioService> = buildList {
for (channel in Wire.allBytes(Wire.fields(response), 1)) {
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 sinkFields = Wire.fields(sink)
val audioConfigs = Wire.allBytes(sinkFields, 3)
@@ -116,13 +125,32 @@ object ServiceDiscovery {
*/
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? =
Wire.allBytes(Wire.fields(response), 1).firstNotNullOfOrNull { 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? {
for (channel in Wire.allBytes(Wire.fields(response), 1)) {
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 f = Wire.fields(Wire.bytes(source, 2) ?: continue)
return AudioService(
@@ -179,9 +207,7 @@ object ServiceDiscovery {
val fields = Wire.fields(Wire.bytes(service, 6) ?: 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.
val methods = fields.filter { it.number == 2 }.flatMap { f ->
f.bytes?.let(::packedVarints) ?: listOf(f.varint.toInt())
}
val methods = Wire.repeatedInts(fields, 2)
return BluetoothService(id, address, methods)
}
return null
@@ -215,8 +241,8 @@ object ServiceDiscovery {
* repeated TouchScreen touchscreen = 2; ... }
* 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
* scaled to the projected video size before injection. [keycodesSupported] is what the
* Touch reports use projected video coordinates; these dimensions describe the input
* 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
* 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.
@@ -245,38 +271,18 @@ object ServiceDiscovery {
private fun inputServices(response: ByteArray): List<InputService> = buildList {
for (channel in Wire.allBytes(Wire.fields(response), 1)) {
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 inputFields = Wire.fields(input)
// 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 width = Wire.varint(touchFields, 1)?.toInt() ?: 0
val height = Wire.varint(touchFields, 2)?.toInt() ?: 0
// Declared packed, but some units send each keycode as its own field.
val keycodes = inputFields.filter { it.number == 1 }.flatMap { f ->
f.bytes?.let(::packedVarints) ?: listOf(f.varint.toInt())
}
val keycodes = Wire.repeatedInts(inputFields, 1)
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
* cluster display, offers a sink for each; the one without a display id (0) is the main one.
@@ -286,10 +292,23 @@ object ServiceDiscovery {
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 {
for (channel in Wire.allBytes(Wire.fields(response), 1)) {
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 sinkFields = Wire.fields(sink)
@@ -307,7 +326,7 @@ object ServiceDiscovery {
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 +335,15 @@ object ServiceDiscovery {
* SensorSourceService { repeated Sensor sensors = 1; }
* Sensor { required SensorType sensor_type = 1; }
*
* Field numbers for what actually rides inside each SensorEvent once subscribed are not
* pinned down here - unlike video/audio/input, there's no independent source to check them
* against, so [CarSensors] shows the raw decode instead of trusting a guessed field map.
* SensorBatch contains repeated messages in fields 1 through 22 matching these type ids.
* The vehicle data screen keeps their raw contents for diagnostics.
*/
class SensorService(val serviceId: Int, val types: List<Int>)
fun findSensorService(response: ByteArray): SensorService? {
for (channel in Wire.allBytes(Wire.fields(response), 1)) {
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 types = Wire.allBytes(Wire.fields(sensorSource), 1).mapNotNull {
Wire.varint(Wire.fields(it), 1)?.toInt()
@@ -4,33 +4,25 @@ import java.net.Socket
import java.nio.ByteBuffer
import java.security.SecureRandom
import java.security.cert.X509Certificate
import javax.net.ssl.KeyManagerFactory
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLEngine
import javax.net.ssl.SSLEngineResult
import javax.net.ssl.SSLEngineResult.HandshakeStatus
import javax.net.ssl.X509ExtendedTrustManager
/**
* Runs [PhoneTls] against an in-process TLS client and checks that a full handshake plus a
* data round trip works.
*
* This exists because of one specific hazard: a bug in the certificate, the key manager or
* the engine wiring produces a dead connection in the car that is indistinguishable from a
* head unit rejecting us - and the whole point of the spike is a trustworthy go/no-go
* signal. Run this on the bench first; if it passes, a failure in the car is about the head
* unit, not about this code.
*/
/** Checks mutual TLS and an encrypted data round trip with an in-process head-unit peer. */
object TlsSelfTest {
private val log = GearslipLog.tagged("TLS")
fun run(identity: CertProvider.Identity): Boolean {
fun run(identity: CertProvider.Identity, withClientCertificate: Boolean = true): Boolean {
log.i("=== TLS self-test starting ===")
log.i("cert source: ${identity.source}")
log.i(" subject = ${identity.certificate.subjectX500Principal}")
log.i(" issuer = ${identity.certificate.issuerX500Principal}")
return try {
val server = PhoneTls(identity.keyStore, identity.password)
val client = Driver(clientEngine())
val client = Driver(clientEngine(withClientCertificate))
var outbound = client.pump(null) // ClientHello
var rounds = 0
@@ -49,8 +41,7 @@ object TlsSelfTest {
return false
}
// Data round trip, exercising the same encrypt path the spike uses for
// ServiceDiscoveryRequest.
// Exercise the same encrypt path used by ServiceDiscoveryRequest.
val plaintext = "gearslip round trip".toByteArray()
val decrypted = client.decrypt(server.encrypt(plaintext))
if (!decrypted.contentEquals(plaintext)) {
@@ -66,9 +57,15 @@ object TlsSelfTest {
}
}
private fun clientEngine(): SSLEngine {
private fun clientEngine(withCertificate: Boolean): SSLEngine {
val context = SSLContext.getInstance("TLSv1.2")
context.init(null, arrayOf(TrustAll()), SecureRandom())
val keyManagers = if (withCertificate) {
val client = SelfSignedCert.generate(commonName = "Self-test head unit")
KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()).apply {
init(client.keyStore, SelfSignedCert.PASSWORD)
}.keyManagers
} else null
context.init(keyManagers, arrayOf(TrustAll()), SecureRandom())
return context.createSSLEngine().apply {
useClientMode = true
enabledProtocols = arrayOf("TLSv1.2")
@@ -42,6 +42,14 @@ class VideoSource(
private set
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
@Volatile private var running = false
private var frameIndex = 0L
@@ -51,6 +59,8 @@ class VideoSource(
val bitRate = defaultBitRate(width, height, frameRate, level).let {
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 {
setInteger(
MediaFormat.KEY_COLOR_FORMAT,
@@ -128,9 +138,28 @@ class VideoSource(
}.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() {
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() }
inputSurface = null
runCatching { codec?.stop() }
@@ -87,8 +87,17 @@ internal class AacEncoder(private val sampleRate: Int, private val channels: Int
runCatching { codec.release() }
}
private companion object {
const val INPUT_WAIT_US = 10_000L
val FREQUENCIES = listOf(96000, 88200, 64000, 48000, 44100, 32000, 24000, 22050, 16000, 12000, 11025, 8000, 7350)
companion object {
private const val INPUT_WAIT_US = 10_000L
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(),
)
}
}
}
@@ -4,6 +4,8 @@ import app.seb3thehacker.gearslip.GearslipLog
import app.seb3thehacker.gearslip.Protobuf
import app.seb3thehacker.gearslip.ServiceDiscovery
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:
@@ -25,7 +27,10 @@ class AudioLink(
/** Log name: "audio" for the media sink, "guidance" for the directions sink. */
private val tag: String = "audio",
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 onReady: (AudioLink) -> Unit = { CarAudio.attach(it) },
private val onClose: (AudioLink) -> Unit = { CarAudio.detach(it) },
@@ -47,6 +52,10 @@ class AudioLink(
private var bytes = 0L
private var dropped = 0
@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()
/** Only for a sink that takes AAC and nothing else; PCM sinks get the capture as it is. */
@Volatile private var aac: AacEncoder? = null
@@ -58,6 +67,47 @@ class AudioLink(
val channelId get() = service.serviceId
/** Sending audio now, which for media means the car has handed us the speakers. */
val streaming get() = started
@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)")
}
fun opened(status: Int) {
GearslipLog.i("$tag: <- ChannelOpenResponse: status=$status")
if (status != 0) {
@@ -134,6 +184,8 @@ class AudioLink(
}
}
MSG_UNDERFLOW -> onUnderflow()
MSG_ACK -> {
val acked = Wire.varint(Wire.fields(body), 2)?.toInt() ?: 1
synchronized(ackLock) {
@@ -161,6 +213,12 @@ class AudioLink(
fun onFocus(state: Int) {
val name = FOCUS_STATES[state] ?: "unknown($state)"
GearslipLog.i("$tag: <- AudioFocusResponse state=$name")
if (!asksFocus) {
// Directions speak whatever the answer: a lost turn is worse than a loud one.
answerDueBy = 0L
focusAnswer?.countDown()
return
}
val had = focusGranted
focusGranted = state == FOCUS_STATE_GAIN || state == FOCUS_STATE_GAIN_TRANSIENT ||
state == FOCUS_STATE_GAIN_MEDIA_ONLY
@@ -169,6 +227,26 @@ class AudioLink(
if (had && (state == FOCUS_STATE_LOSS || state == FOCUS_STATE_LOSS_TRANSIENT)) onTakenBack()
}
/**
* The car ran out of sound to play (AudioUnderflowNotification, no body): its buffer emptied
* before the next chunk arrived, which the driver hears as a gap. Capture runs in real time, so
* the car's buffer only ever holds what the window lets through. A little silence slipped in
* ahead of the next chunk puts it that far ahead, so the next slow patch is soaked up rather
* than heard. Capped, as each step adds a little delay to the music.
*/
private fun onUnderflow() {
underflows++
val step = if (started && padMs < MAX_PAD_MS) PAD_STEP_MS else 0
padMs += step
pendingPadMs += step
if (underflows <= 3 || underflows % 20 == 0) {
GearslipLog.w("$tag: <- AudioUnderflow ($underflows this drive) - running ${padMs}ms ahead now")
}
}
/** Silence for [ms], whole frames of this sink's format. */
private fun silence(ms: Int): ByteArray = ByteArray(sampleRate * ms / 1000 * channels * 2)
override fun start() {
if (!ready || started) return
if (asksFocus && !focusRequested) {
@@ -181,12 +259,30 @@ class AudioLink(
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() {
synchronized(startLock) { startStreamLocked() }
}
private fun startStreamLocked() {
if (!ready || started) return
sendOnChannel(
MSG_START,
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
lastAckAt = System.currentTimeMillis()
if (aacSink && aac == null) {
@@ -200,6 +296,10 @@ class AudioLink(
}
override fun stop() {
synchronized(startLock) { stopLocked() }
}
private fun stopLocked() {
if (!started) return
started = false
synchronized(ackLock) { ackLock.notifyAll() }
@@ -214,10 +314,23 @@ class AudioLink(
GearslipLog.i("$tag: -> Stop, AudioFocusRequest(RELEASE) after $chunks chunks / $bytes bytes")
chunks = 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) {
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
if (encoder == null) {
send(pcm.copyOf(size))
@@ -286,6 +399,7 @@ class AudioLink(
configTimer?.interrupt()
configTimer = null
stop()
handBackSpeakers()
ready = false
onClose(this)
}
@@ -295,15 +409,21 @@ class AudioLink(
const val MSG_FOCUS_RESPONSE = 19
private const val MSG_CHANNEL_OPEN_RESPONSE = 8
private const val MSG_DATA = 0
private const val MSG_CODEC_CONFIG = 1
private const val MSG_SETUP = 32768
private const val MSG_START = 32769
private const val MSG_STOP = 32770
private const val MSG_CONFIG = 32771
private const val MSG_ACK = 32772
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 FOCUS_GAIN = 1
private const val FOCUS_GAIN_TRANSIENT_MAY_DUCK = 3
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_TRANSIENT = 2
private const val FOCUS_STATE_GAIN_MEDIA_ONLY = 6
@@ -44,6 +44,9 @@ object CarAudio {
@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
/** Rate and channel count the capture should use: the car's, or a sensible default. */
val sampleRate get() = link?.sampleRate ?: 48_000
val channels get() = link?.channels ?: 2
@@ -71,6 +74,7 @@ object CarAudio {
when (_capture.value) {
Capture.ON, Capture.ASKING, Capture.DENIED -> return
Capture.OFF -> if (link == null) {
openSink?.invoke()
GearslipLog.i("audio: waiting for the car to offer an audio sink")
} else {
ask()
@@ -23,19 +23,19 @@ data class Caller(val number: String, val name: String?) {
sealed interface CallUiState {
data object None : 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,
* 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
* cannot hold, so a call taken here is finished the way it would be without Gearslip at all -
* 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.
* Call audio stays on the phone's normal voice path; nothing here sends it over the USB link,
* which only ever carries media playback.
*/
object CarCalls {
@@ -47,9 +47,14 @@ object CarCalls {
private var pendingRespond: ((allow: Boolean) -> Unit)? = 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) {
if (watcher != null) 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 callback = object : TelephonyCallback(), TelephonyCallback.CallStateListener {
override fun onCallStateChanged(state: Int) = onPhoneState(state)
@@ -66,6 +71,7 @@ object CarCalls {
watcher = null
pendingRespond = null
ringingCaller = null
unmute()
_state.value = CallUiState.None
}
@@ -74,11 +80,15 @@ object CarCalls {
TelephonyManager.CALL_STATE_IDLE -> {
pendingRespond = null
ringingCaller = null
// A mute left on would silence the microphone for everything after the call.
unmute()
_state.value = CallUiState.None
}
TelephonyManager.CALL_STATE_OFFHOOK -> {
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 -> {
// 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}") }
}
/** 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) {
if (context.checkSelfPermission(Manifest.permission.ANSWER_PHONE_CALLS) != PackageManager.PERMISSION_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")
}
}
@@ -95,7 +95,12 @@ object AssistantCommands {
heard: String,
): String? {
// "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
Regex("^(?:use|using|with|open|in|on) (.+?) (?:to |and )(.+)$").find(text)?.let { m ->
if (after(m.groupValues[2], NAVIGATE_TRIGGERS) != null) {
@@ -135,6 +140,20 @@ object AssistantCommands {
val (place, nearby) = plainPlace(destination)
destination = place
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 {
navApps.size == 1 -> navApps.first()
@@ -144,6 +163,7 @@ object AssistantCommands {
CarServices.connectNav(template, frame, destination)
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}."
}
@@ -98,7 +98,7 @@ object BluetoothAudioCheck {
if (!armed) return
val context = appContext ?: return
if (CarSettings.pipeAudio.value) {
set(false, "USB audio is on")
set(false, "audio goes through Gearslip")
return
}
if (!CarSettings.bluetoothWarning.value) {
@@ -1,5 +1,7 @@
package app.seb3thehacker.gearslip.car
import androidx.compose.ui.res.stringResource
import app.seb3thehacker.gearslip.R
import app.seb3thehacker.gearslip.car.theme.*
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@@ -45,32 +47,28 @@ internal fun BluetoothPairOverlay() {
val locationOff by LocationCheck.off.collectAsState()
val locationDismissed by LocationCheck.carDismissed.collectAsState()
if (missing && bluetoothAudio && warn && !dismissed) PairCard(
title = "Bluetooth isn't connected",
body = "Music plays over Bluetooth, but your phone isn't connected to the car. " +
"Connect it from the car's Bluetooth menu or your phone's.",
secondary = "Don't show again" to BluetoothAudioCheck::neverAgain,
primary = "OK" to BluetoothAudioCheck::dismissOnCar,
title = stringResource(R.string.home_bt_title),
body = stringResource(R.string.overlay_bt_body),
secondary = stringResource(R.string.overlay_dont_show) to BluetoothAudioCheck::neverAgain,
primary = stringResource(R.string.overlay_ok) to BluetoothAudioCheck::dismissOnCar,
) else if (locationOff && !locationDismissed) PairCard(
title = "Location is off",
body = "Map apps can't show where you are or give directions. Turn on location " +
"in your phone's quick settings when you're parked.",
secondary = "Don't show again" to LocationCheck::neverAgain,
primary = "OK" to LocationCheck::dismissOnCar,
title = stringResource(R.string.overlay_location_off),
body = stringResource(R.string.overlay_location_body),
secondary = stringResource(R.string.overlay_dont_show) to LocationCheck::neverAgain,
primary = stringResource(R.string.overlay_ok) to LocationCheck::dismissOnCar,
)
}
CarBluetooth.Prompt.ASK -> PairCard(
title = "Pair with this car?",
body = (if (bluetoothAudio) "Calls and music play" else "Calls play") +
" through the car's speakers once your phone and the car are paired. Your phone " +
"then asks you to confirm a code.",
secondary = "Not now" to CarBluetooth::notNow,
primary = "Pair" to CarBluetooth::pair,
title = stringResource(R.string.overlay_pair_title),
body = stringResource(if (bluetoothAudio) R.string.overlay_pair_body_music else R.string.overlay_pair_body),
secondary = stringResource(R.string.worked_not_now) to CarBluetooth::notNow,
primary = stringResource(R.string.overlay_pair) to CarBluetooth::pair,
)
CarBluetooth.Prompt.PAIRING -> PairCard(
title = "Confirm on your phone",
body = "Check the code on your phone and tap Pair. The car may ask too.",
title = stringResource(R.string.overlay_confirm_title),
body = stringResource(R.string.overlay_confirm_body),
secondary = null,
primary = "Hide" to CarBluetooth::hide,
primary = stringResource(R.string.carset_hide) to CarBluetooth::hide,
)
}
}
@@ -1,5 +1,7 @@
package app.seb3thehacker.gearslip.car
import androidx.compose.ui.res.stringResource
import app.seb3thehacker.gearslip.R
import androidx.compose.foundation.shape.CircleShape
import app.seb3thehacker.gearslip.car.theme.*
import androidx.compose.foundation.layout.Arrangement
@@ -33,21 +35,17 @@ import app.seb3thehacker.gearslip.call.CallUiState
import app.seb3thehacker.gearslip.call.CarCalls
/**
* Sits above whatever the car is showing while a call is ringing or under way. Ringing takes the
* whole screen, because nothing else matters more right now; an active call is a thin band along
* the top, since from here the driver can only see who it is, not end it - see [CarCalls].
* Takes the whole car screen while a call is ringing, because nothing else matters more right
* now. A call under way moves into the nav bar instead (see NavCallPill), so it covers nothing.
*/
@Composable
fun CallOverlay(modifier: Modifier = Modifier) {
fun CallOverlay() {
val state by CarCalls.state.collectAsState()
val context = LocalContext.current.applicationContext
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.Active -> ActiveCallBanner(s, modifier)
CallUiState.None -> {}
else -> {}
}
}
@@ -67,7 +65,7 @@ private fun IncomingCall(s: CallUiState.Ringing, onAnswer: () -> Unit, onDecline
)
Spacer(Modifier.height(16.dp))
Text(
"Incoming call",
stringResource(R.string.call_incoming),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -80,13 +78,13 @@ private fun IncomingCall(s: CallUiState.Ringing, onAnswer: () -> Unit, onDecline
)
Spacer(Modifier.height(56.dp))
Row(horizontalArrangement = Arrangement.spacedBy(48.dp)) {
CallButton(Icons.Filled.Close, "Decline", MaterialTheme.colorScheme.error, enabled = s.canDecline, onClick = onDecline)
CallButton(Icons.Filled.Call, "Answer", MaterialTheme.colorScheme.primary, enabled = true, onClick = onAnswer)
CallButton(Icons.Filled.Close, stringResource(R.string.call_decline), MaterialTheme.colorScheme.error, enabled = s.canDecline, onClick = onDecline)
CallButton(Icons.Filled.Call, stringResource(R.string.call_answer), MaterialTheme.colorScheme.primary, enabled = true, onClick = onAnswer)
}
if (!s.canDecline) {
Spacer(Modifier.height(20.dp))
Text(
"Already ringing out loud - answer here, or let it ring.",
stringResource(R.string.call_ringing),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -110,25 +108,3 @@ private fun CallButton(icon: ImageVector, label: String, color: Color, enabled:
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,
)
}
}
}
@@ -1,5 +1,7 @@
package app.seb3thehacker.gearslip.car
import androidx.compose.ui.res.stringResource
import app.seb3thehacker.gearslip.R
import app.seb3thehacker.gearslip.car.theme.*
import android.view.SurfaceHolder
import android.view.SurfaceView
@@ -11,11 +13,14 @@ import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.util.VelocityTracker
import kotlin.math.hypot
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.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.height
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
@@ -34,8 +39,11 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
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.unit.dp
import androidx.compose.material.icons.filled.Close
import androidx.compose.ui.viewinterop.AndroidView
import app.seb3thehacker.gearslip.GearslipLog
import app.seb3thehacker.gearslip.host.CarAppConnection
@@ -59,13 +67,40 @@ fun CarAppStage(
) {
val template by connection.template.collectAsStateWithLifecycle()
val cover = remember { ChromeCover() }
val navBar = LocalNavBarInset.current
Box(Modifier.fillMaxSize()) {
// The app's own drawing goes underneath; everything else is the host's rendering of
// the template it described, laid over the top.
if (status.surfaceRequested) {
AppSurface(connection, frame, template, Modifier.fillMaxSize())
androidx.compose.runtime.CompositionLocalProvider(LocalChromeCover provides cover, LocalCarAppConnection provides connection) {
if (status.surfaceRequested) {
AppSurface(connection, frame, template, Modifier.fillMaxSize())
}
// The docked player floats over one side of the map: the chrome keeps clear of it,
// and the app hears that side is covered, so its map centres in the rest.
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))
}
}
@@ -84,14 +119,29 @@ private fun AppSurface(
modifier: Modifier = Modifier,
) {
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
// visible area even though the surface itself hasn't changed.
LaunchedEffect(template, size) {
val (width, height) = size
// The app lays its map out around whatever the host covers, so a template change or a card
// changing size moves the visible area even though the surface itself hasn't changed.
val (width, height) = size
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) }
LaunchedEffect(template, size, measured, measuredStable, cardsAtEnd) {
if (width > 0 && height > 0) {
val visible = visibleAreaFor(template, width, height)
connection.reportAreas(visible = visible, stable = visible)
val visible = visibleAreaFor(template, width, height, measured, cardsAtEnd)
// Waze seems to centre its car in the stable area rather than the visible one, so it
// gets the cards taken out of that too. Test: see whether its car moves out from under them.
val stable = if (!isContentTemplate(template) && connection.appPackage != "com.waze") {
visibleAreaFor(template, width, height, measuredStable, cardsAtEnd)
} else {
visible
}
connection.reportAreas(visible = visible, stable = stable)
}
}
@@ -101,7 +151,9 @@ private fun AppSurface(
// 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
// 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()
androidx.compose.runtime.key(constraints.maxWidth, constraints.maxHeight, epoch) {
SurfaceHost(connection, frame, onSize = { size = it }, Modifier.fillMaxSize())
@@ -189,28 +241,31 @@ private fun ChromeBar(
onDisconnect: () -> Unit,
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(
color = Color.Black.copy(alpha = 0.55f),
contentColor = Color.White,
modifier = modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surfaceContainer,
contentColor = MaterialTheme.colorScheme.onSurface,
modifier = modifier.fillMaxWidth().height(CHROME_BAR),
) {
Row(
Modifier.padding(horizontal = 16.dp, vertical = 6.dp),
Modifier.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text(
status.app ?: "Car app",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
val detail = buildList {
status.template?.let { add(it) }
add(if (status.surfaceAttached) "drawing" else "waiting for the app")
}.joinToString(" - ")
Text(detail, style = MaterialTheme.typography.bodySmall)
}
GsButton(onClick = onDisconnect, tone = GsTone.Tonal) { Text("Stop") }
Text(
status.app ?: stringResource(R.string.stage_car_app),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.weight(1f),
)
GsIconButton(
androidx.compose.material.icons.Icons.Filled.Close,
status.app?.let { stringResource(R.string.stage_close, it) } ?: stringResource(R.string.stage_close_app),
onDisconnect,
size = 36.dp,
iconSize = 20.dp,
shape = androidx.compose.foundation.shape.CircleShape,
)
}
}
}
@@ -220,3 +275,6 @@ private val CHROME_BAR = 44.dp
/** 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
/** 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,5 +1,7 @@
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.filled.Call
import androidx.compose.material.icons.filled.Search
@@ -13,7 +15,9 @@ import app.seb3thehacker.gearslip.ProjectedContent
/** One tile in the launcher and the screen it opens. */
class CarApp(
val id: String,
/** English, for matching what a driver says; [labelRes] is what the screen shows. */
val label: String,
@param:StringRes val labelRes: Int,
val icon: ImageVector,
val content: @Composable () -> Unit,
)
@@ -22,16 +26,16 @@ class CarApp(
object CarApps {
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))
},
CarApp("phone", "Screen sharing", Icons.Filled.Share) {
CarApp("phone", "Screen sharing", R.string.app_screen_sharing, Icons.Filled.Share) {
PhoneAppsScreen()
},
CarApp("dialer", "Phone", Icons.Filled.Call) {
CarApp("dialer", "Phone", R.string.carset_phone, Icons.Filled.Call) {
PhoneDialerScreen()
},
CarApp("settings", "Settings", Icons.Filled.Settings) {
CarApp("settings", "Settings", R.string.settings_title, Icons.Filled.Settings) {
CarSettingsScreen()
},
)
@@ -1,5 +1,7 @@
package app.seb3thehacker.gearslip.car
import androidx.annotation.StringRes
import app.seb3thehacker.gearslip.R
import android.Manifest
import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter
@@ -111,7 +113,7 @@ object CarBluetooth {
// 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({
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)
}
@@ -168,7 +170,7 @@ object CarBluetooth {
bondStarted = true
val started = runCatching { device.createBond() }.getOrDefault(false)
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) {
@@ -184,8 +186,8 @@ object CarBluetooth {
log.i("bond state $previous -> $state")
when {
state == BluetoothDevice.BOND_BONDING -> bondStarted = true
state == BluetoothDevice.BOND_BONDED -> finish("Paired. Calls now play through the car.")
previous == BluetoothDevice.BOND_BONDING -> finish("Pairing didn't finish. Pair from the car's Bluetooth menu instead.")
state == BluetoothDevice.BOND_BONDED -> finish(R.string.bt_paired)
previous == BluetoothDevice.BOND_BONDING -> finish(R.string.bt_pair_unfinished)
}
}
}
@@ -193,12 +195,12 @@ object CarBluetooth {
receiver = r
}
private fun finish(message: String) {
private fun finish(@StringRes message: Int) {
pairing = false
stopWatching()
main.removeCallbacksAndMessages(null)
_prompt.value = Prompt.NONE
CarToasts.show(this, message, CarToast.LENGTH_LONG)
CarToasts.show(this, appContext?.getString(message) ?: return, CarToast.LENGTH_LONG)
}
private fun stopWatching() {
@@ -1,5 +1,7 @@
package app.seb3thehacker.gearslip.car
import androidx.compose.ui.res.stringResource
import app.seb3thehacker.gearslip.R
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
@@ -60,7 +62,7 @@ fun CarDashboardScreen() {
.padding(horizontal = 24.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
ScreenHeader("Today")
ScreenHeader(stringResource(R.string.dash_today))
WeatherCard(context)
AgendaCard(context)
NotificationsSection()
@@ -84,9 +86,9 @@ private fun WeatherCard(context: Context) {
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
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(
s.reason, style = MaterialTheme.typography.bodyLarge,
stringResource(s.reason), style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
is WeatherState.Ready -> {
@@ -94,7 +96,7 @@ private fun WeatherCard(context: Context) {
WeatherIcon(w.code, w.isDay, size = 44.dp)
Column {
Text(
"${w.temp}${w.tempUnit} · ${Weather.describe(w.code)}",
"${w.temp}${w.tempUnit} · ${Weather.condition(w.code)}",
style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold,
)
Text(w.place, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
@@ -112,12 +114,12 @@ private fun AgendaCard(context: Context) {
}
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
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
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(
"Nothing in the next week.",
stringResource(R.string.dash_nothing_week),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -135,7 +137,7 @@ private fun EventRow(event: AgendaEvent) {
verticalAlignment = Alignment.CenterVertically,
) {
Text(
eventWhen(event),
eventWhen(event, stringResource(R.string.dash_all_day)),
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
)
@@ -149,14 +151,14 @@ private fun EventRow(event: AgendaEvent) {
}
}
/** "Today" events show just a time; later days get a weekday so a week's list stays readable. */
private fun eventWhen(event: AgendaEvent): String {
/** 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, allDay: String): String {
val zone = if (event.allDay) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
val start = Calendar.getInstance(zone).apply { timeInMillis = event.startMillis }
val today = Calendar.getInstance()
val sameDay = start.get(Calendar.YEAR) == today.get(Calendar.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
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
}
@@ -64,7 +64,9 @@ object CarEnvironment {
}
@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 val handler = Handler(Looper.getMainLooper())
@@ -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
import androidx.compose.ui.res.stringResource
import app.seb3thehacker.gearslip.car.theme.*
import androidx.compose.foundation.layout.Arrangement
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.padding
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.filled.Close
import androidx.compose.material.icons.filled.PlayArrow
@@ -64,7 +67,9 @@ import app.seb3thehacker.gearslip.media.NowPlaying
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 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
}
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
// when the player is sharing the screen with the map.
Row(
Modifier.fillMaxSize(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Box(
Modifier
.weight(if (splitScreen) 2.1f else 1f)
.fillMaxHeight(),
// The map runs edge to edge underneath; the player floats over one side of it as a card, with
// a gap all round. The map's surface keeps its size when the card comes and goes, so the app
// only re-centres its map instead of drawing onto a new surface.
BoxWithConstraints(Modifier.fillMaxSize()) {
val column = (maxWidth / PLAYER_SHARE).coerceIn(PLAYER_MIN_WIDTH, PLAYER_MAX_WIDTH)
androidx.compose.runtime.CompositionLocalProvider(
LocalCardsAtEnd provides (splitScreen && playerLeft),
LocalSidePanelInset provides if (splitScreen) column else 0.dp,
) {
MapPane(navStatus, frame)
}
if (splitScreen) {
val side = Modifier.weight(1f).fillMaxHeight()
if (panel == SidePanel.LYRICS) SideLyrics(now, side) else DockedPlayer(now, side)
androidx.compose.animation.AnimatedVisibility(
splitScreen,
Modifier.align(if (playerLeft) Alignment.CenterStart else Alignment.CenterEnd),
enter = androidx.compose.animation.slideInHorizontally(tween(220)) { if (playerLeft) -it / 3 else it / 3 } +
androidx.compose.animation.fadeIn(tween(180)),
exit = androidx.compose.animation.slideOutHorizontally(tween(180)) { if (playerLeft) -it / 3 else it / 3 } +
androidx.compose.animation.fadeOut(tween(140)),
) {
val card = Modifier
.width(column)
.fillMaxHeight()
.padding(
start = if (playerLeft) CARD_GAP else 0.dp,
end = if (playerLeft) 0.dp else CARD_GAP,
top = CARD_GAP,
bottom = LocalNavBarInset.current + CARD_GAP,
)
if (shown.value == SidePanel.LYRICS) SideLyrics(now, card) else DockedPlayer(now, card)
}
}
}
/** The player takes one part in this many of the screen's width. */
private const val PLAYER_SHARE = 3.6f
/** Narrow enough to leave the map most of a small screen; on a wide one the map gets the extra room. */
private val PLAYER_MIN_WIDTH = 240.dp
private val PLAYER_MAX_WIDTH = 340.dp
/** Between the player card and the screen's edges, and the map's own cards beside it. */
private val CARD_GAP = 12.dp
/** The card's corners: rounder than a button's, as the map app's own cards are. */
private val CARD_SHAPE = RoundedCornerShape(24.dp)
@Composable
private fun MapPane(status: CarAppConnection.Status, frame: CarEnvironment.Frame) {
val navigator = LocalCarNavigator.current
@@ -123,8 +158,11 @@ private fun MapPane(status: CarAppConnection.Status, frame: CarEnvironment.Frame
}
Column(
Modifier.fillMaxSize().padding(24.dp),
verticalArrangement = Arrangement.Center,
Modifier.fillMaxSize().padding(
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,
) {
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()
when (status.phase) {
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 -> {
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) }
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) {
// 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.
Text("No mapping apps found.", style = MaterialTheme.typography.titleMedium)
Text(stringResource(R.string.home_no_maps), style = MaterialTheme.typography.titleMedium)
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,
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
// 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.
Text("Map stopped.", style = MaterialTheme.typography.titleMedium)
GsButton(onClick = { scope.launch { CarServices.reconnectNav(frame) } }, tone = GsTone.Tonal) { Text("Reopen") }
GsButton(onClick = { navigator.apps() }, tone = GsTone.Tonal) { Text("Car apps") }
Text(stringResource(R.string.home_map_stopped), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
Text(
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 {
// Autostart is still picking one - a navigation app is installed, so this resolves
// 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
* and lyrics. While it's here the nav bar's pill steps aside on Home, so the controls never show
* The player, docked beside the map: the art as large as it fits, then title, progress, transport,
* 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.
*/
@Composable
@@ -175,28 +231,31 @@ private fun DockedPlayer(now: NowPlaying, modifier: Modifier) {
val scheme = MaterialTheme.colorScheme
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)) {
BoxWithConstraints(Modifier.fillMaxSize().padding(12.dp)) {
// Whatever height the controls below leave over, so the art grows on a taller
// screen and shrinks rather than pushing the buttons off a short one.
val artSize = minOf(maxWidth - 44.dp, maxHeight - 250.dp).coerceIn(56.dp, 200.dp)
// The theme's surface, with a faint wash of the cover's colours behind the top of it. A solid
// slab of album colour beside the map fought it for attention; this only hints at the song.
Surface(modifier = modifier.gsPanelShadow(CARD_SHAPE), shape = CARD_SHAPE, color = scheme.surfaceContainer) {
AlbumGlow(art, Modifier.fillMaxSize())
Box(Modifier.fillMaxSize().padding(12.dp)) {
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.
val coverShape = RoundedCornerShape(16.dp)
Box(
Modifier
.align(Alignment.TopCenter)
.size(artSize)
.clip(RoundedCornerShape(12.dp))
.gsPanelShadow(coverShape)
.clip(coverShape)
.background(scheme.surface)
.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))
Text(
@@ -212,46 +271,53 @@ private fun DockedPlayer(now: NowPlaying, modifier: Modifier) {
} else {
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))
PlayModeRow(now, media, size = 42.dp, modifier = Modifier.fillMaxWidth()) {
PlayModeRow(now, media, size = 50.dp, modifier = Modifier.fillMaxWidth()) {
GsIconButton(
ImageVector.vectorResource(R.drawable.lyrics_24), "Lyrics", navigator::showSideLyrics,
size = 42.dp, iconSize = 22.dp,
ImageVector.vectorResource(R.drawable.lyrics_24), stringResource(R.string.storage_lyrics), navigator::showSideLyrics,
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
* larger title and artist instead. The player goes back to the nav bar meanwhile, whose dock
* button swaps these back out for the controls.
* Lyrics alone beside the map, big enough to read at a glance. The header is slim, since the
* player goes back to the nav bar meanwhile and names the song there. Its note button swaps the
* lyrics back for the player; the X closes the panel.
*/
@Composable
private fun SideLyrics(now: NowPlaying, modifier: Modifier) {
val navigator = LocalCarNavigator.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) }
Surface(modifier = modifier, color = rememberArtColor(art, MaterialTheme.colorScheme.surfaceVariant)) {
Column(Modifier.fillMaxSize().padding(16.dp)) {
Row(verticalAlignment = Alignment.Top) {
Column(Modifier.weight(1f)) {
Text(
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,
)
Surface(modifier = modifier.gsPanelShadow(CARD_SHAPE), shape = CARD_SHAPE, color = scheme.surfaceContainer) {
Column(Modifier.fillMaxSize().padding(start = 18.dp, end = 12.dp, top = 12.dp, bottom = 8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.size(36.dp).clip(RoundedCornerShape(8.dp)).background(scheme.surface)) {
art?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) }
}
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)
}
}
@@ -1,5 +1,7 @@
package app.seb3thehacker.gearslip.car
import androidx.compose.ui.res.stringResource
import app.seb3thehacker.gearslip.R
import app.seb3thehacker.gearslip.car.theme.*
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -44,6 +46,12 @@ object CarKeyboardVisibility {
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.
*
@@ -73,10 +81,11 @@ fun CarKeyboard(
) {
var symbols by remember { mutableStateOf(false) }
var shifted by remember { mutableStateOf(false) }
val preview = LocalCarPreview.current
DisposableEffect(Unit) {
CarKeyboardVisibility.state.value = true
onDispose { CarKeyboardVisibility.state.value = false }
DisposableEffect(preview) {
if (!preview) CarKeyboardVisibility.state.value = true
onDispose { if (!preview) CarKeyboardVisibility.state.value = false }
}
// 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) } }
LaunchedEffect(text) { PhoneTyping.carText(phoneField, text) }
if (preview) return
val rows = if (symbols) SYMBOL_ROWS else LETTER_ROWS
val keyHeight = keyHeight(LocalConfiguration.current.screenHeightDp)
@@ -145,7 +155,7 @@ fun CarKeyboard(
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
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) {
DismissKey(onDismiss, Modifier.weight(1.4f))
}
@@ -192,7 +202,7 @@ private fun DismissKey(onClick: () -> Unit, modifier: Modifier = Modifier) {
horizontalArrangement = Arrangement.Center,
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(
image ?: if (search) Icons.Filled.Search else Icons.Filled.ArrowBack,
contentDescription = "Submit",
contentDescription = stringResource(R.string.keyboard_submit),
)
}
}
@@ -1,5 +1,7 @@
package app.seb3thehacker.gearslip.car
import androidx.compose.ui.res.stringResource
import app.seb3thehacker.gearslip.R
import app.seb3thehacker.gearslip.car.theme.*
import androidx.compose.foundation.Image
import androidx.compose.foundation.ExperimentalFoundationApi
@@ -119,35 +121,62 @@ internal object LauncherCache {
context.packageManager.getApplicationIcon(pkg).toBitmap(128, 128).asImageBitmap()
}.getOrNull()
val navApps = CarAppCatalog.installed(context)
val mediaApps = MediaCatalog.installed(context)
// Some apps (Spotify among them) ship both a Car App Library template and a legacy
// 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 carApps = CarAppCatalog.installed(context).associateBy { it.component.packageName }
val mediaApps = MediaCatalog.installed(context).associateBy { it.component.packageName }
val messagingApps = MessagingCatalog.installed(context).associateBy { it.packageName }
val messagingApps = MessagingCatalog.installed(context)
// Google Messages ships its own car app, which won't start under Gearslip. Gearslip's
// own Messages screen does the same job, so a messenger gets only that tile.
val messagingPackages = messagingApps.map { it.packageName }.toSet()
val nav = navApps
.filter { it.component.packageName !in messagingPackages }
// An app whose template screen is rejected but whose player works through Gearslip
// (Spotify) shows only its player tile - a "· Browse" tile beside it would just report
// 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)
// One decision per app over everything it offers a car - a car screen, a player, car
// notifications - rather than a filter per kind that has to know about the others.
val tiles = (carApps.keys + mediaApps.keys + messagingApps.keys).flatMap { pkg ->
val icon by lazy { iconOf(pkg) }
tilesFor(carApps[pkg], mediaApps[pkg], messagingApps[pkg], context.getString(R.string.media_browse)).map { tile ->
when (tile) {
is AppTile.Screen -> Entry(tile.label, icon, template = tile.app)
is AppTile.Player -> Entry(tile.app.label, icon, media = tile.app)
is AppTile.Messages -> Entry(tile.app.label, icon, messaging = tile.app)
}
}
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.
val messaging = messagingApps
.filter { it.packageName !in mediaPackages }
.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))
}
}
@@ -254,7 +283,7 @@ fun CarLauncher() {
val pkg = entry.packageName?.takeIf { entry.messaging == null }
val id = entry.componentId
return Tile(
entry.label, entry.icon, entry.builtIn?.glyph,
entry.builtIn?.let { context.getString(it.labelRes) } ?: entry.label, entry.icon, entry.builtIn?.glyph,
broken = pkg != null && (KnownApps.isBroken(context, pkg) || (entry.template != null && KnownApps.screenBroken(pkg))),
onLongClick = id?.let { { navigator.showAppMenu(it) } },
) { openApp(entry, id?.let { running[it] }, navigator, frame) }
@@ -281,7 +310,7 @@ private fun BrokenBadge(size: androidx.compose.ui.unit.Dp, modifier: Modifier =
modifier.size(size).background(Color(0xFFD32F2F), CircleShape),
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))
}
}
@@ -0,0 +1,680 @@
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.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
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() }
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.
val turn = rememberNextTurn().takeIf { 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) { 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. */
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,
)
}
}
}
}
/** 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
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.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
@@ -41,7 +45,11 @@ sealed interface CarScreen {
enum class SidePanel { NONE, CONTROLS, LYRICS }
/** 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
@@ -57,6 +65,7 @@ class CarNavigator(startId: String) {
Crumb(
if (CarApps.find(startId) != null) CarScreen.App(startId) else CarScreen.Home,
"Home",
R.string.crumb_home,
),
)
@@ -69,15 +78,15 @@ class CarNavigator(startId: String) {
/** Pushes a new step, unless it's the same screen already on top - tapping the button for
* 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
stack.add(Crumb(screen, label))
stack.add(Crumb(screen, label, labelRes))
}
/** The one way back to a clean slate: resets the whole trail to just Home. */
fun home() {
stack.clear()
stack.add(Crumb(CarScreen.Home, "Home"))
stack.add(Crumb(CarScreen.Home, "Home", R.string.crumb_home))
}
/**
@@ -90,8 +99,9 @@ class CarNavigator(startId: String) {
private set
/**
* The pill's dock button: moves the player beside the map, going Home first if need be. With
* lyrics already there it swaps them for the controls; with the controls there it closes.
* The pill's dock button, an open/close switch for the panel: moves the player beside the map,
* 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() {
if (current != CarScreen.Home) {
@@ -99,9 +109,11 @@ class CarNavigator(startId: String) {
home()
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 closeSidePanel() { sidePanel = SidePanel.NONE }
@@ -114,17 +126,17 @@ class CarNavigator(startId: String) {
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 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") }
@@ -132,10 +144,10 @@ class CarNavigator(startId: String) {
/** The list lives on the dashboard; only a reply gets a screen of its own. */
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
* 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.StateFlow
/**
* 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.
*/
/** Raw sensor readings, keyed by SensorBatch field number, which matches the sensor type id. */
object CarSensors {
data class Reading(val typeId: Int, val typeName: String, val raw: String, val updatedAtMs: Long)
@@ -30,16 +21,10 @@ object CarSensors {
}
}
/**
* 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.
*/
/** Each SensorBatch field contains repeated readings of one sensor type. */
fun onEvent(body: ByteArray) {
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 updated = byType.mapValues { (number, group) ->
Reading(
@@ -61,10 +46,7 @@ object CarSensors {
}
}
/**
* Sensor type ids as named across public Android Auto reverse-engineering references. Treated as
* a label, not a fact - see [CarSensors] doc.
*/
/** Sensor type values shared by the discovery service and SensorBatch. */
object SensorType {
private val NAMES = mapOf(
1 to "Location (GPS)",
@@ -89,6 +71,10 @@ object SensorType {
20 to "Gyroscope",
21 to "GPS satellites",
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"
@@ -60,6 +60,13 @@ object CarServices {
private var navStopped = false
private var autoplayPending = false
/**
* The app that was playing when the driver opened another one. Opening an app is only a look
* at its library: until something plays there, the music carries on in this one, so leaving
* the new app's screen goes back to it, as Android Auto does.
*/
private var stillPlaying: MediaApp? = null
fun init(appContext: Context) {
context = appContext.applicationContext
app.seb3thehacker.gearslip.notify.CarMotion.start(appContext)
@@ -130,6 +137,8 @@ object CarServices {
val live = phase == CarMedia.Phase.READY || phase == CarMedia.Phase.CONNECTING
if (_mediaApp.value?.component == app.component && live) return
val previous = _mediaApp.value
stillPlaying = previous?.takeIf { it.component != app.component && media.now.value.playing && !autoplay }
_mediaApp.value = app
autoplayPending = autoplay
media.connect(app)
@@ -151,12 +160,26 @@ object CarServices {
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. */
fun closeMedia() {
if (media.now.value.playing) media.togglePlay()
media.disconnect()
_mediaApp.value = null
autoplayPending = false
stillPlaying = null
}
/** Called once the media app is connected: starts playback if that was asked for. */
@@ -1,5 +1,6 @@
package app.seb3thehacker.gearslip.car
import app.seb3thehacker.gearslip.car.theme.DarkStyle
import android.content.Context
import app.seb3thehacker.gearslip.AppSettings
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). */
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
* button is tapped, and persisted through [AppSettings] so they survive the next connection.
@@ -36,15 +64,26 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
private const val KEY_LAST_MEDIA = "car_last_media"
private const val KEY_AUTOPLAY = "car_autoplay"
private const val KEY_PLAYER_TITLE = "car_player_title"
private const val KEY_PLAYER_LEFT = "car_player_left"
private const val KEY_MORE_LYRICS = "car_more_lyrics"
private const val KEY_THEME = "car_app_theme"
private const val KEY_UI_THEME = "car_ui_theme"
private const val KEY_DARK_STYLE = "car_dark_style"
private const val KEY_PINNED = "car_pinned_apps"
private const val KEY_APP_ORDER = "car_app_order"
private const val KEY_EXPERIMENTAL_FEATURES = "car_experimental_features"
private const val KEY_SYSTEM_SPEECH = "car_system_speech"
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_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_SEEN = "car_whats_new_seen_code"
@@ -68,8 +107,14 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
private val lastMediaFlow = MutableStateFlow<String?>(null)
private val autoplayFlow = MutableStateFlow(false)
private val playerTitleFlow = MutableStateFlow(false)
private val playerLeftFlow = MutableStateFlow(false)
private val moreLyricsFlow = MutableStateFlow(true)
private val themeFlow = MutableStateFlow(AppTheme.PHONE)
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)
/** Id of the [GsTheme] controls are drawn in; an unknown id falls back to the default. */
@@ -79,11 +124,51 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
private val experimentalFeaturesFlow = MutableStateFlow(false)
private val systemSpeechFlow = 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 unitsFlow = MutableStateFlow(Units.AUTO)
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 whatsNewSeenFlow = MutableStateFlow(0)
@@ -146,6 +231,12 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
*/
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
/** Lyrics from BetterLyrics and KuGou as well as LRCLIB; off sends song details to LRCLIB only. */
val moreLyrics: StateFlow<Boolean> = moreLyricsFlow
val scale: StateFlow<Float> = scaleFlow
val openOnConnect: StateFlow<String> = openFlow
val nightMode: StateFlow<NightMode> = nightFlow
@@ -198,6 +289,27 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
*/
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
* once, from this same install. Separate from the phone side's own compat/safety dialogs -
@@ -220,9 +332,14 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
themeFlow.value = runCatching {
AppTheme.valueOf(AppSettings.getString(app, KEY_THEME, AppTheme.PHONE.name))
}.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)
autoplayFlow.value = AppSettings.getString(app, KEY_AUTOPLAY, "false") == "true"
playerTitleFlow.value = AppSettings.getString(app, KEY_PLAYER_TITLE, "false") == "true"
playerLeftFlow.value = AppSettings.getString(app, KEY_PLAYER_LEFT, "false") == "true"
moreLyricsFlow.value = AppSettings.getString(app, KEY_MORE_LYRICS, "true") == "true"
pipeAudioFlow.value = AppSettings.getString(app, KEY_PIPE_AUDIO, "true") == "true"
// Until the driver sets it, on for USB audio and off for Bluetooth, where the map app's
// own voice already reaches the car.
@@ -239,10 +356,24 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
experimentalFeaturesFlow.value = AppSettings.getString(app, KEY_EXPERIMENTAL_FEATURES, "false") == "true"
systemSpeechFlow.value = AppSettings.getString(app, KEY_SYSTEM_SPEECH, "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"
unitsFlow.value = runCatching {
Units.valueOf(AppSettings.getString(app, KEY_UNITS, Units.AUTO.name))
}.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"
whatsNewSeenFlow.value = AppSettings.getString(app, KEY_WHATS_NEW_SEEN, "0").toIntOrNull() ?: 0
}
@@ -272,6 +403,11 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
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) {
uiThemeFlow.value = id
AppSettings.putString(app, KEY_UI_THEME, id)
@@ -282,6 +418,16 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
AppSettings.putString(app, KEY_PLAYER_TITLE, on.toString())
}
fun setPlayerLeft(on: Boolean) {
playerLeftFlow.value = on
AppSettings.putString(app, KEY_PLAYER_LEFT, on.toString())
}
fun setMoreLyrics(on: Boolean) {
moreLyricsFlow.value = on
AppSettings.putString(app, KEY_MORE_LYRICS, on.toString())
}
fun setAutoplay(on: Boolean) {
autoplayFlow.value = on
AppSettings.putString(app, KEY_AUTOPLAY, on.toString())
@@ -325,6 +471,16 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
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) {
carSensorsFlow.value = on
AppSettings.putString(app, KEY_CAR_SENSORS, on.toString())
@@ -1,16 +1,25 @@
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 android.os.Build
import androidx.compose.foundation.layout.Arrangement
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.fillMaxHeight
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.graphics.Color
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
@@ -51,6 +60,9 @@ fun CarSettingsScreen() {
val open by CarSettings.openOnConnect.collectAsState()
val night by CarSettings.nightMode.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 pipeAudio by CarSettings.pipeAudio.collectAsState()
val speakDirections by CarSettings.speakDirections.collectAsState()
@@ -58,10 +70,15 @@ fun CarSettingsScreen() {
val locationWarning by CarSettings.locationWarning.collectAsState()
val autoplay by CarSettings.autoplay.collectAsState()
val playerTitle by CarSettings.playerTitle.collectAsState()
val playerLeft by CarSettings.playerLeft.collectAsState()
val moreLyrics by CarSettings.moreLyrics.collectAsState()
val theme by CarSettings.appTheme.collectAsState()
val uiTheme by CarSettings.uiTheme.collectAsState()
val darkStyle by CarSettings.darkStyle.collectAsState()
val experimentalFeatures by CarSettings.experimentalFeaturesEnabled.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 display by CarEnvironment.display.collectAsState()
val vehicle by CarEnvironment.vehicle.collectAsState()
@@ -70,140 +87,260 @@ fun CarSettingsScreen() {
value = withContext(Dispatchers.Default) { CertSummary.read(context) }
}
Column(
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
ScreenHeader("Settings")
// A rail of the groups down the left, and the chosen group's settings beside it: five short
// lists instead of one long scroll.
var chosen by androidx.compose.runtime.saveable.rememberSaveable { androidx.compose.runtime.mutableIntStateOf(0) }
val listState = androidx.compose.foundation.lazy.rememberLazyListState()
androidx.compose.runtime.LaunchedEffect(chosen) { listState.scrollToItem(0) }
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 }
LazyColumn(
Modifier.weight(1f).fillMaxHeight(),
state = listState,
// Built as it scrolls into view rather than all at once: every row here is a set of
// buttons, and building the whole screen made opening Settings visibly slow.
contentPadding = PaddingValues(horizontal = 24.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
var section: Int
section = 0
if (section == chosen) item { Group(stringResource(R.string.car_group_screen)) }
ChoiceRow(
"Open on connect",
listOf(CarSettings.HOME to "Home") + CarApps.all.filter { it.id != "settings" }.map { it.id to it.label },
open,
) { CarSettings.setOpenOnConnect(it) }
if (section == chosen) item {
ChoiceRow(
stringResource(R.string.car_open_on_connect),
listOf(CarSettings.HOME to stringResource(R.string.choice_open_home)) + CarApps.all.filter { it.id != "settings" }.map { it.id to stringResource(it.labelRes) },
open,
) { CarSettings.setOpenOnConnect(it) }
}
ChoiceRow(
"Size",
listOf(1f to "Normal", 1.25f to "Large", 1.5f to "Extra large"),
scale,
) { CarSettings.setScale(it) }
if (section == chosen) item {
ChoiceRow(
stringResource(R.string.car_size),
listOf(1f to stringResource(R.string.choice_size_normal), 1.25f to stringResource(R.string.choice_size_large), 1.5f to stringResource(R.string.choice_size_xlarge)),
scale,
) { CarSettings.setScale(it) }
}
ChoiceRow(
"App theme",
listOf(AppTheme.PHONE to "Follow phone", AppTheme.LIGHT to "Light", AppTheme.DARK to "Dark"),
theme,
) { CarSettings.setAppTheme(it) }
if (section == chosen) item {
ChoiceRow(
stringResource(R.string.car_app_theme),
listOf(AppTheme.PHONE to stringResource(R.string.choice_theme_phone), AppTheme.LIGHT to stringResource(R.string.choice_theme_light), AppTheme.DARK to stringResource(R.string.choice_theme_dark)),
theme,
) { CarSettings.setAppTheme(it) }
}
ChoiceRow(
"Theme",
GsThemes.builtIn.map { it.id to it.name },
uiTheme,
) { CarSettings.setUiTheme(it) }
if (section == chosen && theme != AppTheme.LIGHT) item {
ChoiceRow(
stringResource(R.string.car_dark_mode),
listOf(
DarkStyle.BLACK to stringResource(R.string.choice_dark_black),
DarkStyle.BLACK_COLOUR to stringResource(R.string.choice_dark_black_colour),
DarkStyle.DEEP_COLOUR to stringResource(R.string.choice_dark_deep_colour),
DarkStyle.GREY to stringResource(R.string.choice_dark_grey),
),
darkStyle,
) { CarSettings.setDarkStyle(it) }
}
ChoiceRow(
"Map day/night",
listOf(NightMode.AUTO to "Auto", NightMode.DAY to "Day", NightMode.NIGHT to "Night"),
night,
) { CarSettings.setNightMode(it) }
if (section == chosen) item {
ChoiceRow(
stringResource(R.string.carset_theme),
GsThemes.builtIn.map { it.id to it.name },
uiTheme,
) { CarSettings.setUiTheme(it) }
}
ChoiceRow(
"Units",
listOf(Units.AUTO to "Auto", Units.IMPERIAL to "Imperial (mi, ft, °F)", Units.METRIC to "Metric (km, m, °C)"),
units,
) { CarSettings.setUnits(it) }
if (section == chosen) item {
ChoiceRow(
stringResource(R.string.car_map_day_night),
listOf(NightMode.AUTO to stringResource(R.string.choice_auto), NightMode.DAY to stringResource(R.string.choice_night_day), NightMode.NIGHT to stringResource(R.string.choice_night_night)),
night,
) { CarSettings.setNightMode(it) }
}
ChoiceRow(
"Player in the nav bar",
listOf(false to "Skip buttons", true to "Song title"),
playerTitle,
) { CarSettings.setPlayerTitle(it) }
if (section == chosen) item {
ChoiceRow(
stringResource(R.string.car_units),
listOf(Units.AUTO to stringResource(R.string.choice_auto), Units.IMPERIAL to stringResource(R.string.carset_imperial), Units.METRIC to stringResource(R.string.carset_metric)),
units,
) { CarSettings.setUnits(it) }
}
ChoiceRow(
"Autoplay on connect",
listOf(false to "Off", true to "On"),
autoplay,
) { CarSettings.setAutoplay(it) }
// For a head unit that reports the wrong size and stretches the picture (issue #12).
if (section == chosen) item {
ChoiceRow(
stringResource(R.string.carset_screen_size),
listOf<ScreenSize?>(null).plus(SCREEN_PRESETS).plus(screenSize).distinct()
.map { it to (it?.toString() ?: stringResource(R.string.screen_from_car)) },
screenSize,
) { CarSettings.setScreenSize(it) }
}
ChoiceRow(
"Car audio",
listOf(false to "Bluetooth", true to "USB"),
pipeAudio,
) { CarSettings.setPipeAudio(it) }
Text(
"USB sends the phone's sound through Gearslip. Android asks to share your screen each " +
"drive, and a few apps block it. Bluetooth plays as the phone normally would, and " +
"Android never asks.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (!pipeAudio) {
ChoiceRow(
"Warn when Bluetooth isn't connected",
listOf(false to "Off", true to "On"),
bluetoothWarning,
) { CarSettings.setBluetoothWarning(it) }
}
ChoiceRow(
"Warn when location is off",
listOf(false to "Off", true to "On"),
locationWarning,
) { CarSettings.setLocationWarning(it) }
if (section == chosen && screenSize != null) item {
ChoiceRow(
stringResource(R.string.carset_fit),
listOf(ScreenFit.CROP to stringResource(R.string.screen_crop), ScreenFit.SQUASH to stringResource(R.string.screen_squash)),
screenFit,
) { CarSettings.setScreenFit(it) }
}
ChoiceRow(
"Spoken directions",
listOf(false to "Off", true to "On"),
speakDirections,
) { CarSettings.setSpeakDirections(it) }
Text(
"Gearslip reads each turn aloud through the car's speakers, and the music dips while it " +
"speaks. Turn it off if your map app's own voice already plays in the car.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
section = 1
if (section == chosen) item { Group(stringResource(R.string.car_group_nav_bar)) }
ExperimentalFeatureRow(
title = "Use the phone's speech recognition",
description = "Listens through the phone's own speech service instead of Gearslip's. On " +
"most phones that's Google's, which may send your voice to Google. GrapheneOS has " +
"none, so Gearslip keeps using its own.",
checked = systemSpeech,
) { CarSettings.setSystemSpeech(it) }
if (section == chosen) item {
ChoiceRow(
stringResource(R.string.car_nav_bar),
listOf(
NavBarMode.SHOW to stringResource(R.string.choice_nav_show),
NavBarMode.HIDE_NAVIGATING to stringResource(R.string.choice_nav_hide_navigating),
NavBarMode.HIDE to stringResource(R.string.choice_nav_hide),
),
navBar,
) { CarSettings.setNavBarMode(it) }
}
WebPageSetting()
if (section == chosen) item {
ChoiceRow(
stringResource(R.string.car_player_nav_bar),
listOf(false to stringResource(R.string.car_skip_buttons), true to stringResource(R.string.car_song_title)),
playerTitle,
) { CarSettings.setPlayerTitle(it) }
}
ScreenFit(vehicle)
if (section == chosen) item {
ChoiceRow(
stringResource(R.string.car_player_beside_map),
listOf(false to stringResource(R.string.car_right), true to stringResource(R.string.car_left)),
playerLeft,
) { CarSettings.setPlayerLeft(it) }
}
ChoiceRow(
"Experimental features",
listOf(false to "Hide", true to "Show"),
experimentalFeatures,
) { CarSettings.setExperimentalFeaturesEnabled(it) }
if (experimentalFeatures) {
ExperimentalFeaturesList(carSensors)
}
if (section == chosen) item {
ChoiceRow(
stringResource(R.string.car_autoplay),
listOf(false to stringResource(R.string.off), true to stringResource(R.string.on)),
autoplay,
) { CarSettings.setAutoplay(it) }
}
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Connection", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
Info("Phone", "${Build.MANUFACTURER} ${Build.MODEL}")
Info("Vehicle", vehicle.ifEmpty { "-" })
Info("Video", display.ifEmpty { "-" })
Info("Certificate", cert?.headline ?: "Checking…")
}
if (section == chosen) item {
ChoiceRow(
stringResource(R.string.carset_lyrics_from),
listOf(false to "LRCLIB", true to stringResource(R.string.carset_more_sources)),
moreLyrics,
) { CarSettings.setMoreLyrics(it) }
}
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("About", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
Info("Version", app.seb3thehacker.gearslip.BuildConfig.VERSION_NAME)
Info("Build", app.seb3thehacker.gearslip.BuildConfig.VERSION_CODE.toString())
ExperimentalFeatureRow(
"What's new after updates",
"Show the list of changes once, the first time the car connects after an update.",
whatsNew,
) { CarSettings.setWhatsNewAfterUpdates(it) }
GsButton(onClick = CarSettings::openWhatsNew, tone = GsTone.Tonal, modifier = Modifier.height(56.dp)) {
Text("What's new", style = MaterialTheme.typography.titleMedium)
section = 2
if (section == chosen) item { Group(stringResource(R.string.car_group_sound)) }
if (section == chosen) item {
ChoiceRow(
stringResource(R.string.car_audio),
listOf(false to stringResource(R.string.guide_audio_bluetooth), true to stringResource(R.string.guide_audio_usb)),
pipeAudio,
) { CarSettings.setPipeAudio(it) }
}
if (section == chosen) item {
Text(
stringResource(R.string.carset_audio_note),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
section = 3
if (section == chosen) item { Group(stringResource(R.string.car_group_warnings)) }
if (section == chosen && !pipeAudio) item {
ChoiceRow(
stringResource(R.string.car_warn_bluetooth),
listOf(false to stringResource(R.string.off), true to stringResource(R.string.on)),
bluetoothWarning,
) { CarSettings.setBluetoothWarning(it) }
}
if (section == chosen) item {
ChoiceRow(
stringResource(R.string.car_warn_location),
listOf(false to stringResource(R.string.off), true to stringResource(R.string.on)),
locationWarning,
) { CarSettings.setLocationWarning(it) }
}
if (section == chosen) item {
ChoiceRow(
stringResource(R.string.car_spoken_directions),
listOf(false to stringResource(R.string.off), true to stringResource(R.string.on)),
speakDirections,
) { CarSettings.setSpeakDirections(it) }
}
if (section == chosen) item {
Text(
stringResource(R.string.carset_directions_note),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (section == chosen) item {
ExperimentalFeatureRow(
title = stringResource(R.string.car_phone_speech),
description = stringResource(R.string.carset_phone_speech_note),
checked = systemSpeech,
) { CarSettings.setSystemSpeech(it) }
}
section = 4
if (section == chosen) item { Group(stringResource(R.string.car_group_more)) }
if (section == chosen) item {
WebPageSetting()
}
if (section == chosen) item {
ScreenFit(vehicle)
}
if (section == chosen) item {
ChoiceRow(
stringResource(R.string.car_experimental),
listOf(false to stringResource(R.string.carset_hide), true to stringResource(R.string.carset_show)),
experimentalFeatures,
) { CarSettings.setExperimentalFeaturesEnabled(it) }
}
if (section == chosen && experimentalFeatures) item {
ExperimentalFeaturesList(carSensors, adaptiveVideo, clusterVideo)
}
if (section == chosen) item {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(stringResource(R.string.settings_connection), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
Info(stringResource(R.string.carset_phone), "${Build.MANUFACTURER} ${Build.MODEL}")
Info(stringResource(R.string.carset_vehicle), vehicle.ifEmpty { "-" })
Info(stringResource(R.string.carset_video), display.ifEmpty { "-" })
Info(stringResource(R.string.settings_certificate), cert?.headline ?: stringResource(R.string.cert_checking))
}
}
if (section == chosen) item {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(stringResource(R.string.settings_about), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
Info(stringResource(R.string.carset_version), app.seb3thehacker.gearslip.BuildConfig.VERSION_NAME)
Info(stringResource(R.string.carset_build), app.seb3thehacker.gearslip.BuildConfig.VERSION_CODE.toString())
ExperimentalFeatureRow(
stringResource(R.string.carset_whats_new_after),
stringResource(R.string.carset_whats_new_after_sub),
whatsNew,
) { CarSettings.setWhatsNewAfterUpdates(it) }
GsButton(onClick = CarSettings::openWhatsNew, tone = GsTone.Tonal, modifier = Modifier.height(56.dp)) {
Text(stringResource(R.string.settings_whats_new), style = MaterialTheme.typography.titleMedium)
}
}
}
}
}
@@ -211,8 +348,78 @@ fun CarSettingsScreen() {
@Composable
private fun <T> ChoiceRow(title: String, options: List<Pair<T, String>>, selected: T, onSelect: (T) -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(title, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
// On a wide screen the name sits beside its choices, which halves how far there is to scroll.
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)) {
options.forEach { (value, label) ->
val on = value == selected
@@ -239,19 +446,26 @@ private fun <T> ChoiceRow(title: String, options: List<Pair<T, String>>, selecte
* so the list reads fine whether there's one entry or several.
*/
@Composable
private fun ExperimentalFeaturesList(carSensors: Boolean) {
private fun ExperimentalFeaturesList(carSensors: Boolean, adaptiveVideo: Boolean, clusterVideo: Boolean) {
Column(
Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
ExperimentalFeatureRow(
title = "Vehicle data",
description = "Adds a Vehicle data tile: a live, raw dump of the head unit's sensor " +
"channel. Field labels (speed, RPM, fuel...) are carried over from other " +
"reverse-engineering projects and unverified against this car - treat them as a " +
"guess, not a fact, until you've watched one change for yourself.",
title = stringResource(R.string.car_vehicle_data),
description = stringResource(R.string.carset_vehicle_data_note),
checked = carSensors,
) { CarSettings.setCarSensorsEnabled(it) }
ExperimentalFeatureRow(
title = stringResource(R.string.car_adaptive_video),
description = stringResource(R.string.carset_adaptive_note),
checked = adaptiveVideo,
) { CarSettings.setAdaptiveVideoEnabled(it) }
ExperimentalFeatureRow(
title = stringResource(R.string.car_cluster_video),
description = stringResource(R.string.car_cluster_video_note),
checked = clusterVideo,
) { CarSettings.setClusterVideoEnabled(it) }
}
}
@@ -297,7 +511,7 @@ private fun WebPageSetting() {
var url by remember { mutableStateOf(AppSettings.startupUrl(context)) }
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(
onClick = { editing = !editing },
shape = MaterialTheme.shapes.large,
@@ -305,7 +519,7 @@ private fun WebPageSetting() {
modifier = Modifier.fillMaxWidth(),
) {
Text(
url.ifEmpty { "DuckDuckGo (default) - tap to set a different page" },
url.ifEmpty { stringResource(R.string.carset_web_default) },
style = MaterialTheme.typography.bodyLarge,
color = if (url.isEmpty()) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface,
maxLines = 1,
@@ -314,7 +528,7 @@ private fun WebPageSetting() {
)
}
Text(
"Shown by the Browser app on the launcher. Applies on the next connection.",
stringResource(R.string.carset_web_note),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -357,22 +571,22 @@ private fun ScreenFit(vehicle: String) {
}
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Screen fit", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
Stepper("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))) }
Text(stringResource(R.string.carset_screen_fit), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
Stepper(stringResource(R.string.carset_top), insets.top) { change(insets.copy(top = (insets.top + it).coerceAtLeast(0))) }
Stepper(stringResource(R.string.carset_bottom), insets.bottom) { change(insets.copy(bottom = (insets.bottom + it).coerceAtLeast(0))) }
Text(
"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,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
GsButton(
onClick = {
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),
) { Text(if (known) "Save for this vehicle" else "No vehicle profile matched") }
) { Text(if (known) stringResource(R.string.carset_save_vehicle) else stringResource(R.string.carset_no_profile)) }
saved?.let { Text(it, style = MaterialTheme.typography.bodyMedium) }
}
}
@@ -384,7 +598,7 @@ private fun Stepper(label: String, value: Int, onStep: (Int) -> Unit) {
StepButton("−5", Modifier.weight(1f)) { onStep(-5) }
StepButton("−1", Modifier.weight(1f)) { onStep(-1) }
Text(
"${value}px",
stringResource(R.string.carset_px, value),
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.weight(1.3f),
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
@@ -406,3 +620,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
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
@@ -59,6 +63,8 @@ import androidx.compose.runtime.setValue
import androidx.compose.animation.animateContentSize
import android.media.session.PlaybackState
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.Modifier
import androidx.compose.ui.draw.clip
@@ -72,6 +78,7 @@ import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextOverflow
import app.seb3thehacker.gearslip.R
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
@@ -106,6 +113,7 @@ fun CarUi() {
val phoneDark by CarEnvironment.phoneDark.collectAsState()
val appTheme by CarSettings.appTheme.collectAsState()
val uiThemeId by CarSettings.uiTheme.collectAsState()
val darkStyle by CarSettings.darkStyle.collectAsState()
val mediaApp by CarServices.mediaApp.collectAsState()
val darkOutside by CarEnvironment.darkOutside.collectAsState()
val appContext = LocalContext.current.applicationContext
@@ -114,7 +122,7 @@ fun CarUi() {
AppTheme.LIGHT -> false
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 insets by CarEnvironment.insets.collectAsState()
val navigator = remember { CarNavigator(CarSettings.openOnConnect.value) }
@@ -123,6 +131,7 @@ fun CarUi() {
while (true) {
delay(250)
controlsWake.tick()
CarFullscreen.tick()
}
}
// The knob counts as a touch: the map's buttons can't be found while they're faded out.
@@ -193,17 +202,34 @@ fun CarUi() {
.fillMaxSize()
// Watches every touch without taking any: first in line, consumes nothing.
.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 {
while (true) {
awaitPointerEvent(PointerEventPass.Initial)
val event = awaitPointerEvent(PointerEventPass.Initial)
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,
) {
// Insets are in video-frame pixels, so convert with the un-scaled density.
val pad = with(base) {
// Insets are video-frame pixels. Resolve them with the live (size-scaled) density,
// 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(
start = insets.left.toDp(), top = insets.top.toDp(),
end = insets.right.toDp(), bottom = insets.bottom.toDp(),
@@ -211,6 +237,26 @@ fun CarUi() {
}
val screen = navigator.current
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)) {
Column(Modifier.fillMaxSize()) {
if (!safetyWarningSeen) {
@@ -223,31 +269,62 @@ fun CarUi() {
// 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
// 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) {
Box(
Modifier
.fillMaxSize()
.graphicsLayer { if (fromHome) alpha = enter.value }
.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.
.pointerInput(Unit) { detectTapGestures { } },
) {
when (screen) {
CarScreen.Home -> Unit
CarScreen.Apps -> CarLauncher()
CarScreen.Media -> 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()
Box(
Modifier.fillMaxSize().graphicsLayer {
if (!fromHome) alpha = enter.value
translationY = (1f - enter.value) * 16.dp.toPx()
},
) {
when (screen) {
CarScreen.Home -> Unit
CarScreen.Apps -> CarLauncher()
CarScreen.Media -> CompositionLocalProvider(
LocalNavBarInset provides if (!keyboardVisible && !fullscreen) NAV_BAR_HEIGHT else 0.dp,
) { 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))
CallPromptOverlay()
BluetoothPairOverlay()
PopupTimer()
@@ -258,21 +335,39 @@ fun CarUi() {
)
// Above the popup: a call is more urgent than any notification.
VoiceHelpOverlay(Modifier.align(Alignment.Center))
CallOverlay(Modifier.align(Alignment.TopCenter))
AssistantOverlay(Modifier.align(Alignment.BottomCenter))
if (app.seb3thehacker.gearslip.BuildConfig.DEBUG) {
CallOverlay()
AssistantOverlay(Modifier.align(Alignment.BottomCenter).padding(bottom = homeInset))
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
// something in the top-right (settings, search, a Liked Songs
// shortcut) and this badge used to sit right on top of it, hiding
// 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.
AppMenu(navigator, rememberRunningApps())
@@ -284,6 +379,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 -
* Home included. The phone's own setup guide carries the same warning, but a driver who plugs in
@@ -305,22 +408,20 @@ private fun CarSafetyWizard(onDone: () -> Unit) {
modifier = Modifier.fillMaxWidth(),
) {
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(
"Gearslip is unofficial software: it is not made or reviewed by any " +
"carmaker, and it can fail without warning.",
stringResource(R.string.carui_unofficial),
style = ChromeType.body,
)
Text(
"Never interact with this screen while driving. Set the route or the " +
"song before you go, then keep your attention on the road.",
stringResource(R.string.carui_never_interact),
style = ChromeType.body,
)
}
}
Spacer(Modifier.height(24.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 +452,7 @@ private fun BrowseAppScreen(navigator: CarNavigator) {
}
val failed = status.phase == CarAppConnection.Phase.REJECTED || status.phase == CarAppConnection.Phase.FAILED
if (failed || timedOut) {
AppWontStart(status.app ?: "This app", leave)
AppWontStart(status.app ?: stringResource(R.string.carui_this_app), leave)
return
}
CarAppStage(CarServices.browse, status, frame, onDisconnect = leave)
@@ -367,13 +468,13 @@ private fun AppWontStart(app: String, onBack: () -> Unit) {
verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterVertically),
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(
"It didn't start. Some apps only run on Google's Android Auto.",
stringResource(R.string.carui_app_unsupported_body),
style = MaterialTheme.typography.bodyLarge,
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 +499,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. */
private object NoHaptics : HapticFeedback {
override fun performHapticFeedback(hapticFeedbackType: HapticFeedbackType) {}
@@ -1,5 +1,7 @@
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.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
@@ -76,7 +78,7 @@ fun CarWhatsNew() {
) {
Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
Text(
"What's new in ${release.versionName}",
stringResource(R.string.whatsnew_title, release.versionName),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
)
@@ -99,12 +101,12 @@ fun CarWhatsNew() {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp)) {
GsSwitch(afterUpdates, CarSettings::setWhatsNewAfterUpdates)
Text(
"Show after updates",
stringResource(R.string.whatsnew_show_after),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f),
)
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)
}
}
}
@@ -24,23 +24,36 @@ import app.seb3thehacker.gearslip.Protobuf
*/
class ClusterNav(
val channelId: Int,
minimumIntervalMs: Long = 0,
private val sendOnChannel: (messageId: Int, body: ByteArray) -> Unit,
) {
private val updateIntervalMs = maxOf(TICK_MS, minimumIntervalMs)
@Volatile private var running = false
private var worker: Thread? = null
private var updatesRequested = true
private var sentStatus = -1
private var sentTurn: String? = null
private var sentDistance: String? = null
@Synchronized
fun onMessage(messageId: Int, body: ByteArray) {
when (messageId) {
MSG_CHANNEL_OPEN_RESPONSE -> {
val status = Protobuf.readInt32Field(body, 1) ?: 0
val status = Protobuf.readInt32Field(body, 1)
GearslipLog.i("cluster: <- ChannelOpenResponse: status=$status")
if (status == 0) start()
}
// The car saying when it wants turns. Sending all along is harmless, so these are only logged.
MSG_START -> GearslipLog.i("cluster: <- start")
MSG_STOP -> GearslipLog.i("cluster: <- stop")
MSG_START -> {
updatesRequested = true
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)")
}
}
@@ -48,19 +61,28 @@ class ClusterNav(
private fun start() {
if (running) return
running = true
Thread({
worker = Thread({
while (running) {
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() {
running = false
worker?.interrupt()
worker = null
}
@Synchronized
private fun tick() {
if (!running || !updatesRequested) return
val turn = NavTurn.current()
val status = when {
turn == null -> STATUS_INACTIVE
@@ -82,7 +104,7 @@ class ClusterNav(
val maneuver = step.maneuver
val (event, side) = maneuver?.let { eventFor(it.type) } ?: (EVENT_UNKNOWN to SIDE_UNSPECIFIED)
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
sentTurn = key
var body = Protobuf.stringField(1, road) +
@@ -0,0 +1,140 @@
package app.seb3thehacker.gearslip.car
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import app.seb3thehacker.gearslip.host.clockTime
import app.seb3thehacker.gearslip.host.display
import app.seb3thehacker.gearslip.host.remainingTime
import kotlinx.coroutines.delay
/**
* The instrument-cluster screen: the next turn, big enough to read in a glance past the wheel.
* The arrow and the distance to it, what to do there, and the time, distance and arrival left
* along the bottom. With no route it shows what's playing, quietly, or nothing.
*
* Always dark, like the dials around it. [marginX] and [marginY] are the pixels the car crops off
* the frame, kept clear.
*/
@Composable
internal fun ClusterScreen(marginX: Int, marginY: Int) {
// The map app's turn changes a few times a minute: read it once a second.
val turn by produceState<ClusterTurn?>(null) {
while (true) {
value = ClusterTurn.now()
delay(1_000)
}
}
val density = LocalDensity.current
MaterialTheme(colorScheme = darkColorScheme()) {
CompositionLocalProvider(LocalContentColor provides Color.White) {
BoxWithConstraints(
Modifier.fillMaxSize().background(Color.Black).padding(
horizontal = with(density) { (marginX / 2).toDp() } + 24.dp,
vertical = with(density) { (marginY / 2).toDp() } + 16.dp,
),
contentAlignment = Alignment.CenterStart,
) {
// Sized off the height: clusters run from short strips to near squares.
val unit = maxHeight / 12
val t = turn
if (t == null) {
NowPlayingLine(Modifier.align(Alignment.Center))
return@BoxWithConstraints
}
Column(verticalArrangement = Arrangement.spacedBy(unit * 0.4f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
CarGlyph(t.icon, Modifier.size(unit * 3.4f))
Spacer(Modifier.width(unit))
Text(
t.distance,
fontSize = with(density) { (unit * 2.2f).toSp() },
fontWeight = FontWeight.Bold,
maxLines = 1,
)
}
if (t.what.isNotEmpty()) {
Text(
t.what,
fontSize = with(density) { (unit * 1.1f).toSp() },
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
if (t.trip.isNotEmpty()) {
Text(
t.trip,
fontSize = with(density) { (unit * 0.85f).toSp() },
color = Color.White.copy(alpha = 0.7f),
maxLines = 1,
)
}
}
}
}
}
}
@Composable
private fun NowPlayingLine(modifier: Modifier) {
val now = CarServices.mediaOrNull()?.now?.value
if (now == null || now.title.isEmpty()) return
Text(
listOf(now.title, now.artist).filter { it.isNotEmpty() }.joinToString(" · "),
modifier,
style = MaterialTheme.typography.titleLarge,
color = Color.White.copy(alpha = 0.7f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
/** What the cluster shows of a route, read from [NavTurn] and the trip's estimate to the destination. */
private class ClusterTurn(
val icon: androidx.car.app.model.CarIcon?,
val distance: String,
val what: String,
val trip: String,
) {
companion object {
fun now(): ClusterTurn? {
val turn = NavTurn.current() ?: return null
if (turn.rerouting) return null
val step = turn.step
val cue = step.cue?.toString().orEmpty()
val what = cue.ifEmpty { step.road?.toString().orEmpty() }
val estimate = CarServices.navOrNull()?.trip?.value?.destinationTravelEstimates?.firstOrNull()
val trip = estimate?.let { e ->
listOf(
e.remainingTimeSeconds.takeIf { it >= 0 }?.let { remainingTime(it) }.orEmpty(),
e.remainingDistance.display(),
e.arrivalTimeAtDestination.clockTime(),
).filter { it.isNotEmpty() }.joinToString(" · ")
}.orEmpty()
return ClusterTurn(step.maneuver?.icon, turn.distance.display(), what, trip)
}
}
}
@@ -1,5 +1,6 @@
package app.seb3thehacker.gearslip.car
import app.seb3thehacker.gearslip.R
import android.content.Context
import android.os.Bundle
import android.speech.tts.TextToSpeech
@@ -27,7 +28,8 @@ import kotlin.math.roundToInt
* 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
* 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.
*/
@@ -42,7 +44,10 @@ object DirectionsVoice {
@Volatile private var link: AudioLink? = null
@Volatile private var running = false
private val speaker = Executors.newSingleThreadExecutor { Thread(it, "gearslip-directions").apply { isDaemon = true } }
@Volatile private var musicIsOurs: () -> Boolean = { false }
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
private var turnKey: String? = null
@@ -62,8 +67,9 @@ object DirectionsVoice {
}
/** The car opened its directions sink: start following the map app's turns. */
fun attach(link: AudioLink) {
fun attach(link: AudioLink, musicIsOurs: () -> Boolean) {
this.link = link
this.musicIsOurs = musicIsOurs
GearslipLog.i("guidance: the car offered a directions sink at ${link.sampleRate}Hz x${link.channels}")
if (running) return
running = true
@@ -82,16 +88,26 @@ object DirectionsVoice {
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() {
if (!CarSettings.speakDirections.value) return
val turn = NavTurn.current()
if (turn == null) {
turnKey = null
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 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 close = (turn.seconds != null && turn.seconds <= NEAR_SECONDS) || (meters != null && meters <= NEAR_METERS)
@@ -106,7 +122,7 @@ object DirectionsVoice {
say(instruction)
stage = Stage.DONE
} 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
}
return
@@ -117,7 +133,7 @@ object DirectionsVoice {
stage = Stage.DONE
}
stage == Stage.FAR && meters != null && meters <= PREPARE_AT_METERS -> {
say("In ${spoken(turn.distance)}, $instruction")
say(ahead(turn.distance, instruction))
stage = Stage.PREPARED
}
}
@@ -125,67 +141,79 @@ object DirectionsVoice {
/** 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? {
val res = appContext?.resources ?: return null
val road = step.road.text()
val onto = if (road.isBlank()) "" else " onto $road"
val toward = if (road.isBlank()) "" else " toward $road"
// Each manoeuvre is a whole sentence, with and without the road, so a translation can
// 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 built = when (maneuver?.type) {
Maneuver.TYPE_TURN_SLIGHT_LEFT -> "Turn slightly left$onto"
Maneuver.TYPE_TURN_SLIGHT_RIGHT -> "Turn slightly right$onto"
Maneuver.TYPE_TURN_NORMAL_LEFT -> "Turn left$onto"
Maneuver.TYPE_TURN_NORMAL_RIGHT -> "Turn right$onto"
Maneuver.TYPE_TURN_SHARP_LEFT -> "Turn sharply left$onto"
Maneuver.TYPE_TURN_SHARP_RIGHT -> "Turn sharply right$onto"
Maneuver.TYPE_U_TURN_LEFT, Maneuver.TYPE_U_TURN_RIGHT -> "Make a U-turn"
Maneuver.TYPE_KEEP_LEFT -> "Keep left$toward"
Maneuver.TYPE_KEEP_RIGHT -> "Keep right$toward"
Maneuver.TYPE_FORK_LEFT -> "Keep left at the fork$toward"
Maneuver.TYPE_FORK_RIGHT -> "Keep right at the fork$toward"
Maneuver.TYPE_TURN_SLIGHT_LEFT -> say(R.string.dir_slight_left, R.string.dir_slight_left_onto)
Maneuver.TYPE_TURN_SLIGHT_RIGHT -> say(R.string.dir_slight_right, R.string.dir_slight_right_onto)
Maneuver.TYPE_TURN_NORMAL_LEFT -> say(R.string.dir_left, R.string.dir_left_onto)
Maneuver.TYPE_TURN_NORMAL_RIGHT -> say(R.string.dir_right, R.string.dir_right_onto)
Maneuver.TYPE_TURN_SHARP_LEFT -> say(R.string.dir_sharp_left, R.string.dir_sharp_left_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 -> say(R.string.dir_u_turn, 0)
Maneuver.TYPE_KEEP_LEFT -> say(R.string.dir_keep_left, R.string.dir_keep_left_toward)
Maneuver.TYPE_KEEP_RIGHT -> say(R.string.dir_keep_right, R.string.dir_keep_right_toward)
Maneuver.TYPE_FORK_LEFT -> say(R.string.dir_fork_left, R.string.dir_fork_left_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_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_U_TURN_RIGHT -> "Take the ramp on the 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_RIGHT, Maneuver.TYPE_OFF_RAMP_NORMAL_RIGHT -> "Take the exit on the right$toward"
Maneuver.TYPE_MERGE_LEFT -> "Merge left$onto"
Maneuver.TYPE_MERGE_RIGHT -> "Merge right$onto"
Maneuver.TYPE_MERGE_SIDE_UNSPECIFIED -> "Merge$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 -> say(R.string.dir_exit_left, R.string.dir_exit_left_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 -> say(R.string.dir_merge_left, R.string.dir_merge_left_onto)
Maneuver.TYPE_MERGE_RIGHT -> say(R.string.dir_merge_right, R.string.dir_merge_right_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_CCW, Maneuver.TYPE_ROUNDABOUT_ENTER_AND_EXIT_CCW_WITH_ANGLE ->
maneuver.roundaboutExitNumber.takeIf { it in 1..ORDINALS.size }
?.let { "At the roundabout, take the ${ORDINALS[it - 1]} exit$onto" }
?: "Go through the roundabout$onto"
Maneuver.TYPE_ROUNDABOUT_ENTER_CW, Maneuver.TYPE_ROUNDABOUT_ENTER_CCW -> "Enter the roundabout"
Maneuver.TYPE_ROUNDABOUT_EXIT_CW, Maneuver.TYPE_ROUNDABOUT_EXIT_CCW -> "Exit the roundabout$onto"
Maneuver.TYPE_STRAIGHT -> "Continue straight$onto"
Maneuver.TYPE_NAME_CHANGE -> "Continue$onto"
Maneuver.TYPE_FERRY_BOAT, Maneuver.TYPE_FERRY_BOAT_LEFT, Maneuver.TYPE_FERRY_BOAT_RIGHT -> "Take the ferry"
Maneuver.TYPE_FERRY_TRAIN, Maneuver.TYPE_FERRY_TRAIN_LEFT, Maneuver.TYPE_FERRY_TRAIN_RIGHT -> "Take the train"
Maneuver.TYPE_DESTINATION, Maneuver.TYPE_DESTINATION_STRAIGHT -> "Your destination is ahead"
Maneuver.TYPE_DESTINATION_LEFT -> "Your destination is on the left"
Maneuver.TYPE_DESTINATION_RIGHT -> "Your destination is on the right"
?.let { res.getString(ORDINALS[it - 1]) }
?.let { exit ->
if (road.isBlank()) res.getString(R.string.dir_roundabout_take, exit)
else res.getString(R.string.dir_roundabout_take_onto, exit, road)
}
?: say(R.string.dir_roundabout_through, R.string.dir_roundabout_through_onto)
Maneuver.TYPE_ROUNDABOUT_ENTER_CW, Maneuver.TYPE_ROUNDABOUT_ENTER_CCW -> say(R.string.dir_roundabout_enter, 0)
Maneuver.TYPE_ROUNDABOUT_EXIT_CW, Maneuver.TYPE_ROUNDABOUT_EXIT_CCW -> say(R.string.dir_roundabout_exit, R.string.dir_roundabout_exit_onto)
Maneuver.TYPE_STRAIGHT -> say(R.string.dir_straight, R.string.dir_straight_onto)
Maneuver.TYPE_NAME_CHANGE -> say(R.string.dir_continue, R.string.dir_continue_onto)
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
}
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. */
private fun spoken(d: Distance): String {
val res = appContext?.resources ?: return ""
val n = d.displayDistance
val number = if (n % 1.0 == 0.0) n.toInt().toString() else "%.1f".format(n)
val one = n == 1.0
val whole = n % 1.0 == 0.0
val number = if (whole) n.toInt().toString() else "%.1f".format(n)
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) {
0.25 -> "a quarter mile"
0.5 -> "half a mile"
0.75 -> "three quarters of a mile"
1.0 -> "1 mile"
else -> "$number miles"
0.25 -> res.getString(R.string.dir_quarter_mile)
0.5 -> res.getString(R.string.dir_half_mile)
0.75 -> res.getString(R.string.dir_three_quarter_mile)
else -> if (whole) res.getQuantityString(R.plurals.dir_miles, n.toInt(), number) else res.getString(R.string.dir_miles_decimal, number)
}
Distance.UNIT_FEET -> "$number feet"
Distance.UNIT_YARDS -> "$number yards"
else -> "${n.roundToInt()} meters"
Distance.UNIT_FEET -> res.getQuantityString(R.plurals.dir_feet, n.toInt(), number)
Distance.UNIT_YARDS -> res.getQuantityString(R.plurals.dir_yards, n.toInt(), number)
else -> res.getQuantityString(R.plurals.dir_meters, n.roundToInt(), n.roundToInt())
}
}
@@ -223,7 +251,16 @@ object DirectionsVoice {
GearslipLog.w("guidance: text-to-speech gave audio Gearslip can't read")
return
}
stream(sink, pcm)
// 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)")
}
@@ -321,6 +358,10 @@ object DirectionsVoice {
private const val PREPARE_FROM_METERS = 2_500
private const val PREPARE_AT_METERS = 1_000
private const val LEAD_MS = 100L
private const val HOLD_MS = 2_000L
private const val SYNTH_TIMEOUT_S = 10L
private val ORDINALS = listOf("first", "second", "third", "fourth", "fifth", "sixth", "seventh", "eighth")
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,
)
}
@@ -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,
* 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 {
var awake by mutableStateOf(true)
private set
var navBarAwake by mutableStateOf(true)
private set
private var lastTouch = SystemClock.uptimeMillis()
fun touch() {
lastTouch = SystemClock.uptimeMillis()
if (!awake) awake = true
if (!navBarAwake) navBarAwake = true
}
/** Called on a timer; hides the buttons once nothing has touched the screen for [IDLE_MS]. */
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 {
const val IDLE_MS = 4_000L
const val NAV_BAR_IDLE_MS = 8_000L
}
}
@@ -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
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.ui.unit.sp
import androidx.compose.foundation.text.TextAutoSize
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.background
import androidx.compose.foundation.rememberScrollState
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
@@ -49,6 +55,9 @@ 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.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.SolidColor
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.
LaunchedEffect(app) { CarServices.openMedia(app) }
androidx.compose.runtime.DisposableEffect(Unit) { onDispose { CarServices.mediaScreenClosed() } }
val art = rememberArt(now)
// The art's colour behind everything, header included; the buttons keep their own.
Surface(Modifier.fillMaxSize(), color = rememberArtColor(art, MaterialTheme.colorScheme.background, darkLightness = 0.15f, lightLightness = 0.9f)) {
// The theme's background, with the cover's colours glowing faintly behind the cover on the left.
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
if (phase == CarMedia.Phase.READY && !searching) AlbumGlow(art, Modifier.fillMaxSize(), wide = true)
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))
when (phase) {
CarMedia.Phase.REJECTED -> when (rejection) {
CarMedia.Rejection.NEEDS_NOTIFICATION_ACCESS -> Notice(
"${app.label} keeps its library to itself.",
"Gearslip can still control it once it has Notification access on the phone. " +
"Allow it under Settings > Notifications > Notification access.",
action = "Open on phone" to {
stringResource(R.string.media_keeps_library, app.label),
stringResource(R.string.media_needs_access),
action = stringResource(R.string.media_open_on_phone) to {
val app = context.applicationContext
runCatching {
app.startActivity(
@@ -117,24 +127,23 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) {
},
)
CarMedia.Rejection.NEEDS_APP_RUNNING -> Notice(
"${app.label} isn't running.",
"${app.label} plays through Gearslip's own player, but only while it's " +
"open on the phone. Open it once and it keeps playing here in the car.",
action = "Open ${app.label}" to { media.launchAndConnect(app) },
stringResource(R.string.media_not_running, app.label),
stringResource(R.string.media_not_running_body, app.label),
action = stringResource(R.string.media_open_app, app.label) to { media.launchAndConnect(app) },
)
CarMedia.Rejection.REFUSED -> Notice(
"${app.label} would not let Gearslip browse it.",
"Some media apps only accept Google's own host.",
stringResource(R.string.media_refused, app.label),
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
}
if (searching) {
SearchPanel(
Box(Modifier.padding(bottom = LocalNavBarInset.current)) { SearchPanel(
appLabel = app.label,
media = media,
canPickForYou = now.canSearch,
@@ -152,52 +161,91 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) {
tab = Tab.NOW_PLAYING
},
onClose = { searching = false },
)
) }
return@Surface
}
// 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().padding(start = 16.dp, end = 16.dp, top = 10.dp, bottom = 8.dp),
horizontalArrangement = Arrangement.spacedBy(24.dp),
) {
Column(Modifier.weight(0.8f).fillMaxHeight()) {
AppIdentity(app, onExit)
Spacer(Modifier.height(12.dp))
if (tab == Tab.NOW_PLAYING) {
CoverArt(art, Modifier.weight(1f).fillMaxWidth())
} else {
CompactPlayer(now, media, Modifier.weight(1f).fillMaxWidth())
// The page runs down under the see-through nav bar, so the cover's glow carries on behind
// it; the content stops at the bar's top edge. Lists run right to it; the rest keeps a gap.
val barTop = LocalNavBarInset.current
val barInset = barTop + 8.dp
val tabs: @Composable (Modifier) -> Unit = { tabModifier ->
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 }
MediaTab(stringResource(R.string.media_browse), tab == Tab.BROWSE, tabModifier) { tab = Tab.BROWSE }
MediaTab(stringResource(R.string.storage_lyrics), tab == Tab.LYRICS, tabModifier) { tab = Tab.LYRICS }
}
val searchButton: @Composable () -> Unit = {
if (now.canSearch || media.canSearchLibrary) {
GsIconButton(
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 }
MediaTab("Browse", tab == Tab.BROWSE, Modifier.weight(1f)) { tab = Tab.BROWSE }
MediaTab("Up next", tab == Tab.QUEUE, Modifier.weight(1f)) { tab = Tab.QUEUE }
MediaTab("Lyrics", tab == Tab.LYRICS, Modifier.weight(1f)) { tab = Tab.LYRICS }
if (now.canSearch || media.canSearchLibrary) {
GsIconButton(
Icons.Filled.Search,
"Search ${app.label}",
{ searching = true },
colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHigh, MaterialTheme.colorScheme.onSurface),
size = 44.dp,
shape = MaterialTheme.shapes.large,
)
}
BoxWithConstraints(Modifier.fillMaxSize().padding(start = 16.dp, end = 16.dp, top = 10.dp)) {
// A small screen, or a big one at Extra large size, has no room for two columns: the
// app and the tabs share one bar, and a list takes the whole width. The nav bar keeps
// the controls, so nothing is lost.
if (maxWidth < NARROW_WIDTH || maxHeight - barInset < NARROW_HEIGHT) {
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
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) }
Spacer(Modifier.height(12.dp))
Box(Modifier.weight(1f).fillMaxWidth()) {
when (tab) {
Tab.NOW_PLAYING -> NowPlayingControls(now, media, Modifier.fillMaxSize())
Tab.BROWSE -> BrowsePanel(browse, media, app.label, Modifier.fillMaxSize())
Tab.QUEUE -> QueuePanel(queue, now, media, Modifier.fillMaxSize())
Tab.LYRICS -> LyricsPanel(now, Modifier.fillMaxSize())
Column(Modifier.weight(1.2f).fillMaxHeight()) {
// The tabs share the row evenly; a long word (a translation, a big text size)
// shrinks to fit its tab instead.
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
tabs(Modifier.weight(1f))
searchButton()
}
search?.let { SearchStatus(it, app.label, media::dismissSearch) }
Spacer(Modifier.height(12.dp))
tabContent(Modifier.weight(1f).fillMaxWidth())
}
}
}
@@ -212,7 +260,7 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) {
private fun AppIdentity(app: MediaApp, onExit: () -> Unit, modifier: Modifier = Modifier) {
val icon = remember(app) { findEntry(app.component.flattenToString())?.icon }
Row(modifier, verticalAlignment = Alignment.CenterVertically) {
GsIconButton(Icons.Filled.ArrowBack, "Back", onExit, size = 44.dp)
GsIconButton(Icons.Filled.ArrowBack, stringResource(R.string.back), onExit, size = 44.dp)
Spacer(Modifier.width(14.dp))
icon?.let {
Image(it, null, Modifier.size(26.dp).clip(RoundedCornerShape(7.dp)))
@@ -272,10 +320,10 @@ private fun SearchPanel(
verticalAlignment = Alignment.CenterVertically,
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(
query,
"Search $appLabel",
stringResource(R.string.media_search, appLabel),
Modifier.weight(1f).clickable { typing = true },
onClear = {
query = ""
@@ -286,12 +334,12 @@ private fun SearchPanel(
Box(Modifier.weight(1f).fillMaxWidth().padding(horizontal = 16.dp)) {
val entries = current?.entries
when {
trimmed.isEmpty() -> SearchNote("Type a song, artist, album, or playlist.")
trimmed.isEmpty() -> SearchNote(stringResource(R.string.media_search_hint))
unsupported && canPickForYou ->
SearchNote("$appLabel can't list results. Press search, and $appLabel picks what to play.")
unsupported -> SearchNote("$appLabel can't search from the car.")
entries == null -> SearchNote("Searching $appLabel…")
entries.isEmpty() -> SearchNote("$appLabel found nothing for \"$trimmed\".")
SearchNote(stringResource(R.string.media_search_no_list, appLabel))
unsupported -> SearchNote(stringResource(R.string.media_search_unsupported, appLabel))
entries == null -> SearchNote(stringResource(R.string.media_searching, appLabel))
entries.isEmpty() -> SearchNote(stringResource(R.string.media_search_nothing, appLabel, trimmed))
else -> LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp), contentPadding = PaddingValues(bottom = 8.dp)) {
items(entries) { entry ->
EntryRow(entry, media) {
@@ -349,8 +397,8 @@ private fun SearchStatus(search: CarMedia.Search, appLabel: String, onDismiss: (
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
) {
Text(
if (search.ignored) "$appLabel didn't play anything for \"${search.query}\"."
else "Searching $appLabel for \"${search.query}\"…",
if (search.ignored) stringResource(R.string.media_search_ignored, appLabel, search.query)
else stringResource(R.string.media_searching_for, appLabel, search.query),
style = MaterialTheme.typography.bodyLarge,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
@@ -375,14 +423,17 @@ private fun MediaTab(label: String, selected: Boolean, modifier: Modifier, onCli
style = MaterialTheme.typography.titleSmall,
fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal,
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 current line in the accent colour; unsynced ones are plain scrolling text.
* The song's lyrics, fetched when this tab opens. Synced lyrics follow playback: the line being
* 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
internal fun LyricsPanel(
@@ -393,43 +444,89 @@ internal fun LyricsPanel(
val context = LocalContext.current
val lyrics by produceState<LyricsState>(LyricsState.Loading, now.title, now.artist, now.album, now.durationMs) {
value = LyricsState.Loading
value = Lyrics.find(context, now.title, now.artist, now.album, now.durationMs)
?.let { LyricsState.Found(it) } ?: LyricsState.Missing
}
val list = rememberLazyListState()
// Ticks with playback so the highlighted line moves; only synced lyrics need it.
var position by remember { mutableLongStateOf(0L) }
LaunchedEffect(now) {
while (true) {
position = now.currentPosition()
kotlinx.coroutines.delay(300)
val found = Lyrics.find(context, now.title, now.artist, now.album, now.durationMs)
value = found?.let { LyricsState.Found(it) } ?: LyricsState.Missing
// Cached lyrics show at once; if a source now has better ones, they replace them.
if (found != null) {
Lyrics.upgrade(context, now.title, now.artist, now.album, now.durationMs, found)
?.let { value = LyricsState.Found(it) }
}
}
when (val state = lyrics) {
LyricsState.Loading -> Notice("Looking for lyrics…", null)
LyricsState.Loading -> Notice(stringResource(R.string.media_looking_lyrics), null)
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,
)
is LyricsState.Found -> {
val lines = state.result.lines
val current = if (state.result.synced) lines.indexOfLast { it.timeMs <= position } else -1
LaunchedEffect(current) {
if (current >= 0) list.animateScrollToItem((current - 1).coerceAtLeast(0))
is LyricsState.Found -> Column(modifier) {
if (state.result.synced) SyncedLyrics(now, state.result, lineStyle, Modifier.weight(1f).fillMaxWidth())
else PlainLyrics(state.result, lineStyle, Modifier.weight(1f).fillMaxWidth())
// Debug builds name the service the lyrics came from, to see which one answers.
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(
line.text.ifEmpty { " " },
style = lineStyle,
fontWeight = if (index == current) FontWeight.Bold else FontWeight.Normal,
color = when {
!state.result.synced -> MaterialTheme.colorScheme.onSurface
index == current -> MaterialTheme.colorScheme.primary
else -> MaterialTheme.colorScheme.onSurfaceVariant
},
color = if (index < current) sung else scheme.onSurfaceVariant,
)
}
}
@@ -437,6 +534,72 @@ internal fun LyricsPanel(
}
}
/**
* The line being sung, in bold and a size up. With word timings, words already sung are in the
* accent colour, the one being sung fills in letter by letter, and the rest wait dimmed.
*/
@Composable
private fun CurrentLine(line: app.seb3thehacker.gearslip.media.LyricLine, position: () -> Long, lineStyle: androidx.compose.ui.text.TextStyle) {
val scheme = MaterialTheme.colorScheme
val style = lineStyle.copy(fontWeight = FontWeight.Bold, fontSize = lineStyle.fontSize * 1.15f)
if (line.words.isEmpty()) {
Text(line.text.ifEmpty { " " }, style = style, color = scheme.primary)
return
}
val lit = scheme.primary
val waiting = scheme.onSurfaceVariant.copy(alpha = 0.7f)
val now = position()
val text = androidx.compose.ui.text.buildAnnotatedString {
line.words.forEachIndexed { i, word ->
// The line's first word has no leading space to trim, the last no trailing one.
val shown = if (i == line.words.lastIndex) word.text.trimEnd() else word.text
val progress = when {
now >= word.endMs -> 1f
now <= word.startMs -> 0f
else -> (now - word.startMs).toFloat() / (word.endMs - word.startMs).coerceAtLeast(1)
}
val letters = shown.trimEnd().length
val filled = progress * letters
shown.forEachIndexed { c, ch ->
val amount = (filled - c).coerceIn(0f, 1f)
withStyle(androidx.compose.ui.text.SpanStyle(color = androidx.compose.ui.graphics.lerp(waiting, lit, amount))) {
append(ch)
}
}
}
}
Text(text, style = style)
}
@Composable
private fun PlainLyrics(result: LyricsResult, lineStyle: androidx.compose.ui.text.TextStyle, modifier: Modifier) {
LazyColumn(
modifier.fadingEdges(),
contentPadding = PaddingValues(vertical = 24.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(result.lines) { line ->
Text(line.text.ifEmpty { " " }, style = lineStyle, color = MaterialTheme.colorScheme.onSurface)
}
}
}
/** Fades the top and bottom edges out, so a line scrolling past doesn't end in a hard cut. */
private fun Modifier.fadingEdges(): Modifier = this
.graphicsLayer { compositingStrategy = androidx.compose.ui.graphics.CompositingStrategy.Offscreen }
.drawWithContent {
drawContent()
drawRect(
androidx.compose.ui.graphics.Brush.verticalGradient(
0f to androidx.compose.ui.graphics.Color.Transparent,
0.14f to androidx.compose.ui.graphics.Color.Black,
0.86f to androidx.compose.ui.graphics.Color.Black,
1f to androidx.compose.ui.graphics.Color.Transparent,
),
blendMode = androidx.compose.ui.graphics.BlendMode.DstIn,
)
}
private sealed interface LyricsState {
data object Loading : LyricsState
data object Missing : LyricsState
@@ -444,7 +607,7 @@ private sealed interface LyricsState {
}
@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(
Modifier.fillMaxSize().padding(horizontal = 24.dp),
verticalArrangement = Arrangement.Center,
@@ -467,31 +630,103 @@ private fun Notice(title: String, detail: String?, action: Pair<String, () -> Un
// --- now playing -----------------------------------------------------------------------------
/**
* The now-playing view on its own, for places with no tabs around it (an app's own
* now-playing template): the cover on the left, [NowPlayingControls] on the right.
* The now-playing view on its own: an app's own now-playing template, or the Now playing tab on a
* 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
internal fun NowPlayingPage(now: NowPlaying, media: CarMedia, modifier: Modifier) {
Row(
modifier.padding(horizontal = 24.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(24.dp),
) {
CoverArt(rememberArt(now), Modifier.weight(0.8f).fillMaxHeight())
NowPlayingControls(now, media, Modifier.weight(1.2f).fillMaxHeight())
internal fun NowPlayingPage(now: NowPlaying, media: CarMedia, modifier: Modifier, padded: Boolean = true) {
BoxWithConstraints(if (padded) modifier.padding(horizontal = 24.dp, vertical = 8.dp) else modifier) {
val art = rememberArt(now)
if (maxWidth >= 520.dp && maxHeight >= 260.dp) {
val artWidth = minOf(maxHeight, (maxWidth - 24.dp) * 0.4f)
Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.spacedBy(24.dp)) {
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. */
@Composable
private fun CoverArt(art: android.graphics.Bitmap?, modifier: Modifier) {
BoxWithConstraints(modifier, contentAlignment = Alignment.Center) {
private fun CoverArt(art: android.graphics.Bitmap?, modifier: Modifier, alignment: Alignment = Alignment.Center) {
BoxWithConstraints(modifier, contentAlignment = alignment) {
val shape = RoundedCornerShape(22.dp)
Surface(
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surfaceVariant,
shadowElevation = 6.dp,
modifier = Modifier.size(minOf(maxWidth, maxHeight)),
shape = shape,
color = MaterialTheme.colorScheme.surfaceContainerHigh,
modifier = Modifier.size(minOf(maxWidth, maxHeight)).gsPanelShadow(shape),
) {
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 +737,80 @@ private fun CoverArt(art: android.graphics.Bitmap?, modifier: Modifier) {
*/
@Composable
private fun NowPlayingControls(now: NowPlaying, media: CarMedia, modifier: Modifier) {
Column(
modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(14.dp, Alignment.CenterVertically),
) {
Column {
Text(
now.title.ifEmpty { "Nothing playing" },
style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold,
maxLines = 2, overflow = TextOverflow.Ellipsis,
)
Text(
listOf(now.artist, now.album).filter { it.isNotEmpty() }.joinToString(" - "),
style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
now.error?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error, maxLines = 2) }
BoxWithConstraints(modifier) {
val compact = maxHeight < 360.dp || maxWidth < 420.dp
val showTimes = maxHeight >= 260.dp
Column(
Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(if (compact) 8.dp else 14.dp, Alignment.CenterVertically),
) {
// The title gives up its height first and scrolls, so the controls never leave the screen.
Column(Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState())) {
Text(
now.title.ifEmpty { stringResource(R.string.media_nothing_playing) },
style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold,
maxLines = 2, overflow = TextOverflow.Ellipsis,
)
Text(
listOf(now.artist, now.album).filter { it.isNotEmpty() }.joinToString(" - "),
style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
now.error?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error, maxLines = 2) }
if (now.title.isEmpty() && now.error == null) {
Text(
stringResource(R.string.media_press_play),
style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
PlayerProgress(now, media, Modifier.fillMaxWidth(), showTimes = showTimes)
TransportRow(now, media, skip = if (compact) 48.dp else 60.dp, modifier = Modifier.fillMaxWidth())
PlayModeRow(now, media, size = if (compact) 46.dp else 56.dp, modifier = Modifier.fillMaxWidth(), maxOther = 2)
}
PlayerProgress(now, media, Modifier.fillMaxWidth())
TransportRow(now, media, skip = 60.dp, modifier = Modifier.fillMaxWidth())
PlayModeRow(now, media, size = 56.dp, modifier = Modifier.fillMaxWidth(), maxOther = 2)
}
}
/** Below these, the media app drops to one column: see [MediaScreen]. */
private val NARROW_WIDTH = 600.dp
private val NARROW_HEIGHT = 300.dp
/**
* The player beside the browse list, the queue and the lyrics: no art, which the list needs the
* room for, just what's playing, the seek bar and the controls.
* The player beside the browse list, the queue and the lyrics: the cover as big as 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
private fun CompactPlayer(now: NowPlaying, media: CarMedia, modifier: Modifier) {
Column(
modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
Column {
Text(
now.title.ifEmpty { "Nothing playing" },
style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold,
maxLines = 2, overflow = TextOverflow.Ellipsis,
)
Text(
now.artist,
style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
private fun CompactPlayer(now: NowPlaying, media: CarMedia, art: android.graphics.Bitmap?, modifier: Modifier) {
Column(modifier) {
BoxWithConstraints(Modifier.weight(1f).fillMaxWidth()) {
val cover = minOf(maxHeight, maxWidth * 0.62f).coerceAtLeast(0.dp)
Row(Modifier.fillMaxSize(), verticalAlignment = Alignment.Bottom) {
CoverArt(art, Modifier.size(cover))
Spacer(Modifier.width(16.dp))
Column(Modifier.weight(1f).padding(bottom = 4.dp)) {
Text(
now.title.ifEmpty { stringResource(R.string.media_nothing_playing) },
style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold,
maxLines = 3, overflow = TextOverflow.Ellipsis,
)
Text(
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())
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())
}
}
// --- 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()
}
@@ -1,5 +1,7 @@
package app.seb3thehacker.gearslip.car
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import android.os.SystemClock
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
@@ -83,9 +85,10 @@ internal fun ReplyGlyph(modifier: Modifier = Modifier) =
Icon(ImageVector.vectorResource(R.drawable.voice_selection_24), null, modifier)
/** "Alex", or "Alex · 3 new" when a burst of messages from one chat has been merged. */
@Composable
private fun CarNotification.heading(): String {
val who = title.ifBlank { appLabel }
return if (burst > 1) "$who · $burst new" else who
return if (burst > 1) pluralStringResource(R.plurals.msg_burst, burst, who, burst) else who
}
/** The card. It shows the voice reply too, wherever that was started from. */
@@ -106,17 +109,17 @@ fun NotificationPopup(showNew: Boolean, modifier: Modifier = Modifier) {
Text(n.heading(), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(n.text, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
GsIconButton(Icons.Filled.Close, "Dismiss", { CarNotifications.dismissPopup(n.id) }, size = 40.dp, iconSize = 20.dp)
GsIconButton(Icons.Filled.Close, stringResource(R.string.msg_dismiss), { CarNotifications.dismissPopup(n.id) }, size = 40.dp, iconSize = 20.dp)
}
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
GsButton(onClick = { VoiceReply.read(n) }, tone = GsTone.Neutral, modifier = Modifier.weight(1f)) {
ReadGlyph(Modifier.size(20.dp))
Text("Read", Modifier.padding(start = 8.dp))
Text(stringResource(R.string.msg_read), Modifier.padding(start = 8.dp))
}
if (n.reply != null) {
GsButton(onClick = { VoiceReply.reply(n) }, modifier = Modifier.weight(1f)) {
ReplyGlyph(Modifier.size(20.dp))
Text("Reply", Modifier.padding(start = 8.dp))
Text(stringResource(R.string.crumb_reply), Modifier.padding(start = 8.dp))
}
}
}
@@ -191,36 +194,36 @@ private fun VoiceReplyBody(s: VoiceReply.State, n: CarNotification) {
when (s.phase) {
VoiceReply.Phase.READING, VoiceReply.Phase.READ -> {
GsButton(onClick = VoiceReply::cancel, tone = GsTone.Neutral, modifier = wide) {
Text(if (s.phase == VoiceReply.Phase.READING) "Stop" else "Close")
Text(if (s.phase == VoiceReply.Phase.READING) stringResource(R.string.msg_stop) else stringResource(R.string.close))
}
if (n.reply != null) {
GsButton(onClick = { VoiceReply.reply(n, readFirst = false) }, modifier = wide) {
ReplyGlyph(Modifier.size(20.dp))
Text("Reply", Modifier.padding(start = 8.dp))
Text(stringResource(R.string.crumb_reply), Modifier.padding(start = 8.dp))
}
}
}
VoiceReply.Phase.OFFERING -> {
GsButton(onClick = VoiceReply::cancel, tone = GsTone.Neutral, modifier = wide) { Text("No") }
GsButton(onClick = VoiceReply::cancel, tone = GsTone.Neutral, modifier = wide) { Text(stringResource(R.string.msg_no)) }
GsButton(onClick = { VoiceReply.reply(n, readFirst = false) }, modifier = wide) {
ReplyGlyph(Modifier.size(20.dp))
Text("Reply", Modifier.padding(start = 8.dp))
Text(stringResource(R.string.crumb_reply), Modifier.padding(start = 8.dp))
}
}
VoiceReply.Phase.ASKING, VoiceReply.Phase.LISTENING ->
GsButton(onClick = VoiceReply::cancel, tone = GsTone.Neutral, modifier = wide) { Text("Cancel") }
GsButton(onClick = VoiceReply::cancel, tone = GsTone.Neutral, modifier = wide) { Text(stringResource(R.string.cancel)) }
VoiceReply.Phase.CONFIRMING -> {
GsButton(onClick = VoiceReply::cancel, tone = GsTone.Neutral, modifier = wide) { Text("Cancel") }
GsButton(onClick = VoiceReply::sendNow, modifier = wide) { Text("Send now") }
GsButton(onClick = VoiceReply::cancel, tone = GsTone.Neutral, modifier = wide) { Text(stringResource(R.string.cancel)) }
GsButton(onClick = VoiceReply::sendNow, modifier = wide) { Text(stringResource(R.string.msg_send_now)) }
}
VoiceReply.Phase.ERROR -> {
GsButton(onClick = VoiceReply::cancel, tone = GsTone.Neutral, modifier = wide) { Text("Close") }
GsButton(onClick = VoiceReply::cancel, tone = GsTone.Neutral, modifier = wide) { Text(stringResource(R.string.close)) }
if (n.reply != null) {
GsButton(onClick = { VoiceReply.reply(n, readFirst = false) }, tone = GsTone.Tonal, modifier = wide) { Text("Try again") }
GsButton(onClick = { VoiceReply.reply(n, readFirst = false) }, tone = GsTone.Tonal, modifier = wide) { Text(stringResource(R.string.update_try_again)) }
GsButton(
onClick = { VoiceReply.cancel(); navigator.notifications(replyTo = n.key) },
tone = GsTone.Neutral, modifier = wide,
) { Text("Type") }
) { Text(stringResource(R.string.msg_type)) }
}
}
VoiceReply.Phase.SENT, VoiceReply.Phase.IDLE -> Unit
@@ -228,13 +231,14 @@ private fun VoiceReplyBody(s: VoiceReply.State, n: CarNotification) {
}
}
@Composable
private fun voiceHeading(s: VoiceReply.State, who: String): String = when (s.phase) {
VoiceReply.Phase.READING, VoiceReply.Phase.READ -> who
VoiceReply.Phase.OFFERING, VoiceReply.Phase.ASKING -> "Reply to $who"
VoiceReply.Phase.LISTENING -> "Listening"
VoiceReply.Phase.CONFIRMING -> "Sending to $who"
VoiceReply.Phase.SENT -> "Sent to $who"
VoiceReply.Phase.ERROR -> "Couldn't reply"
VoiceReply.Phase.OFFERING, VoiceReply.Phase.ASKING -> stringResource(R.string.msg_reply_to, who)
VoiceReply.Phase.LISTENING -> stringResource(R.string.msg_listening)
VoiceReply.Phase.CONFIRMING -> stringResource(R.string.msg_sending_to, who)
VoiceReply.Phase.SENT -> stringResource(R.string.msg_sent_to, who)
VoiceReply.Phase.ERROR -> stringResource(R.string.msg_reply_failed)
VoiceReply.Phase.IDLE -> ""
}
@@ -1,5 +1,8 @@
package app.seb3thehacker.gearslip.car
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import app.seb3thehacker.gearslip.R
import app.seb3thehacker.gearslip.car.theme.*
import android.content.Intent
import android.provider.Settings
@@ -8,6 +11,8 @@ import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
@@ -60,14 +65,14 @@ fun MessagesScreen(packageName: String) {
val navigator = LocalCarNavigator.current
val context = LocalContext.current.applicationContext
val entry = remember(packageName) { findEntry(MESSAGING_PREFIX + packageName) }
val label = entry?.label ?: history.firstOrNull { it.packageName == packageName }?.appLabel ?: "Messages"
val label = entry?.label ?: history.firstOrNull { it.packageName == packageName }?.appLabel ?: stringResource(R.string.msg_messages)
val threads = history.filter { it.packageName == packageName }
LaunchedEffect(history) { CarNotifications.dismissPopupFrom(packageName) }
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
Column(Modifier.fillMaxSize().padding(start = 16.dp, end = 16.dp, top = 16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
GsIconButton(Icons.AutoMirrored.Filled.ArrowBack, "Back", navigator::back)
GsIconButton(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back), navigator::back)
entry?.icon?.let { Image(it, contentDescription = null, modifier = Modifier.size(36.dp).clip(RoundedCornerShape(8.dp))) }
Text(label, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
}
@@ -82,7 +87,7 @@ fun MessagesScreen(packageName: String) {
if (threads.isEmpty()) {
Text(
"No recent messages. New $label messages appear here as they arrive on the phone.",
stringResource(R.string.msg_none_recent, label),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -104,6 +109,7 @@ fun MessagesScreen(packageName: String) {
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun ConversationCard(n: CarNotification, onRead: () -> Unit, onReply: () -> Unit, onMarkRead: () -> Unit) {
var expanded by rememberSaveable(n.key) { mutableStateOf(false) }
@@ -143,40 +149,48 @@ private fun ConversationCard(n: CarNotification, onRead: () -> Unit, onReply: ()
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
)
if (n.replied) {
Icon(
Icons.Filled.Check,
contentDescription = stringResource(R.string.notif_replied),
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 8.dp).size(20.dp),
)
}
}
if (!expanded && lines.size > COLLAPSED_LINES) {
Text(
"${lines.size - COLLAPSED_LINES} earlier",
pluralStringResource(R.plurals.msg_earlier, lines.size - COLLAPSED_LINES, lines.size - COLLAPSED_LINES),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
)
}
shown.forEach { MessageLine(it, showSender = n.isGroup) }
// Below the messages and side by side, so the text keeps the card's whole width.
FlowRow(
Modifier.padding(top = 6.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
GsButton(onClick = onRead, tone = GsTone.Neutral) {
ReadGlyph(Modifier.size(20.dp))
Text(stringResource(R.string.msg_read), Modifier.padding(start = 8.dp))
}
if (n.reply != null) {
GsButton(onClick = onReply, tone = GsTone.Tonal) {
ReplyGlyph(Modifier.size(20.dp))
Text(stringResource(R.string.crumb_reply), Modifier.padding(start = 8.dp))
}
GsButton(onClick = { quick = !quick }, tone = GsTone.Neutral) {
Text(stringResource(if (quick) R.string.msg_hide_quick else R.string.msg_quick))
}
}
if (n.markRead != null) {
GsButton(onClick = onMarkRead, tone = GsTone.Neutral) { Text(stringResource(R.string.msg_mark_read)) }
}
}
if (quick) QuickReplies(n, Modifier.padding(top = 6.dp))
}
Column(horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(8.dp)) {
if (n.replied) {
Icon(Icons.Filled.Check, contentDescription = "Replied", tint = MaterialTheme.colorScheme.primary)
}
GsButton(onClick = onRead, tone = GsTone.Neutral) {
ReadGlyph(Modifier.size(20.dp))
Text("Read", Modifier.padding(start = 8.dp))
}
if (n.reply != null) {
GsButton(onClick = onReply, tone = GsTone.Tonal) {
ReplyGlyph(Modifier.size(20.dp))
Text("Reply", Modifier.padding(start = 8.dp))
}
}
if (n.reply != null) {
GsButton(onClick = { quick = !quick }, tone = GsTone.Neutral) {
Text(if (quick) "Hide quick replies" else "Quick reply")
}
}
if (n.markRead != null) {
GsButton(onClick = onMarkRead, tone = GsTone.Neutral) { Text("Mark read") }
}
}
}
}
}
@@ -184,8 +198,8 @@ private fun ConversationCard(n: CarNotification, onRead: () -> Unit, onReply: ()
@Composable
private fun MessageLine(line: ChatLine, showSender: Boolean) {
val who = when {
line.sender == null -> "You: "
showSender -> "${line.sender}: "
line.sender == null -> stringResource(R.string.msg_you)
showSender -> stringResource(R.string.msg_sender, line.sender)
else -> ""
}
Text(
@@ -1,5 +1,8 @@
package app.seb3thehacker.gearslip.car
import androidx.annotation.StringRes
import androidx.compose.ui.res.stringResource
import app.seb3thehacker.gearslip.R
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@@ -65,26 +68,32 @@ private val CarGlyph: ImageVector = ImageVector.Builder("Car", 24.dp, 24.dp, 24f
*/
internal class BuiltInApp(
val id: String,
/** English, for matching what a driver says; [labelRes] is what the screen shows. */
val label: String,
@param:StringRes val labelRes: Int,
val glyph: ImageVector,
val screen: CarScreen?,
val open: (CarNavigator) -> Unit,
)
internal object BuiltInApps {
val Web = BuiltInApp("web", "Browser", Icons.Filled.Search, CarScreen.App("web")) { it.open("web") }
val ScreenSharing = BuiltInApp("phone", "Screen sharing", Icons.Filled.Share, CarScreen.App("phone")) { it.open("phone") }
val Phone = BuiltInApp("dialer", "Phone", Icons.Filled.Call, CarScreen.App("dialer")) { it.open("dialer") }
val VehicleData = BuiltInApp("vehicle", "Vehicle data", Icons.Filled.Info, CarScreen.VehicleData) { it.vehicleData() }
val Settings = BuiltInApp("settings", "Settings", Icons.Filled.Settings, CarScreen.Settings) { it.settings() }
val Web = BuiltInApp("web", "Browser", R.string.app_browser, Icons.Filled.Search, CarScreen.App("web")) { it.open("web") }
val ScreenSharing = BuiltInApp("phone", "Screen sharing", R.string.app_screen_sharing, Icons.Filled.Share, CarScreen.App("phone")) { it.open("phone") }
val Phone = BuiltInApp("dialer", "Phone", R.string.carset_phone, Icons.Filled.Call, CarScreen.App("dialer")) { it.open("dialer") }
val VehicleData = BuiltInApp("vehicle", "Vehicle data", R.string.car_vehicle_data, Icons.Filled.Info, CarScreen.VehicleData) { it.vehicleData() }
val Settings = BuiltInApp("settings", "Settings", R.string.settings_title, Icons.Filled.Settings, CarScreen.Settings) { it.settings() }
/** Hands the screen back to the car's own interface, like Android Auto's Exit. */
val Exit = BuiltInApp("exit", "Exit", CarGlyph, screen = null) { CarFocus.exitToCar() }
val Exit = BuiltInApp("exit", "Exit", R.string.app_exit, CarGlyph, screen = null) { CarFocus.exitToCar() }
val all = listOf(Web, ScreenSharing, Phone, VehicleData, Settings, Exit)
val entries: List<Entry> = all.map { Entry(it.label, icon = null, builtIn = it) }
}
/** The name a driver sees: translated for Gearslip's own apps, the app's own name otherwise. */
@Composable
internal fun Entry.shownLabel(): String = builtIn?.let { stringResource(it.labelRes) } ?: label
/** Vehicle data is experimental: raw, unverified sensor labels (see [CarSensors]). */
@Composable
internal fun rememberVehicleDataShown(): Boolean {
@@ -215,7 +224,7 @@ internal fun AppMenu(navigator: CarNavigator, running: Map<String, RunningSlot>)
entry.icon?.let { Image(it, null, Modifier.size(44.dp).clip(RoundedCornerShape(10.dp))) }
entry.builtIn?.let { Icon(it.glyph, null, Modifier.size(36.dp)) }
Text(
entry.label,
entry.shownLabel(),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(start = 14.dp),
@@ -232,9 +241,9 @@ internal fun AppMenu(navigator: CarNavigator, running: Map<String, RunningSlot>)
) {
Text(
when {
isPinned -> "Unpin from nav bar"
barFull -> "Nav bar is full (${CarSettings.MAX_PINNED} pinned)"
else -> "Pin to nav bar"
isPinned -> stringResource(R.string.navapps_unpin)
barFull -> stringResource(R.string.navapps_full, CarSettings.MAX_PINNED)
else -> stringResource(R.string.navapps_pin)
},
style = MaterialTheme.typography.titleMedium,
)
@@ -248,13 +257,13 @@ internal fun AppMenu(navigator: CarNavigator, running: Map<String, RunningSlot>)
},
tone = GsTone.Danger,
modifier = Modifier.fillMaxWidth().height(56.dp),
) { Text("Close app", style = MaterialTheme.typography.titleMedium) }
) { Text(stringResource(R.string.navapps_close), style = MaterialTheme.typography.titleMedium) }
}
GsButton(
onClick = navigator::dismissAppMenu,
tone = GsTone.Neutral,
modifier = Modifier.fillMaxWidth().height(56.dp),
) { Text("Cancel", style = MaterialTheme.typography.titleMedium) }
) { Text(stringResource(R.string.cancel), style = MaterialTheme.typography.titleMedium) }
}
}
}
@@ -281,19 +290,19 @@ private fun MoveButtons(id: String) {
tone = GsTone.Tonal,
enabled = index > 0,
modifier = Modifier.weight(1f).height(56.dp),
) { Text("Move left", style = MaterialTheme.typography.titleMedium) }
) { Text(stringResource(R.string.navapps_move_left), style = MaterialTheme.typography.titleMedium) }
GsButton(
onClick = { move(1) },
tone = GsTone.Tonal,
enabled = index < order.lastIndex,
modifier = Modifier.weight(1f).height(56.dp),
) { Text("Move right", style = MaterialTheme.typography.titleMedium) }
) { Text(stringResource(R.string.navapps_move_right), style = MaterialTheme.typography.titleMedium) }
}
if (custom.isNotEmpty()) {
GsButton(
onClick = { CarSettings.setAppOrder(emptyList()) },
tone = GsTone.Neutral,
modifier = Modifier.fillMaxWidth().height(56.dp),
) { Text("Reset app order", style = MaterialTheme.typography.titleMedium) }
) { Text(stringResource(R.string.navapps_reset), style = MaterialTheme.typography.titleMedium) }
}
}
@@ -1,5 +1,7 @@
package app.seb3thehacker.gearslip.car
import androidx.compose.ui.res.stringResource
import app.seb3thehacker.gearslip.R
import app.seb3thehacker.gearslip.car.theme.*
import android.content.Intent
import android.provider.Settings
@@ -79,12 +81,12 @@ fun NotificationsSection() {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
"Notifications",
stringResource(R.string.notif_title),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.weight(1f),
)
if (history.isNotEmpty()) TextButton(onClick = CarNotifications::clear) { Text("Clear all") }
if (history.isNotEmpty()) TextButton(onClick = CarNotifications::clear) { Text(stringResource(R.string.notif_clear_all)) }
}
if (!listening) {
@@ -97,7 +99,7 @@ fun NotificationsSection() {
if (history.isEmpty()) {
Text(
"Nothing yet. New notifications from the phone will show up here.",
stringResource(R.string.notif_empty),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -124,11 +126,11 @@ internal fun AccessBanner(onOpen: () -> Unit) {
) {
Icon(Icons.Filled.Warning, contentDescription = null)
Text(
"Notifications are off. Allow Gearslip under Settings > Notifications > Notification access on the phone.",
stringResource(R.string.notif_off),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
)
GsButton(onClick = onOpen) { Text("Open") }
GsButton(onClick = onOpen) { Text(stringResource(R.string.guide_open)) }
}
}
}
@@ -160,10 +162,10 @@ private fun NotificationRow(n: CarNotification, onReply: (CarNotification) -> Un
}
}
when {
n.replied -> Icon(Icons.Filled.Check, contentDescription = "Replied", tint = MaterialTheme.colorScheme.primary)
n.replied -> Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.notif_replied), tint = MaterialTheme.colorScheme.primary)
n.reply != null -> GsButton(onClick = { VoiceReply.reply(n) }, tone = GsTone.Tonal) {
ReplyGlyph(Modifier.size(20.dp))
Text("Reply", Modifier.padding(start = 8.dp))
Text(stringResource(R.string.crumb_reply), Modifier.padding(start = 8.dp))
}
}
}
@@ -198,14 +200,14 @@ private fun ReplyPane(n: CarNotification, onDone: () -> Unit) {
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
GsIconButton(Icons.AutoMirrored.Filled.ArrowBack, "Back", onDone)
GsIconButton(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back), onDone)
Surface(
shape = RoundedCornerShape(24.dp),
color = MaterialTheme.colorScheme.surfaceContainerHighest,
modifier = Modifier.weight(1f),
) {
Text(
if (text.isEmpty()) "Reply to ${n.title.ifBlank { n.appLabel }}" else "$text|",
if (text.isEmpty()) stringResource(R.string.msg_reply_to, n.title.ifBlank { n.appLabel }) else "$text|",
color = if (text.isEmpty()) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
@@ -1,5 +1,7 @@
package app.seb3thehacker.gearslip.car
import androidx.compose.ui.res.stringResource
import app.seb3thehacker.gearslip.R
import app.seb3thehacker.gearslip.car.theme.*
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
@@ -52,8 +54,8 @@ fun PhoneAppsScreen() {
value = PhoneApps.cached() ?: withContext(Dispatchers.IO) { PhoneApps.installed(context).also { PhoneApps.warm(context) } }
}
Column(Modifier.fillMaxSize().padding(horizontal = 20.dp, vertical = 12.dp)) {
ScreenHeader("Screen sharing")
Column(Modifier.fillMaxSize().padding(start = 20.dp, end = 20.dp, top = 12.dp)) {
ScreenHeader(stringResource(R.string.app_screen_sharing))
Spacer(Modifier.height(12.dp))
if (!TouchRelayService.enabled) {
TouchRelayWarning()
@@ -63,7 +65,7 @@ fun PhoneAppsScreen() {
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 108.dp),
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 8.dp),
contentPadding = PaddingValues(bottom = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
@@ -94,11 +96,11 @@ private fun TouchRelayWarning() {
) {
Icon(Icons.Filled.Warning, contentDescription = null)
Text(
"Touch is off. Enable Gearslip under Settings > Accessibility on the phone.",
stringResource(R.string.phoneapps_touch_off),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
)
GsButton(onClick = { PhoneApps.openAccessibilitySettings(context) }) { Text("Open") }
GsButton(onClick = { PhoneApps.openAccessibilitySettings(context) }) { Text(stringResource(R.string.guide_open)) }
}
}
}
@@ -1,5 +1,7 @@
package app.seb3thehacker.gearslip.car
import androidx.compose.ui.res.stringResource
import app.seb3thehacker.gearslip.R
import app.seb3thehacker.gearslip.car.theme.*
import android.Manifest
import android.content.Context
@@ -8,9 +10,11 @@ import android.content.pm.PackageManager
import android.net.Uri
import android.provider.ContactsContract
import android.provider.Settings
import app.seb3thehacker.gearslip.GearslipLog
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -80,14 +84,24 @@ internal fun loadContacts(context: Context): List<Contact> {
return out.distinctBy { it.number }
}
/**
* Places the call through Telecom directly. Starting ACTION_CALL from here launched the system's
* call activity onto the car's display, which Android refuses for another app's activity, and
* from the background, which it blocks too - so the call never went out.
*/
internal fun placeCall(context: Context, number: String) {
if (number.isBlank()) return
if (context.checkSelfPermission(Manifest.permission.CALL_PHONE) != PackageManager.PERMISSION_GRANTED) return
runCatching {
context.startActivity(
Intent(Intent.ACTION_CALL, Uri.parse("tel:${Uri.encode(number)}")).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
)
if (context.checkSelfPermission(Manifest.permission.CALL_PHONE) != PackageManager.PERMISSION_GRANTED) {
GearslipLog.w("calls: can't place a call without CALL_PHONE")
return
}
val telecom = context.getSystemService(android.telecom.TelecomManager::class.java)
runCatching { telecom!!.placeCall(Uri.fromParts("tel", number, null), null) }
.onSuccess {
GearslipLog.i("calls: placing a call")
app.seb3thehacker.gearslip.call.CarCalls.outgoingCall(context, number)
}
.onFailure { GearslipLog.w("calls: placing a call failed: ${it.message}") }
}
/** A call another car screen wants to place - a number on a place page - waiting on the driver. */
@@ -122,45 +136,48 @@ private const val TAB_KEYPAD = 1
@Composable
fun PhoneDialerScreen() {
val context = LocalContext.current
val hasPermissions = hasCallPermissions(context)
var hasPermissions by remember { mutableStateOf(hasCallPermissions(context)) }
var tab by remember { mutableStateOf(TAB_CONTACTS) }
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
Column(Modifier.fillMaxSize().padding(start = 16.dp, end = 16.dp, top = 16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
// The tabs share the title's row, so the list and keypad get the height back.
ScreenHeader("Phone") {
ScreenHeader(stringResource(R.string.carset_phone)) {
if (hasPermissions) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
DialerTab("Contacts", tab == TAB_CONTACTS, Modifier.width(140.dp)) { tab = TAB_CONTACTS }
DialerTab("Keypad", tab == TAB_KEYPAD, Modifier.width(140.dp)) { tab = TAB_KEYPAD }
DialerTab(stringResource(R.string.dialer_contacts), tab == TAB_CONTACTS, Modifier.width(140.dp)) { tab = TAB_CONTACTS }
DialerTab(stringResource(R.string.dialer_keypad), tab == TAB_KEYPAD, Modifier.width(140.dp)) { tab = TAB_KEYPAD }
}
}
}
if (!hasPermissions) {
MissingPermission(context)
MissingPermission(context) { hasPermissions = true }
return@Column
}
Box(Modifier.weight(1f).fillMaxWidth()) {
if (tab == TAB_CONTACTS) ContactsList(context) else Keypad(context)
if (tab == TAB_CONTACTS) ContactsList(context) else Box(Modifier.padding(bottom = 16.dp)) { Keypad(context) }
}
}
}
@Composable
private fun MissingPermission(context: Context) {
private fun MissingPermission(context: Context, onGranted: () -> Unit) {
// Android's dialog can't show on the car screen, so it opens on the phone; checking again
// every second picks the answer up without the driver having to come back in.
androidx.compose.runtime.LaunchedEffect(Unit) {
while (!hasCallPermissions(context)) kotlinx.coroutines.delay(1_000)
onGranted()
}
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
"Gearslip needs the Phone and Contacts permissions to dial from here. Grant them " +
"from the app's settings.",
stringResource(R.string.dialer_needs_permission),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
GsButton(tone = GsTone.Tonal, onClick = {
context.startActivity(
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", context.packageName, null)),
)
}) { Text("Open app settings") }
app.seb3thehacker.gearslip.PermissionActivity.ask(context, CALL_PERMISSIONS)
}) { Text(stringResource(R.string.dialer_ask)) }
}
}
@@ -196,7 +213,7 @@ private fun Keypad(context: Context) {
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
number.ifEmpty { "Enter a number" },
number.ifEmpty { stringResource(R.string.dialer_enter_number) },
style = MaterialTheme.typography.headlineSmall,
color = if (number.isEmpty()) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface,
maxLines = 1,
@@ -225,7 +242,7 @@ private fun Keypad(context: Context) {
colors = gsColors(GsTone.Primary),
enabled = number.isNotEmpty(),
modifier = Modifier.weight(1f).fillMaxWidth(),
) { Icon(Icons.Filled.Call, "Call") }
) { Icon(Icons.Filled.Call, stringResource(R.string.dialer_call)) }
}
}
}
@@ -267,16 +284,16 @@ private fun ContactsList(context: Context) {
Box(Modifier.weight(1f).fillMaxWidth()) {
when {
contacts.isEmpty() -> Text(
"No contacts with a phone number found.",
stringResource(R.string.dialer_no_contacts),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
filtered.isEmpty() -> Text(
"No contacts match \"$query\".",
stringResource(R.string.dialer_no_match, query),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
else -> LazyColumn(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
else -> LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(bottom = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(filtered) { contact ->
ContactRow(contact) { pendingCall = contact }
}
@@ -329,14 +346,14 @@ private fun ConfirmCallOverlay(contact: Contact, onConfirm: () -> Unit, onDismis
verticalArrangement = Arrangement.spacedBy(20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text("Call ${contact.name}?", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
Text(stringResource(R.string.dialer_call_who, contact.name), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
Text(contact.number, style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(16.dp)) {
GsButton(onClick = onDismiss, tone = GsTone.Outline, modifier = Modifier.weight(1f).height(56.dp)) {
Text("Cancel", style = MaterialTheme.typography.titleMedium)
Text(stringResource(R.string.cancel), style = MaterialTheme.typography.titleMedium)
}
GsButton(onClick = onConfirm, modifier = Modifier.weight(1f).height(56.dp)) {
Text("Call", style = MaterialTheme.typography.titleMedium)
Text(stringResource(R.string.dialer_call), style = MaterialTheme.typography.titleMedium)
}
}
}
@@ -353,7 +370,7 @@ private fun ContactSearchField(query: String, active: Boolean, onToggle: () -> U
modifier = Modifier.fillMaxWidth(),
) {
Text(
query.ifEmpty { "Search contacts" },
query.ifEmpty { stringResource(R.string.dialer_search) },
style = MaterialTheme.typography.bodyLarge,
color = if (query.isEmpty()) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface,
maxLines = 1,
@@ -391,7 +408,7 @@ private fun ContactRow(contact: Contact, onClick: () -> Unit) {
Text(contact.name, style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(contact.number, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Icon(Icons.Filled.Call, contentDescription = "Call", tint = MaterialTheme.colorScheme.primary)
Icon(Icons.Filled.Call, contentDescription = stringResource(R.string.dialer_call), tint = MaterialTheme.colorScheme.primary)
}
}
}
@@ -1,5 +1,7 @@
package app.seb3thehacker.gearslip.car
import androidx.compose.ui.res.stringResource
import app.seb3thehacker.gearslip.R
import android.media.session.PlaybackState
import android.support.v4.media.session.PlaybackStateCompat
import androidx.compose.foundation.layout.Arrangement
@@ -94,23 +96,23 @@ internal fun formatDuration(ms: Long): String {
return if (hours > 0) "%d:%02d:%02d".format(hours, minutes, seconds) else "%d:%02d".format(minutes, seconds)
}
/** Previous, play/pause, next. [skip] sizes the outer two; play is a step bigger. */
/** Previous, play/pause, next. [skip] sizes the outer two; play is [playScale] times bigger. */
@Composable
internal fun TransportRow(now: NowPlaying, media: CarMedia, skip: Dp, modifier: Modifier = Modifier) {
val play = skip * 1.25f
internal fun TransportRow(now: NowPlaying, media: CarMedia, skip: Dp, modifier: Modifier = Modifier, playScale: Float = 1.25f) {
val play = skip * playScale
Row(modifier, horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.CenterVertically) {
GsIconButton(
MediaIcons.Previous, "Previous", media::previous,
MediaIcons.Previous, stringResource(R.string.player_previous), media::previous,
enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_PREVIOUS), size = skip, iconSize = skip * 0.58f, shape = CircleShape,
)
GsIconButton(
if (now.playing) MediaIcons.Pause else Icons.Filled.PlayArrow,
if (now.playing) "Pause" else "Play",
stringResource(if (now.playing) R.string.player_pause else R.string.media_play),
media::togglePlay,
tone = GsTone.Primary, size = play, iconSize = play * 0.58f, shape = CircleShape,
)
GsIconButton(
MediaIcons.Next, "Next", media::next,
MediaIcons.Next, stringResource(R.string.player_next), media::next,
enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_NEXT), size = skip, iconSize = skip * 0.58f, shape = CircleShape,
)
}
@@ -147,7 +149,7 @@ internal fun PlayModeRow(
} else {
GsIconButton(
if (liked == true) Icons.Filled.Favorite else Icons.Filled.FavoriteBorder,
if (liked == true) "Unlike" else "Like",
stringResource(if (liked == true) R.string.player_unlike else R.string.player_like),
media::toggleLike,
enabled = liked != null,
tone = if (liked == true) GsTone.Primary else GsTone.Neutral,
@@ -159,7 +161,7 @@ internal fun PlayModeRow(
CustomActionButton(shuffleAction, media, size, toggle = true)
} else {
GsIconButton(
MediaIcons.Shuffle, if (shuffleOn) "Shuffle off" else "Shuffle on",
MediaIcons.Shuffle, stringResource(if (shuffleOn) R.string.player_shuffle_off else R.string.player_shuffle_on),
media::toggleShuffle,
enabled = shuffleKnown,
tone = if (shuffleOn) GsTone.Primary else GsTone.Neutral,
@@ -172,7 +174,7 @@ internal fun PlayModeRow(
} else {
GsIconButton(
if (modes.repeat == PlaybackStateCompat.REPEAT_MODE_ONE) MediaIcons.RepeatOne else MediaIcons.Repeat,
"Repeat",
stringResource(R.string.player_repeat),
media::cycleRepeat,
enabled = repeatKnown,
tone = if (repeatOn) GsTone.Primary else GsTone.Neutral,
@@ -229,26 +231,48 @@ internal fun rememberArt(now: NowPlaying): android.graphics.Bitmap? {
}
/**
* A background taken from the album art's main colour, in place of [fallback]. Only the hue and a
* capped amount of its saturation come from the art; the lightness stays close to the theme's
* own ([darkLightness] or [lightLightness]), so text drawn in the theme's colours reads the same
* on any cover. Art with no real colour in it keeps [fallback]. Fades between songs rather than
* jumping.
* Three soft pools of the cover's colours: top left, top right and the middle, read off a 3x3
* shrink of it. Still, and redrawn only while a new song's colours fade in, so it costs the video
* stream nothing between songs. Fainter on a light card, where colour shows more. [wide] is for
* a whole screen: the pools gather on its left, behind the cover, and reach less far across it.
*/
@Composable
internal fun rememberArtColor(
art: android.graphics.Bitmap?,
fallback: Color,
darkLightness: Float = 0.2f,
lightLightness: Float = 0.86f,
): Color {
val dark = fallback.luminance() < 0.5f
val tint by produceState<ArtColor.Tint?>(null, art) {
value = art?.let { withContext(Dispatchers.Default) { runCatching { ArtColor.of(it) }.getOrNull() } }
internal fun AlbumGlow(art: android.graphics.Bitmap?, modifier: Modifier, wide: Boolean = false) {
val spots by produceState<List<Color>>(emptyList(), art) {
value = art?.let { kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Default) { glowColours(it) } }.orEmpty()
}
val strength = if (MaterialTheme.colorScheme.surface.luminance() < 0.5f) 0.30f else 0.18f
val fade = tween<Color>(600)
val first by androidx.compose.animation.animateColorAsState(spots.getOrNull(0)?.copy(alpha = strength) ?: Color.Transparent, fade, label = "glow")
val second by androidx.compose.animation.animateColorAsState(spots.getOrNull(1)?.copy(alpha = strength) ?: Color.Transparent, fade, label = "glow")
val third by androidx.compose.animation.animateColorAsState(spots.getOrNull(2)?.copy(alpha = strength * 0.8f) ?: Color.Transparent, fade, label = "glow")
androidx.compose.foundation.Canvas(modifier) {
fun pool(colour: Color, x: Float, y: Float, reach: Float) {
if (colour.alpha <= 0f) return
drawRect(
androidx.compose.ui.graphics.Brush.radialGradient(
listOf(colour, Color.Transparent),
center = androidx.compose.ui.geometry.Offset(size.width * x, size.height * y),
radius = size.width * reach,
),
)
}
if (wide) {
pool(third, 0.22f, 0.8f, 0.35f)
pool(first, 0.1f, 0.25f, 0.42f)
pool(second, 0.3f, 0.45f, 0.38f)
} else {
pool(third, 0.5f, 0.5f, 0.85f)
pool(first, 0.2f, 0.1f, 0.9f)
pool(second, 0.85f, 0.22f, 0.8f)
}
}
val target = tint?.let {
if (dark) Color.hsl(it.hue, it.saturation.coerceIn(0.3f, 0.5f), darkLightness)
else Color.hsl(it.hue, it.saturation.coerceIn(0.35f, 0.55f), lightLightness)
} ?: fallback
return animateColorAsState(target, tween(600), label = "art colour").value
}
/** The cover's colours at its top left, top right and middle. */
private fun glowColours(art: android.graphics.Bitmap): List<Color> {
// A hardware bitmap can't be scaled or read on the CPU, so copy it out first.
val readable = if (art.config == android.graphics.Bitmap.Config.HARDWARE) art.copy(android.graphics.Bitmap.Config.ARGB_8888, false) else art
val small = android.graphics.Bitmap.createScaledBitmap(readable ?: return emptyList(), 3, 3, true)
return listOf(small.getPixel(0, 0), small.getPixel(2, 0), small.getPixel(1, 1)).map { Color(it).copy(alpha = 1f) }
}
@@ -0,0 +1,114 @@
package app.seb3thehacker.gearslip.car
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.location.Geocoder
import android.location.Location
import android.location.LocationManager
import app.seb3thehacker.gearslip.AppSettings
import app.seb3thehacker.gearslip.GearslipLog
import java.util.Locale
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import org.json.JSONArray
import org.json.JSONObject
import kotlin.coroutines.resume
/** One saved place. Home and Work keep a blank [label]; their names come from strings.xml. */
data class SavedPlace(val label: String = "", val address: String = "")
/**
* Up to [COUNT] places a driver can name by voice in any map app: slot [HOME], slot [WORK],
* then three they name themselves. "Navigate home" sends the saved address as the search.
*/
object SavedPlaces {
const val COUNT = 5
const val HOME = 0
const val WORK = 1
private const val KEY = "saved_places"
private val HOME_WORDS = setOf("home", "house", "place", "home address", "my home", "my house", "my place")
private val WORK_WORDS = setOf("work", "office", "job", "work address", "my work", "my office", "my job", "the office")
fun all(context: Context): List<SavedPlace> {
val saved = runCatching {
val array = JSONArray(AppSettings.getString(context, KEY, "[]"))
List(array.length()) { i ->
array.getJSONObject(i).let { SavedPlace(it.optString("label"), it.optString("address")) }
}
}.getOrDefault(emptyList())
return List(COUNT) { saved.getOrNull(it) ?: SavedPlace() }
}
fun set(context: Context, index: Int, place: SavedPlace) {
val next = all(context).toMutableList().also { it[index] = place }
val array = JSONArray()
next.forEach { array.put(JSONObject().put("label", it.label).put("address", it.address)) }
AppSettings.putString(context, KEY, array.toString())
}
/**
* The slot [spoken] names, if any: "home", "my house", "work", or a custom label. Returns the
* slot even when its address is blank, so the assistant can say it isn't set yet.
*/
fun match(context: Context, spoken: String): Pair<Int, SavedPlace>? {
val said = spoken.lowercase().trim()
val places = all(context)
if (said in HOME_WORDS) return HOME to places[HOME]
if (said in WORK_WORDS) return WORK to places[WORK]
val squashed = squash(said.removePrefix("my ").removePrefix("the "))
if (squashed.isEmpty()) return null
places.forEachIndexed { i, place ->
if (i > WORK && place.label.isNotBlank() && squash(place.label) == squashed) return i to place
}
return null
}
private fun squash(s: String) = s.lowercase().filter { it.isLetterOrDigit() }
fun hasLocationPermission(context: Context) =
context.checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
context.checkSelfPermission(Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
/** Where the phone is now, as a street address, or as coordinates when there's no geocoder. Null if unknown. */
suspend fun here(context: Context): String? = withContext(Dispatchers.IO) {
val location = locate(context) ?: return@withContext null
val address = runCatching {
if (!Geocoder.isPresent()) return@runCatching null
@Suppress("DEPRECATION")
Geocoder(context, Locale.getDefault()).getFromLocation(location.latitude, location.longitude, 1)
?.firstOrNull()?.getAddressLine(0)
}.getOrNull()
address ?: "%.6f,%.6f".format(Locale.US, location.latitude, location.longitude)
}
private suspend fun locate(context: Context): Location? {
if (!hasLocationPermission(context)) return null
val manager = context.getSystemService(LocationManager::class.java) ?: return null
val provider = listOf(LocationManager.FUSED_PROVIDER, LocationManager.GPS_PROVIDER, LocationManager.NETWORK_PROVIDER)
.firstOrNull { it in manager.allProviders && manager.isProviderEnabled(it) }
// A fresh fix: saving the spot you're standing on wants better than an hour-old one.
val fresh = provider?.let {
withTimeoutOrNull(FIX_TIMEOUT_MS) {
suspendCancellableCoroutine { done ->
runCatching {
manager.getCurrentLocation(it, null, context.mainExecutor) { fix -> done.resume(fix) }
}.onFailure { e ->
GearslipLog.w("places: fix failed: ${e.message}")
done.resume(null)
}
}
}
}
return fresh ?: manager.allProviders
.mapNotNull { runCatching { manager.getLastKnownLocation(it) }.getOrNull() }
.maxByOrNull { it.time }
}
private const val FIX_TIMEOUT_MS = 15_000L
}
@@ -1,5 +1,7 @@
package app.seb3thehacker.gearslip.car
import androidx.compose.ui.res.stringResource
import app.seb3thehacker.gearslip.R
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
@@ -29,7 +31,7 @@ fun ScreenHeader(title: String, modifier: Modifier = Modifier, trailing: @Compos
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
GsIconButton(Icons.AutoMirrored.Filled.ArrowBack, "Back", navigator::back)
GsIconButton(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back), navigator::back)
Text(
title,
style = MaterialTheme.typography.headlineSmall,
@@ -1,7 +1,16 @@
package app.seb3thehacker.gearslip.car
import androidx.compose.ui.res.stringResource
import app.seb3thehacker.gearslip.R
import app.seb3thehacker.gearslip.car.theme.*
import android.graphics.Rect
import androidx.compose.foundation.layout.widthIn
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.geometry.Rect as ComposeRect
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
@@ -10,6 +19,7 @@ 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
@@ -86,87 +96,232 @@ fun TemplateChrome(template: Template?, modifier: Modifier = Modifier) {
* How much of the surface Gearslip's chrome covers, so the app can keep what matters clear of
* it. Expressed as the visible rectangle within a [width] x [height] surface.
*
* Navigation's cards are measured where they land ([measured], from [ChromeCover]); an app
* centres its position marker in this rectangle, so a guess puts the marker under the card.
* A template that isn't map-backed covers the surface completely, and reporting an empty
* rectangle is how the app is told to stop bothering.
*/
fun visibleAreaFor(template: Template?, width: Int, height: Int): Rect = when (template) {
is NavigationTemplate -> Rect(0, 0, width, (height * (1f - ESTIMATE_FRACTION)).toInt())
fun visibleAreaFor(template: Template?, width: Int, height: Int, measured: Rect? = null, cardsAtEnd: Boolean = false): Rect = when (template) {
is NavigationTemplate -> measured ?: Rect(0, 0, width, height)
is MapWithContentTemplate, is MapTemplate, is PlaceListNavigationTemplate,
is RoutePreviewNavigationTemplate, is PlaceListMapTemplate,
-> Rect((width * CONTENT_PANE_FRACTION).toInt(), 0, width, height)
-> measured ?: if (cardsAtEnd) Rect(0, 0, (width * (1 - CARD_COLUMN_FRACTION)).toInt(), height)
else Rect((width * CARD_COLUMN_FRACTION).toInt(), 0, width, height)
null -> Rect(0, 0, width, height)
else -> Rect(0, 0, 0, 0)
}
/** The side of the map a piece of chrome sits against, and so the side it shrinks the visible area from. */
internal enum class CoverEdge { TOP, BOTTOM, START, END }
/**
* Where the chrome over the map actually landed, in window pixels. The map buttons count even
* while faded out, or whatever an app draws in a corner (Waze's speedometer) is under them the
* moment they come back.
*/
internal class ChromeCover {
private val pieces = mutableStateMapOf<String, Pair<CoverEdge, ComposeRect>>()
fun put(key: String, edge: CoverEdge, bounds: ComposeRect) {
val piece = edge to bounds
if (pieces[key] != piece) pieces[key] = piece
}
fun remove(key: String) {
pieces.remove(key)
}
/** What is left of a [width] x [height] surface at [surface] (window pixels), in surface pixels. */
fun visibleWithin(surface: ComposeRect, width: Int, height: Int, skip: CoverEdge? = null): Rect {
var left = 0f
var top = 0f
var right = width.toFloat()
var bottom = height.toFloat()
pieces.values.forEach { (edge, b) ->
if (edge == skip) return@forEach
when (edge) {
CoverEdge.TOP -> top = maxOf(top, b.bottom - surface.top)
CoverEdge.BOTTOM -> bottom = minOf(bottom, b.top - surface.top)
CoverEdge.START -> left = maxOf(left, b.right - surface.left)
CoverEdge.END -> right = minOf(right, b.left - surface.left)
}
}
// Never hand an app nothing to draw in, however the cards end up.
if (right - left < width / 4f) { left = 0f; right = width.toFloat() }
if (bottom - top < height / 4f) { top = 0f; bottom = height.toFloat() }
return Rect(left.toInt(), top.toInt(), right.toInt(), bottom.toInt())
}
}
internal val LocalChromeCover = compositionLocalOf<ChromeCover?> { null }
/**
* The app's cards go to the far edge of the map and its buttons to the near one, set while the
* player is docked on the left: the player, the map and the cards each keep their own space
* instead of the cards crowding up against the player.
*/
internal val LocalCardsAtEnd = compositionLocalOf { false }
/**
* How much of the map's width the docked player floats over, on the side opposite the cards. The
* map itself runs underneath at full size; the app is told this part is covered, and the chrome
* keeps clear of it.
*/
internal val LocalSidePanelInset = compositionLocalOf { 0.dp }
/** The edge the app's cards cover, and the one its map buttons do. */
internal val cardEdge @Composable get() = if (LocalCardsAtEnd.current) CoverEdge.END else CoverEdge.START
internal val buttonEdge @Composable get() = if (LocalCardsAtEnd.current) CoverEdge.START else CoverEdge.END
/**
* The floating nav bar, as far as the app's map is concerned. Its full [height] at once rather
* than following the slide, so the app is told once instead of every frame.
*/
@Composable
internal fun BottomCover(height: androidx.compose.ui.unit.Dp, modifier: Modifier = Modifier) {
Box(modifier.fillMaxWidth().height(height).covers("nav-bar", CoverEdge.BOTTOM))
}
/** Records where this lands, padding included, until it leaves the screen. */
@Composable
internal fun Modifier.covers(key: String, edge: CoverEdge): Modifier {
val cover = LocalChromeCover.current ?: return this
DisposableEffect(cover, key) { onDispose { cover.remove(key) } }
return onGloballyPositioned { cover.put(key, edge, it.boundsInWindow()) }
}
// --- navigation ------------------------------------------------------------------------------
@Composable
private fun NavigationChrome(template: NavigationTemplate, modifier: Modifier) {
val pan by CarServices.nav.panMode.collectAsState()
Box(modifier.fillMaxWidth()) {
if (!pan) (template.navigationInfo as? RoutingInfo)?.let {
RoutingCard(it, Modifier.align(Alignment.TopStart).padding(12.dp).width(320.dp))
val connection = LocalCarAppConnection.current
val pan by connection.panMode.collectAsState()
// Pan mode clears the map for looking around: only the buttons stay.
val routing = (template.navigationInfo as? RoutingInfo)?.takeUnless { pan }
val estimate = template.destinationTravelEstimate?.takeUnless { pan }
NavigationBars(template, routing, estimate, modifier)
}
/**
* Cards down the left, map on the right: the turn on top, the trip along the bottom, the map
* buttons at the right edge. The whole column counts as covered, so the app centres its map in
* what is left over and the car sits right of the cards rather than under them. Pan mode drops
* the cards and gives the map the whole width.
*/
@Composable
private fun NavigationBars(
template: NavigationTemplate,
routing: RoutingInfo?,
estimate: TravelEstimate?,
modifier: Modifier,
) {
val connection = LocalCarAppConnection.current
val pan by connection.panMode.collectAsState()
val atEnd = LocalCardsAtEnd.current
val cardEdge = cardEdge
val buttonEdge = buttonEdge
androidx.compose.foundation.layout.BoxWithConstraints(modifier.fillMaxSize()) {
val column = cardColumnWidth(maxWidth)
val cards = @Composable {
if (routing != null || estimate != null) {
Column(
Modifier.width(column).fillMaxHeight().covers("nav-cards", cardEdge).padding(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
routing?.let { RoutingCard(it, Modifier.fillMaxWidth()) }
Spacer(Modifier.weight(1f))
estimate?.let { TravelEstimateCard(it, Modifier.fillMaxWidth()) }
}
}
}
RightEdgeControls(
if (pan) null else template.actionStrip,
template.mapActionStrip,
Modifier.align(Alignment.TopEnd).padding(12.dp),
)
if (!pan) template.destinationTravelEstimate?.let {
TravelEstimateBar(it, Modifier.align(Alignment.BottomStart).padding(12.dp))
val buttons = @Composable {
RightEdgeControls(
if (pan) null else template.actionStrip,
template.mapActionStrip,
Modifier.padding(12.dp),
mapActionsModifier = Modifier.covers("nav-buttons", buttonEdge),
alignment = if (atEnd) Alignment.Start else Alignment.End,
)
}
Row(Modifier.fillMaxSize()) {
if (atEnd) buttons() else cards()
Spacer(Modifier.weight(1f))
if (atEnd) cards() else buttons()
}
}
}
/** The manoeuvre card: what to do next, how far away it is, and onto what road. */
/**
* The manoeuvre, big enough to read at a glance: the arrow and the distance to it, what to do
* and onto which road, the lanes to be in, and the turn after it along the bottom.
*/
@Composable
private fun RoutingCard(info: RoutingInfo, modifier: Modifier = Modifier) {
if (info.isLoading) {
ChromeSurface(modifier) {
Text("Finding a route…", Modifier.padding(16.dp), style = ChromeType.title)
Text(stringResource(R.string.nav_finding_route), Modifier.padding(horizontal = 18.dp, vertical = 16.dp), style = ChromeType.title)
}
return
}
val step = info.currentStep ?: return
val scheme = MaterialTheme.colorScheme
ChromeSurface(modifier) {
Column(Modifier.padding(14.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
CarGlyph(step.maneuver?.icon, Modifier.size(44.dp))
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
val distance = info.currentDistance.display()
if (distance.isNotEmpty()) {
Text(distance, style = ChromeType.headline)
Column {
Column(
Modifier.fillMaxWidth().background(scheme.primaryContainer).padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
androidx.compose.runtime.CompositionLocalProvider(
androidx.compose.material3.LocalContentColor provides scheme.onPrimaryContainer,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
CarGlyph(step.maneuver?.icon, Modifier.size(64.dp))
Spacer(Modifier.width(14.dp))
val distance = info.currentDistance.display()
if (distance.isNotEmpty()) {
Text(
distance,
style = MaterialTheme.typography.displaySmall.copy(fontWeight = androidx.compose.ui.text.font.FontWeight.Bold),
maxLines = 1,
)
}
}
Text(step.cue.text(), style = ChromeType.title, maxLines = 2, overflow = TextOverflow.Ellipsis)
val cue = step.cue.text()
if (cue.isNotEmpty()) {
Text(cue, style = ChromeType.title, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
val road = step.road.text()
// "Turn left onto Queensberry Drive" already says the road; saying it twice is noise.
if (road.isNotEmpty() && !cue.contains(road, ignoreCase = true)) {
Text(
road,
style = ChromeType.body,
color = scheme.onPrimaryContainer.copy(alpha = 0.8f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
if (step.lanesImage != null || step.lanes.orEmpty().isNotEmpty()) {
LaneGuidance(step, Modifier.fillMaxWidth().heightIn(max = 56.dp))
}
// Rare, and only where an arrow can't explain the junction: worth the card growing for.
info.junctionImage?.let { JunctionImage(it) }
}
}
val road = step.road.text()
if (road.isNotEmpty()) {
Text(
road,
style = ChromeType.body,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
// Neither is sent by every app - most say only "turn left" and trust the driver to
// find the lane themselves - so both are drawn only when there is something to draw.
info.junctionImage?.let { junction ->
Spacer(Modifier.height(10.dp))
JunctionImage(junction)
}
LaneGuidance(step, Modifier.padding(top = 10.dp))
info.nextStep?.let { next ->
Spacer(Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Text("then", style = ChromeType.label, color = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.width(8.dp))
CarGlyph(next.maneuver?.icon, Modifier.size(20.dp))
Spacer(Modifier.width(8.dp))
Text(next.cue.text(), style = ChromeType.body, maxLines = 1, overflow = TextOverflow.Ellipsis)
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(stringResource(R.string.nav_then), style = ChromeType.label, color = scheme.onSurfaceVariant)
Spacer(Modifier.width(10.dp))
CarGlyph(next.maneuver?.icon, Modifier.size(28.dp))
val nextText = next.cue.text().ifEmpty { next.road.text() }
if (nextText.isNotEmpty()) {
Spacer(Modifier.width(10.dp))
Text(nextText, style = ChromeType.body, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
}
@@ -221,7 +376,7 @@ private fun LaneTile(lane: Lane) {
) {
Icon(
Icons.Filled.KeyboardArrowUp,
contentDescription = if (recommended) "Recommended lane" else "Lane",
contentDescription = stringResource(if (recommended) R.string.nav_lane_recommended else R.string.nav_lane),
tint = if (recommended) MaterialTheme.colorScheme.onPrimaryContainer
else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(24.dp).rotate(shown.turnDegrees()),
@@ -243,63 +398,59 @@ private fun LaneDirection?.turnDegrees(): Float = when (this?.shape) {
}
/**
* The trip at a glance: time and distance left, the clock time it ends at, and - beside them,
* where a thumb reaches - the app's own way to stop navigating. A floating card rather than a
* full-width bar, so it reads as one thing beside the manoeuvre card above it instead of a strip
* that happens to hold three numbers. The close button is Gearslip's own, not the app's: an
* actionStrip's actions carry no flag saying which one (if any) means "stop navigating" - the
* first button here turned out to be a mute toggle, not an exit - so this leaves the app the way
* going Home already does, rather than guess at the app's own controls and risk getting it wrong.
* The trip at a glance, stacked: time left on top, then the clock time it ends at and the
* distance, with Gearslip's own stop button beside them. The close button is Gearslip's, not the
* app's: an actionStrip's actions carry no flag saying which one (if any) means "stop
* navigating" - the first button turned out to be a mute toggle, not an exit - so this ends
* navigation the way going Home already does rather than guess at the app's own controls.
*/
@Composable
private fun TravelEstimateBar(estimate: TravelEstimate, modifier: Modifier = Modifier) {
private fun TravelEstimateCard(estimate: TravelEstimate, modifier: Modifier = Modifier) {
val connection = LocalCarAppConnection.current
val time = remainingTime(estimate.remainingTimeSeconds)
val distance = estimate.remainingDistance.display()
val arrival = estimate.arrivalTimeAtDestination.clockTime()
val trip = estimate.tripText.text()
if (time.isEmpty() && distance.isEmpty() && arrival.isEmpty() && trip.isEmpty()) return
val detail = listOfNotNull(
arrival.takeIf { it.isNotEmpty() },
distance.takeIf { it.isNotEmpty() },
trip.takeIf { it.isNotEmpty() },
).joinToString(" • ")
// The same solid card the manoeuvre card above it uses - one surface for the whole map
// chrome, rather than this one spot styled on its own as a bar over the map.
ChromeSurface(modifier, shadowElevation = 8.dp) {
ChromeSurface(modifier) {
val dark = MaterialTheme.colorScheme.surface.luminanceIsDark()
Row(
Modifier.padding(start = 8.dp, end = 18.dp, top = 10.dp, bottom = 10.dp),
Modifier.padding(start = 18.dp, end = 12.dp, top = 12.dp, bottom = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
// Not a disconnect: the connection stays up and the app decides its own next screen.
// Ending navigation is its own call, separate from a back press - a back press only
// pops the on-screen card, leaving the app still believing it is navigating (so a
// freshly picked destination silently does nothing); this is the call that actually
// clears that flag on the app's side.
GsIconButton(
Icons.Filled.Close,
"Stop navigating",
{ CarServices.nav.stopNavigating() },
colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.colorScheme.onSurface),
size = 40.dp,
iconSize = 20.dp,
shape = CircleShape,
)
Spacer(Modifier.width(10.dp))
Column {
Column(Modifier.weight(1f)) {
if (time.isNotEmpty()) {
Text(
time,
style = ChromeType.headline,
color = estimate.remainingTimeColor.color(dark, MaterialTheme.colorScheme.onSurface),
maxLines = 1,
)
}
val detail = listOfNotNull(
arrival.takeIf { it.isNotEmpty() }?.let { stringResource(R.string.nav_arrive, it) },
distance.takeIf { it.isNotEmpty() },
).joinToString(" • ")
if (detail.isNotEmpty()) {
Text(detail, style = ChromeType.body, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(detail, style = ChromeType.body, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
if (trip.isNotEmpty()) {
Text(trip, style = ChromeType.body, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
// Not a disconnect: the connection stays up and the app decides its own next screen.
// Ending navigation is its own call, separate from a back press - a back press only
// pops the on-screen card, leaving the app still believing it is navigating.
GsIconButton(
Icons.Filled.Close,
stringResource(R.string.nav_stop),
{ connection.stopNavigating() },
colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.colorScheme.onSurface),
size = 52.dp,
iconSize = 26.dp,
shape = CircleShape,
)
}
}
}
@@ -321,16 +472,20 @@ private fun MapPaneLayout(
fullHeight: Boolean = false,
pane: @Composable () -> Unit,
) {
val connection = LocalCarAppConnection.current
// Pan mode hands the whole screen to the map, leaving only its own controls.
val pan by CarServices.nav.panMode.collectAsState()
val pan by connection.panMode.collectAsState()
val edgeActions = (actionStrip?.actions.orEmpty() + mapActionStrip?.actions.orEmpty()).map { it.identity() }.toSet()
val atEnd = LocalCardsAtEnd.current
val cardEdge = cardEdge
androidx.compose.foundation.layout.BoxWithConstraints(modifier.fillMaxWidth()) {
if (!pan) {
ChromeSurface(
Modifier
.align(Alignment.TopStart)
.align(if (atEnd) Alignment.TopEnd else Alignment.TopStart)
.covers("map-pane", cardEdge)
.padding(12.dp)
.width(maxOf(maxWidth * CONTENT_PANE_FRACTION - 12.dp, 232.dp))
.width(cardColumnWidth(maxWidth) - 24.dp)
.let { if (fullHeight) it.height(maxHeight - 24.dp) else it.heightIn(max = maxHeight - 24.dp) },
shape = androidx.compose.foundation.shape.RoundedCornerShape(20.dp),
shadowElevation = 8.dp,
@@ -341,7 +496,8 @@ private fun MapPaneLayout(
RightEdgeControls(
if (pan) null else actionStrip,
mapActionStrip,
Modifier.align(Alignment.TopEnd).padding(12.dp),
Modifier.align(if (atEnd) Alignment.TopStart else Alignment.TopEnd).padding(12.dp),
alignment = if (atEnd) Alignment.Start else Alignment.End,
)
}
}
@@ -421,7 +577,7 @@ private fun RoutePreviewChrome(template: RoutePreviewNavigationTemplate, modifie
)
Box(Modifier.weight(1f, fill = false)) {
if (template.isLoading) {
Text("Finding routes…", Modifier.padding(14.dp), style = ChromeType.title)
Text(stringResource(R.string.nav_finding_routes), Modifier.padding(14.dp), style = ChromeType.title)
} else {
ItemListColumn(template.itemList)
}
@@ -445,17 +601,22 @@ private fun ListPane(
ChromeSurface(modifier, shape = androidx.compose.foundation.shape.RoundedCornerShape(0.dp)) {
Column {
HeaderBar(title, startAction)
if (loading) Text("Loading…", Modifier.padding(14.dp), style = ChromeType.title)
if (loading) Text(stringResource(R.string.media_loading), Modifier.padding(14.dp), style = ChromeType.title)
else ItemListColumn(itemList)
}
}
}
/** Fraction of the surface the content pane covers beside a map. */
private const val CONTENT_PANE_FRACTION = 0.42f
/** Fraction of the surface the travel-estimate bar covers along the bottom. */
private const val ESTIMATE_FRACTION = 0.14f
/**
* The width of the cards beside the map, the same for a place card and for navigation so the
* map doesn't jump sideways when a route starts.
*/
internal fun cardColumnWidth(maxWidth: androidx.compose.ui.unit.Dp): androidx.compose.ui.unit.Dp =
(maxWidth * CARD_COLUMN_FRACTION).coerceIn(280.dp, 440.dp).coerceAtMost(maxWidth)
private const val CARD_COLUMN_FRACTION = 0.4f
/** Search, sign-in and the like are laid out for a whole column, so they keep one. */
private fun Template?.needsFullHeight() =
@@ -1,5 +1,7 @@
package app.seb3thehacker.gearslip.car
import androidx.compose.ui.res.stringResource
import app.seb3thehacker.gearslip.R
import app.seb3thehacker.gearslip.car.theme.*
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.material.icons.filled.Close
@@ -317,7 +319,7 @@ internal fun SearchField(
Spacer(Modifier.width(12.dp))
}
Text(
query.ifEmpty { hint.ifEmpty { "Search" } },
query.ifEmpty { hint.ifEmpty { stringResource(R.string.template_search) } },
style = MaterialTheme.typography.titleMedium,
color = if (query.isEmpty()) muted else MaterialTheme.colorScheme.onSurface,
maxLines = 1,
@@ -327,7 +329,7 @@ internal fun SearchField(
if (onClear != null && query.isNotEmpty()) {
GsIconButton(
androidx.compose.material.icons.Icons.Filled.Close,
"Clear",
stringResource(R.string.storage_clear),
onClear,
colors = GsColors(androidx.compose.ui.graphics.Color.Transparent, muted),
size = 42.dp,
@@ -517,7 +519,8 @@ private val sectionCache = android.util.LruCache<String, List<Item>>(64)
*/
@Composable
private fun SectionedContent(template: SectionedItemTemplate, modifier: Modifier, showActionsInHeader: Boolean = true, tabId: String = "") {
val appPackage = CarServices.nav.appPackage.orEmpty()
val connection = LocalCarAppConnection.current
val appPackage = connection.appPackage.orEmpty()
ContentSurface(modifier) {
// The app's own actions (Spotify's search and Liked Songs shortcut, for two) come from
// template.actions, not the header - but a bottom-of-content row is wrong for a driver
@@ -622,7 +625,7 @@ private fun MediaPlaybackContent(template: MediaPlaybackTemplate, modifier: Modi
} else {
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
Text(
"Nothing playing yet.",
stringResource(R.string.template_nothing_playing),
style = ChromeType.body,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
@@ -646,6 +649,6 @@ private typealias ColumnScopeAlias = androidx.compose.foundation.layout.ColumnSc
@Composable
private fun Loading() {
Box(Modifier.fillMaxWidth().height(80.dp), contentAlignment = Alignment.Center) {
Text("Loading…", style = ChromeType.title)
Text(stringResource(R.string.media_loading), style = ChromeType.title)
}
}
@@ -1,5 +1,8 @@
package app.seb3thehacker.gearslip.car
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.input.pointer.changedToUp
import app.seb3thehacker.gearslip.R
import app.seb3thehacker.gearslip.car.theme.*
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
@@ -34,6 +37,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Call
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@@ -106,7 +110,8 @@ internal fun ChromeSurface(
) {
Surface(
shadowElevation = shadowElevation,
color = MaterialTheme.colorScheme.surface,
// The nav bar's own container colour, so cards over the map match the rest of the car UI.
color = MaterialTheme.colorScheme.surfaceContainer,
contentColor = MaterialTheme.colorScheme.onSurface,
shape = shape,
modifier = modifier,
@@ -217,28 +222,41 @@ internal fun headerTitle(header: Header?, fallback: String): String =
* actions in a row along the top, its map controls stacked under them. Keeping them in one
* column is what stops them colliding on a screen only 480px tall.
*/
/** How visible the map buttons stay once they fade: enough to know they're there. */
private const val RESTING_ALPHA = 0.35f
@Composable
internal fun RightEdgeControls(
actionStrip: ActionStrip?,
mapActionStrip: ActionStrip?,
modifier: Modifier = Modifier,
/** For the stack of map buttons alone, without the row of the app's own above it. */
mapActionsModifier: Modifier = Modifier,
/** End normally; Start when the app's cards have moved to the far edge and these to the near one. */
alignment: Alignment.Horizontal = Alignment.End,
) {
val connection = LocalCarAppConnection.current
val actions = actionStrip?.actions.orEmpty().filter { it.isDrawable() }
val mapActions = mapActionStrip?.actions.orEmpty().filter { it.isDrawable() }
if (actions.isEmpty() && mapActions.isEmpty()) return
// A template that stops offering Pan must not leave the screen stuck in pan mode.
val hasPan = (actions + mapActions).any { it.type == Action.TYPE_PAN }
androidx.compose.runtime.LaunchedEffect(hasPan) { if (!hasPan) CarServices.nav.setPanMode(false) }
androidx.compose.runtime.LaunchedEffect(hasPan) { if (!hasPan) connection.setPanMode(false) }
// Faded out when the screen has been left alone; then they are only a memory of buttons, so
// the first touch on one wakes them rather than pressing it.
val awake = LocalMapControlsWake.current.awake
val pan by CarServices.nav.panMode.collectAsState()
val pan by connection.panMode.collectAsState()
// Kept up while the knob is on one of them, or the ring would fade out along with the button.
var holdsFocus by remember { mutableStateOf(false) }
val visible = awake || pan || holdsFocus
// With the player beside the map there is no room for the app's row and the map controls
// both, so the app's own buttons fold behind one; opening it swaps them in for the map's.
val folded = LocalSidePanelInset.current > 0.dp && actions.size > 1
var open by remember { mutableStateOf(false) }
androidx.compose.runtime.LaunchedEffect(visible, folded) { if (!visible || !folded) open = false }
val alpha by androidx.compose.animation.core.animateFloatAsState(
targetValue = if (visible) 1f else 0f,
targetValue = if (visible) 1f else RESTING_ALPHA,
animationSpec = androidx.compose.animation.core.tween(if (visible) 150 else 500),
label = "mapControls",
)
@@ -255,15 +273,50 @@ internal fun RightEdgeControls(
}
}
},
horizontalAlignment = Alignment.End,
horizontalAlignment = alignment,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
if (actions.isNotEmpty()) {
if (folded) {
val scheme = MaterialTheme.colorScheme
GsIconButton(
Icons.Filled.MoreVert,
stringResource(R.string.car_group_more),
onClick = { open = !open },
colors = GsColors(if (open) scheme.primary else scheme.surfaceContainerHighest, if (open) scheme.onPrimary else scheme.onSurface),
size = ICON_BUTTON_SIZE,
iconSize = ICON_BUTTON_GLYPH,
shape = androidx.compose.foundation.shape.CircleShape,
)
if (open) {
// A tap on any of them closes the stack again; the tap still reaches the button.
Column(
Modifier.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
if (awaitPointerEvent(PointerEventPass.Initial).changes.any { it.changedToUp() }) open = false
}
}
},
horizontalAlignment = alignment,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
actions.forEach { ActionButton(it) }
}
}
} else if (actions.isNotEmpty()) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
actions.forEach { ActionButton(it) }
}
}
mapActions.forEach { ActionButton(it) }
if (mapActions.isNotEmpty() && !(folded && open)) {
Column(
mapActionsModifier,
horizontalAlignment = alignment,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
mapActions.forEach { ActionButton(it) }
}
}
}
}
@@ -278,7 +331,7 @@ internal fun ActionRow(actions: List<Action>, modifier: Modifier = Modifier, lar
}
/** A row with nothing to show: no words, no picture, nothing to tap. */
private fun CarRow.isBlank(): Boolean =
internal fun CarRow.isBlank(): Boolean =
title.text().isBlank() && texts.orEmpty().all { it.text().isBlank() } && image == null &&
toggle == null && onClickDelegate == null && actions.orEmpty().isEmpty()
@@ -288,13 +341,14 @@ private fun CarRow.isBlank(): Boolean =
*/
@Composable
internal fun ActionButton(action: Action, large: Boolean = false, modifier: Modifier = Modifier) {
val connection = LocalCarAppConnection.current
if (action.type == Action.TYPE_APP_ICON) {
AppIconBadge()
return
}
val dark = MaterialTheme.colorScheme.surface.luminanceIsDark()
val title = action.title.text()
val panOn by CarServices.nav.panMode.collectAsState()
val panOn by connection.panMode.collectAsState()
val active = action.type == Action.TYPE_PAN && panOn
val scheme = MaterialTheme.colorScheme
val custom = action.backgroundColor?.type?.let { it != androidx.car.app.model.CarColor.TYPE_DEFAULT } == true
@@ -309,8 +363,8 @@ internal fun ActionButton(action: Action, large: Boolean = false, modifier: Modi
GsIconBox(
onClick = {
if (action.type == Action.TYPE_BACK) CarServices.nav.backPressed()
else if (action.type == Action.TYPE_PAN) CarServices.nav.setPanMode(!panOn)
if (action.type == Action.TYPE_BACK) connection.backPressed()
else if (action.type == Action.TYPE_PAN) connection.setPanMode(!panOn)
else action.onClickDelegate?.click("action")
},
modifier = modifier,
@@ -407,7 +461,7 @@ internal fun AutoStartButton(action: Action, modifier: Modifier = Modifier) {
) {
if (hasGlyph) CarGlyph(action.icon, Modifier.size(24.dp).align(Alignment.CenterStart))
Text(
action.title.text().ifEmpty { "Start" },
action.title.text().ifEmpty { stringResource(R.string.template_start) },
style = ChromeType.title,
maxLines = 1,
)
@@ -421,8 +475,9 @@ private const val AUTO_START_MS = 5_000L
/** The connected app's own launcher icon, which is what the app-icon action stands for. */
@Composable
private fun AppIconBadge() {
val connection = LocalCarAppConnection.current
val context = LocalContext.current
val pkg = CarServices.nav.appPackage
val pkg = connection.appPackage
val icon = remember(pkg) {
pkg?.let {
runCatching {
@@ -497,315 +552,6 @@ internal fun CarGlyph(icon: CarIcon?, modifier: Modifier = Modifier, standard: I
// --- lists, grids and panes ---------------------------------------------------------------------
@Composable
internal fun PaneTitle(title: String) {
if (title.isEmpty()) return
Text(
title,
style = ChromeType.title,
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
)
}
@Composable
internal fun ItemListColumn(
single: ItemList?,
sectioned: List<ItemList>? = null,
modifier: Modifier = Modifier,
) {
val rows = buildList {
single?.items?.filterIsInstance<CarRow>()?.let(::addAll)
sectioned?.forEach { list -> addAll(list.items.filterIsInstance<CarRow>()) }
}
if (rows.isEmpty()) {
val message = single?.noItemsMessage.text()
if (message.isNotEmpty()) Text(message, Modifier.padding(14.dp), style = ChromeType.body)
return
}
// A list the app made selectable is a single-choice list: one option chosen, shown as a radio.
val onSelected = single?.onSelectedDelegate
val chosen = single?.selectedIndex ?: -1
LazyColumn(modifier, contentPadding = PaddingValues(bottom = 8.dp)) {
itemsIndexed(rows) { index, row ->
RowItem(row, onSelected?.let { RowSelection(index == chosen) { it.selected(index) } })
}
}
}
/** A grid of tappable tiles - what apps use for a home screen beside the map. Icons here are as
* often a small menu glyph (Organic Maps' Search, say) as real cover art, so the tile stays
* modest; [GridTile]'s own default is for the shelf case, where every tile is a photo. */
@Composable
internal fun GridItems(list: ItemList?, modifier: Modifier = Modifier) {
val items = list?.items?.filterIsInstance<GridItem>().orEmpty()
if (items.isEmpty()) return
LazyVerticalGrid(
columns = GridCells.Adaptive(84.dp),
modifier = modifier,
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
items(items) { item -> GridTile(item, iconSize = 36.dp) }
}
}
/**
* One cover-art tile: an app's own icon over a title and optional subtitle. Shared by
* [GridItems] (a whole screen of tiles, [iconSize] scaled down there to suit a plain menu glyph)
* and a section's horizontal shelf, which carries the same [GridItem] type but scrolls sideways
* instead of wrapping into a grid and wants its full-size cover art.
*/
@Composable
internal fun GridTile(item: GridItem, modifier: Modifier = Modifier, iconSize: androidx.compose.ui.unit.Dp = 144.dp) {
Column(
modifier
.clickable { item.onClickDelegate.click("grid item") }
.padding(vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
CarGlyph(item.image, Modifier.size(iconSize))
Spacer(Modifier.height(6.dp))
Text(
item.title.text(),
style = ChromeType.label,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
val subtitle = item.text.text()
if (subtitle.isNotEmpty()) {
Text(
subtitle,
style = ChromeType.label,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
/**
* [autoStart] is for a pane floating over a map - a place or a route the app is proposing - where
* a single action counts itself down rather than waiting to be pressed, on the theory that a
* driver glancing at the car screen has already decided. It never applies to more than one
* action: a pane offering a real choice (Start vs. something else) always waits to be tapped.
*/
@Composable
internal fun PaneRows(pane: Pane?, modifier: Modifier = Modifier, autoStart: Boolean = false) {
// Some apps pad a pane with a row of blank strings; drawn, it is just a gap.
val rows = pane?.rows.orEmpty().filterNot { it.isBlank() }
val actions = pane?.actions.orEmpty().filter { it.isDrawable() }
val card = PlaceCard.of(rows)
Column(modifier) {
card.heading?.let { PlaceHeading(it) }
if (card.buttons.isNotEmpty()) RowButtons(card.buttons, Modifier.padding(horizontal = 14.dp, vertical = 6.dp))
if (autoStart && actions.size == 1) {
AutoStartButton(actions.single(), Modifier.padding(horizontal = 14.dp, vertical = 10.dp))
} else {
ActionRow(actions, Modifier.padding(horizontal = 14.dp, vertical = 10.dp), large = true)
}
if (card.heading == null && card.buttons.isEmpty()) {
card.details.forEach { RowItem(it, large = true) }
} else {
card.details.forEach { DetailRow(it, card.heading?.title?.text().orEmpty()) }
}
}
}
/**
* A pane split the way a place page reads: a name on top, the things to do with it as buttons,
* then the facts. Apps send all three as plain rows, so the split is worked out from the rows'
* shape alone. A pane that doesn't fit the shape - all of it tappable, like a menu - gets no
* heading and no buttons, and draws exactly as a plain list of rows.
*/
private class PlaceCard(val heading: CarRow?, val buttons: List<CarRow>, val details: List<CarRow>) {
companion object {
fun of(rows: List<CarRow>): PlaceCard {
// A short tappable row with an icon and nothing under it is a button in all but name:
// MAPS.ME's "Let's go" and "Save".
val buttons = rows.filter {
it.onClickDelegate != null && it.toggle == null && it.image != null &&
it.texts.orEmpty().isEmpty() && it.title.text().length <= 24
}.takeIf { it.size in 1..4 && it.size < rows.size }.orEmpty()
// A first row that can't be tapped and has no icon is the place's name and kind.
val heading = rows.firstOrNull()?.takeIf {
buttons.isNotEmpty() && it.onClickDelegate == null && it.image == null && it.toggle == null
}
return PlaceCard(heading, buttons, rows - buttons.toSet() - listOfNotNull(heading).toSet())
}
}
}
@Composable
private fun PlaceHeading(row: CarRow) {
Column(Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 6.dp)) {
Text(row.title.text(), style = ChromeType.title, maxLines = 2, overflow = TextOverflow.Ellipsis)
row.texts.orEmpty().take(2).forEach {
Text(it.text(), style = ChromeType.body, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
/** Button-shaped rows side by side, the first one filled as the thing most drivers came to do. */
@Composable
private fun RowButtons(rows: List<CarRow>, modifier: Modifier) {
val scheme = MaterialTheme.colorScheme
Row(modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
rows.forEachIndexed { i, row ->
GsIconBox(
onClick = { row.onClickDelegate.click("row") },
modifier = Modifier.weight(1f),
colors = if (i == 0) GsColors(scheme.primary, scheme.onPrimary) else GsColors(scheme.surfaceContainerHighest, scheme.onSurface),
enabled = row.isEnabled,
shape = RoundedCornerShape(24.dp),
) {
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 14.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
CarGlyph(row.image, Modifier.size(24.dp))
Spacer(Modifier.width(8.dp))
Text(row.title.text(), style = ChromeType.body, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
}
}
/**
* One fact about a place, tighter than a list row: a small label over its value, or just the
* value when the row has no label. A phone number gets a Call button that asks first.
*/
@Composable
private fun DetailRow(row: CarRow, placeName: String) {
val context = LocalContext.current
val texts = row.texts.orEmpty().map { it.text() }.filter { it.isNotBlank() }
val label = row.title.text().takeIf { texts.isNotEmpty() }
val values = texts.ifEmpty { listOf(row.title.text()) }
val number = values.firstOrNull { it.isPhoneNumber() }
val canCall = number != null &&
context.checkSelfPermission(android.Manifest.permission.CALL_PHONE) == android.content.pm.PackageManager.PERMISSION_GRANTED
Row(
Modifier
.fillMaxWidth()
.let { if (row.onClickDelegate != null && row.isEnabled) it.clickable { row.onClickDelegate.click("row") } else it }
.padding(horizontal = 14.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
row.image?.let {
CarGlyph(it, Modifier.size(24.dp))
Spacer(Modifier.width(12.dp))
}
Column(Modifier.weight(1f)) {
label?.let {
Text(it, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1)
}
values.take(3).forEach {
Text(it, style = ChromeType.body, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
}
if (canCall) {
Spacer(Modifier.width(8.dp))
GsIconBox(
onClick = { CallPrompt.ask(placeName.ifBlank { number!! }, number!!) },
colors = GsColors(MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.onPrimary),
shape = RoundedCornerShape(24.dp),
) {
Row(Modifier.padding(horizontal = 16.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically) {
androidx.compose.material3.Icon(Icons.Filled.Call, null, Modifier.size(20.dp))
Spacer(Modifier.width(6.dp))
Text("Call", style = ChromeType.body)
}
}
}
ActionRow(row.actions.orEmpty())
}
}
/** A line that is only a phone number: digits with the usual separators, 7 to 15 digits long. */
private fun String.isPhoneNumber(): Boolean {
val t = trim()
if (!Regex("""^\+?[0-9 ().\-]+$""").matches(t)) return false
return t.count { it.isDigit() } in 7..15
}
@Composable
internal fun RowItem(row: CarRow, selection: RowSelection? = null, large: Boolean = false) {
val toggle = row.toggle
// Shown at once and corrected when the app answers with its own template, so the switch
// never lags a tap; keyed on what the app last said so its answer wins.
var checked by remember(toggle?.isChecked) { mutableStateOf(toggle?.isChecked == true) }
val clickable = row.isEnabled && (toggle != null || selection != null || row.onClickDelegate != null)
Row(
Modifier
.fillMaxWidth()
.let {
if (!clickable) it else it.clickable {
when {
toggle != null -> {
checked = !checked
toggle.onCheckedChangeDelegate.checkedChanged(checked)
}
selection != null -> selection.onPick()
else -> row.onClickDelegate.click("row")
}
}
}
.padding(horizontal = 14.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (selection != null) {
RadioButton(selected = selection.selected, onClick = null)
Spacer(Modifier.width(12.dp))
}
row.image?.let {
CarGlyph(it, Modifier.size(28.dp))
Spacer(Modifier.width(12.dp))
}
// large only changes how much room a row gets (more lines, a bigger icon above) - the
// type itself is the same scale as every other row, so a pane's one row and a plain
// list's tenth row read as the same kind of text.
Column(Modifier.weight(1f)) {
Text(
row.title.text(),
style = ChromeType.title,
maxLines = if (large) 2 else 1,
overflow = TextOverflow.Ellipsis,
)
row.texts.orEmpty().take(if (large) 3 else 2).forEach { line ->
Text(
line.text(),
style = ChromeType.body,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = if (large) 2 else 1,
overflow = TextOverflow.Ellipsis,
)
}
}
ActionRow(row.actions.orEmpty())
if (toggle != null) {
Spacer(Modifier.width(8.dp))
Switch(checked = checked, onCheckedChange = null, enabled = row.isEnabled)
}
}
}
/** A row's place in a single-choice list: whether it is the chosen one, and what picking it does. */
internal class RowSelection(val selected: Boolean, val onPick: () -> Unit)
/**
* Whether this colour stands out against the background that [content] was chosen for: at least
* 3:1, the WCAG minimum for icons. The background is taken as the opposite of the content colour.
*/
private fun Color.readableOn(content: Color): Boolean {
val background = if (content.luminanceIsDark()) Color.White else Color(0xFF121212)
val a = luminance() + 0.05f
val b = background.luminance() + 0.05f
return maxOf(a, b) / minOf(a, b) >= 3f
}
internal fun Color.luminanceIsDark(): Boolean =
(red * 0.299f + green * 0.587f + blue * 0.114f) < 0.5f
@@ -0,0 +1,393 @@
package app.seb3thehacker.gearslip.car
import androidx.compose.ui.res.stringResource
import app.seb3thehacker.gearslip.R
import app.seb3thehacker.gearslip.car.theme.*
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.material3.LocalContentColor
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.foundation.Image
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
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.fillMaxHeight
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.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Call
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Warning
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 app.seb3thehacker.gearslip.host.selected
import app.seb3thehacker.gearslip.host.checkedChanged
import androidx.compose.runtime.setValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Switch
import androidx.core.graphics.drawable.toBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.draw.clip
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.draw.alpha
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
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.car.app.model.Action
import androidx.car.app.model.ActionStrip
import androidx.car.app.model.CarIcon
import androidx.car.app.model.GridItem
import androidx.car.app.model.Header
import androidx.car.app.model.ItemList
import androidx.car.app.model.Pane
import androidx.car.app.model.Row as CarRow
import androidx.car.app.model.Template
import app.seb3thehacker.gearslip.host.click
import app.seb3thehacker.gearslip.host.color
import app.seb3thehacker.gearslip.host.image
import app.seb3thehacker.gearslip.host.isDrawable
import app.seb3thehacker.gearslip.host.standardType
import app.seb3thehacker.gearslip.host.text
// Lists, grids, panes and rows of templated apps. Split out of TemplateParts.kt.
@Composable
internal fun PaneTitle(title: String) {
if (title.isEmpty()) return
Text(
title,
style = ChromeType.title,
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
)
}
@Composable
internal fun ItemListColumn(
single: ItemList?,
sectioned: List<ItemList>? = null,
modifier: Modifier = Modifier,
) {
val rows = buildList {
single?.items?.filterIsInstance<CarRow>()?.let(::addAll)
sectioned?.forEach { list -> addAll(list.items.filterIsInstance<CarRow>()) }
}
if (rows.isEmpty()) {
val message = single?.noItemsMessage.text()
if (message.isNotEmpty()) Text(message, Modifier.padding(14.dp), style = ChromeType.body)
return
}
// A list the app made selectable is a single-choice list: one option chosen, shown as a radio.
val onSelected = single?.onSelectedDelegate
val chosen = single?.selectedIndex ?: -1
LazyColumn(modifier, contentPadding = PaddingValues(bottom = 8.dp)) {
itemsIndexed(rows) { index, row ->
RowItem(row, onSelected?.let { RowSelection(index == chosen) { it.selected(index) } })
}
}
}
/** A grid of tappable tiles - what apps use for a home screen beside the map. Icons here are as
* often a small menu glyph (Organic Maps' Search, say) as real cover art, so the tile stays
* modest; [GridTile]'s own default is for the shelf case, where every tile is a photo. */
@Composable
internal fun GridItems(list: ItemList?, modifier: Modifier = Modifier) {
val items = list?.items?.filterIsInstance<GridItem>().orEmpty()
if (items.isEmpty()) return
LazyVerticalGrid(
columns = GridCells.Adaptive(84.dp),
modifier = modifier,
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
items(items) { item -> GridTile(item, iconSize = 36.dp) }
}
}
/**
* One cover-art tile: an app's own icon over a title and optional subtitle. Shared by
* [GridItems] (a whole screen of tiles, [iconSize] scaled down there to suit a plain menu glyph)
* and a section's horizontal shelf, which carries the same [GridItem] type but scrolls sideways
* instead of wrapping into a grid and wants its full-size cover art.
*/
@Composable
internal fun GridTile(item: GridItem, modifier: Modifier = Modifier, iconSize: androidx.compose.ui.unit.Dp = 144.dp) {
Column(
modifier
.clickable { item.onClickDelegate.click("grid item") }
.padding(vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
CarGlyph(item.image, Modifier.size(iconSize))
Spacer(Modifier.height(6.dp))
Text(
item.title.text(),
style = ChromeType.label,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
val subtitle = item.text.text()
if (subtitle.isNotEmpty()) {
Text(
subtitle,
style = ChromeType.label,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
/**
* [autoStart] is for a pane floating over a map - a place or a route the app is proposing - where
* a single action counts itself down rather than waiting to be pressed, on the theory that a
* driver glancing at the car screen has already decided. It never applies to more than one
* action: a pane offering a real choice (Start vs. something else) always waits to be tapped.
*/
@Composable
internal fun PaneRows(pane: Pane?, modifier: Modifier = Modifier, autoStart: Boolean = false) {
// Some apps pad a pane with a row of blank strings; drawn, it is just a gap.
val rows = pane?.rows.orEmpty().filterNot { it.isBlank() }
val actions = pane?.actions.orEmpty().filter { it.isDrawable() }
val card = PlaceCard.of(rows)
Column(modifier) {
card.heading?.let { PlaceHeading(it) }
if (card.buttons.isNotEmpty()) RowButtons(card.buttons, Modifier.padding(horizontal = 14.dp, vertical = 6.dp))
if (autoStart && actions.size == 1) {
AutoStartButton(actions.single(), Modifier.padding(horizontal = 14.dp, vertical = 10.dp))
} else {
ActionRow(actions, Modifier.padding(horizontal = 14.dp, vertical = 10.dp), large = true)
}
if (card.heading == null && card.buttons.isEmpty()) {
card.details.forEach { RowItem(it, large = true) }
} else {
card.details.forEach { DetailRow(it, card.heading?.title?.text().orEmpty()) }
}
}
}
/**
* A pane split the way a place page reads: a name on top, the things to do with it as buttons,
* then the facts. Apps send all three as plain rows, so the split is worked out from the rows'
* shape alone. A pane that doesn't fit the shape - all of it tappable, like a menu - gets no
* heading and no buttons, and draws exactly as a plain list of rows.
*/
private class PlaceCard(val heading: CarRow?, val buttons: List<CarRow>, val details: List<CarRow>) {
companion object {
fun of(rows: List<CarRow>): PlaceCard {
// A short tappable row with an icon and nothing under it is a button in all but name:
// MAPS.ME's "Let's go" and "Save".
val buttons = rows.filter {
it.onClickDelegate != null && it.toggle == null && it.image != null &&
it.texts.orEmpty().isEmpty() && it.title.text().length <= 24
}.takeIf { it.size in 1..4 && it.size < rows.size }.orEmpty()
// A first row that can't be tapped and has no icon is the place's name and kind.
val heading = rows.firstOrNull()?.takeIf {
buttons.isNotEmpty() && it.onClickDelegate == null && it.image == null && it.toggle == null
}
return PlaceCard(heading, buttons, rows - buttons.toSet() - listOfNotNull(heading).toSet())
}
}
}
@Composable
private fun PlaceHeading(row: CarRow) {
Column(Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 6.dp)) {
Text(row.title.text(), style = ChromeType.title, maxLines = 2, overflow = TextOverflow.Ellipsis)
row.texts.orEmpty().take(2).forEach {
Text(it.text(), style = ChromeType.body, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
/** Button-shaped rows side by side, the first one filled as the thing most drivers came to do. */
@Composable
private fun RowButtons(rows: List<CarRow>, modifier: Modifier) {
val scheme = MaterialTheme.colorScheme
Row(modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
rows.forEachIndexed { i, row ->
GsIconBox(
onClick = { row.onClickDelegate.click("row") },
modifier = Modifier.weight(1f),
colors = if (i == 0) GsColors(scheme.primary, scheme.onPrimary) else GsColors(scheme.surfaceContainerHighest, scheme.onSurface),
enabled = row.isEnabled,
shape = RoundedCornerShape(24.dp),
) {
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 14.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
CarGlyph(row.image, Modifier.size(24.dp))
Spacer(Modifier.width(8.dp))
Text(row.title.text(), style = ChromeType.body, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
}
}
/**
* One fact about a place, tighter than a list row: a small label over its value, or just the
* value when the row has no label. A phone number gets a Call button that asks first.
*/
@Composable
private fun DetailRow(row: CarRow, placeName: String) {
val context = LocalContext.current
val texts = row.texts.orEmpty().map { it.text() }.filter { it.isNotBlank() }
val label = row.title.text().takeIf { texts.isNotEmpty() }
val values = texts.ifEmpty { listOf(row.title.text()) }
val number = values.firstOrNull { it.isPhoneNumber() }
val canCall = number != null &&
context.checkSelfPermission(android.Manifest.permission.CALL_PHONE) == android.content.pm.PackageManager.PERMISSION_GRANTED
Row(
Modifier
.fillMaxWidth()
.let { if (row.onClickDelegate != null && row.isEnabled) it.clickable { row.onClickDelegate.click("row") } else it }
.padding(horizontal = 14.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
row.image?.let {
CarGlyph(it, Modifier.size(24.dp))
Spacer(Modifier.width(12.dp))
}
Column(Modifier.weight(1f)) {
label?.let {
Text(it, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1)
}
values.take(3).forEach {
Text(it, style = ChromeType.body, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
}
if (canCall) {
Spacer(Modifier.width(8.dp))
GsIconBox(
onClick = { CallPrompt.ask(placeName.ifBlank { number!! }, number!!) },
colors = GsColors(MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.onPrimary),
shape = RoundedCornerShape(24.dp),
) {
Row(Modifier.padding(horizontal = 16.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically) {
androidx.compose.material3.Icon(Icons.Filled.Call, null, Modifier.size(20.dp))
Spacer(Modifier.width(6.dp))
Text(stringResource(R.string.dialer_call), style = ChromeType.body)
}
}
}
ActionRow(row.actions.orEmpty())
}
}
/** A line that is only a phone number: digits with the usual separators, 7 to 15 digits long. */
private fun String.isPhoneNumber(): Boolean {
val t = trim()
if (!Regex("""^\+?[0-9 ().\-]+$""").matches(t)) return false
return t.count { it.isDigit() } in 7..15
}
@Composable
internal fun RowItem(row: CarRow, selection: RowSelection? = null, large: Boolean = false) {
val toggle = row.toggle
// Shown at once and corrected when the app answers with its own template, so the switch
// never lags a tap; keyed on what the app last said so its answer wins.
var checked by remember(toggle?.isChecked) { mutableStateOf(toggle?.isChecked == true) }
val clickable = row.isEnabled && (toggle != null || selection != null || row.onClickDelegate != null)
Row(
Modifier
.fillMaxWidth()
.let {
if (!clickable) it else it.clickable {
when {
toggle != null -> {
checked = !checked
toggle.onCheckedChangeDelegate.checkedChanged(checked)
}
selection != null -> selection.onPick()
else -> row.onClickDelegate.click("row")
}
}
}
.padding(horizontal = 14.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (selection != null) {
RadioButton(selected = selection.selected, onClick = null)
Spacer(Modifier.width(12.dp))
}
row.image?.let {
CarGlyph(it, Modifier.size(28.dp))
Spacer(Modifier.width(12.dp))
}
// large only changes how much room a row gets (more lines, a bigger icon above) - the
// type itself is the same scale as every other row, so a pane's one row and a plain
// list's tenth row read as the same kind of text.
Column(Modifier.weight(1f)) {
Text(
row.title.text(),
style = ChromeType.title,
maxLines = if (large) 2 else 1,
overflow = TextOverflow.Ellipsis,
)
row.texts.orEmpty().take(if (large) 3 else 2).forEach { line ->
Text(
line.text(),
style = ChromeType.body,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = if (large) 2 else 1,
overflow = TextOverflow.Ellipsis,
)
}
}
ActionRow(row.actions.orEmpty())
if (toggle != null) {
Spacer(Modifier.width(8.dp))
Switch(checked = checked, onCheckedChange = null, enabled = row.isEnabled)
}
}
}
/** A row's place in a single-choice list: whether it is the chosen one, and what picking it does. */
internal class RowSelection(val selected: Boolean, val onPick: () -> Unit)
/**
* Whether this colour stands out against the background that [content] was chosen for: at least
* 3:1, the WCAG minimum for icons. The background is taken as the opposite of the content colour.
*/
internal fun Color.readableOn(content: Color): Boolean {
val background = if (content.luminanceIsDark()) Color.White else Color(0xFF121212)
val a = luminance() + 0.05f
val b = background.luminance() + 0.05f
return maxOf(a, b) / minOf(a, b) >= 3f
}
@@ -1,5 +1,6 @@
package app.seb3thehacker.gearslip.car
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -27,7 +28,7 @@ import androidx.compose.ui.unit.dp
fun VehicleDataScreen() {
val readings by CarSensors.readings.collectAsState()
Column(Modifier.fillMaxSize().padding(horizontal = 24.dp, vertical = 16.dp)) {
Column(Modifier.fillMaxSize().padding(start = 24.dp, end = 24.dp, top = 16.dp)) {
ScreenHeader("Vehicle data")
Text(
"Raw sensor channel from the connected head unit. Field meaning is a best guess - " +
@@ -46,7 +47,7 @@ fun VehicleDataScreen() {
return@Column
}
LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
LazyColumn(contentPadding = PaddingValues(bottom = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(readings, key = { it.typeId }) { reading -> ReadingRow(reading) }
}
}
@@ -1,5 +1,9 @@
package app.seb3thehacker.gearslip.car
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.annotation.StringRes
import app.seb3thehacker.gearslip.R
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
@@ -38,7 +42,7 @@ class WeatherData(
sealed interface WeatherState {
data object Loading : WeatherState
data class Unavailable(val reason: String) : WeatherState
data class Unavailable(@param:StringRes val reason: Int) : WeatherState
data class Ready(val data: WeatherData) : WeatherState
}
@@ -78,7 +82,7 @@ object Weather {
if (flow.value !is WeatherState.Ready) {
val allowed = hasLocationPermission(context)
flow.value = WeatherState.Unavailable(
if (allowed) "Waiting for a location fix." else "Allow location for Gearslip on the phone to see the weather.",
if (allowed) R.string.weather_waiting_fix else R.string.weather_allow_location,
)
}
return
@@ -92,7 +96,7 @@ object Weather {
// No place name: logs get shared in bug reports.
GearslipLog.i("weather: loaded")
}
else if (flow.value !is WeatherState.Ready) flow.value = WeatherState.Unavailable("Could not reach the weather service.")
else if (flow.value !is WeatherState.Ready) flow.value = WeatherState.Unavailable(R.string.weather_unreachable)
}
// --- location -------------------------------------------------------------------------
@@ -197,6 +201,26 @@ object Weather {
)
}
/** Open-Meteo's WMO weather codes, in words on the screen. [describe] is the English the assistant says. */
@StringRes
fun conditionRes(code: Int): Int = when (code) {
0 -> R.string.weather_clear
1 -> R.string.weather_mostly_clear
2 -> R.string.weather_partly_cloudy
3 -> R.string.weather_overcast
45, 48 -> R.string.weather_fog
in 51..57 -> R.string.weather_drizzle
in 61..67 -> R.string.weather_rain
in 71..77 -> R.string.weather_snow
in 80..82 -> R.string.weather_rain_showers
in 85..86 -> R.string.weather_snow_showers
in 95..99 -> R.string.weather_thunderstorm
else -> 0
}
@Composable
fun condition(code: Int): String = conditionRes(code).let { if (it == 0) "-" else stringResource(it) }
/** Open-Meteo's WMO weather codes, in words. */
fun describe(code: Int): String = when (code) {
0 -> "Clear"
@@ -1,5 +1,7 @@
package app.seb3thehacker.gearslip.car
import androidx.compose.ui.res.stringResource
import app.seb3thehacker.gearslip.R
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -41,11 +43,11 @@ fun WeatherScreen() {
Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = 24.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
ScreenHeader("Weather")
ScreenHeader(stringResource(R.string.crumb_weather))
when (val s = state) {
WeatherState.Loading -> Text("Getting the weather…", style = MaterialTheme.typography.titleMedium)
WeatherState.Loading -> Text(stringResource(R.string.weather_getting), style = MaterialTheme.typography.titleMedium)
is WeatherState.Unavailable -> Text(
s.reason, style = MaterialTheme.typography.titleMedium,
stringResource(s.reason), style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
is WeatherState.Ready -> Forecast(s.data)
@@ -66,18 +68,18 @@ private fun Forecast(w: WeatherData) {
WeatherIcon(w.code, w.isDay, size = 88.dp)
Column {
Text("${w.temp}${w.tempUnit}", fontSize = 56.sp, fontWeight = FontWeight.Bold)
Text(Weather.describe(w.code), style = MaterialTheme.typography.titleLarge)
Text(Weather.condition(w.code), style = MaterialTheme.typography.titleLarge)
}
Spacer(Modifier.weight(1f))
Column(horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text("Feels like ${w.feelsLike}${w.tempUnit}", style = MaterialTheme.typography.bodyLarge)
Text("Humidity ${w.humidity}%", style = MaterialTheme.typography.bodyLarge)
Text("Wind ${w.wind} ${w.windUnit}", style = MaterialTheme.typography.bodyLarge)
Text(stringResource(R.string.weather_feels_like, "${w.feelsLike}${w.tempUnit}"), style = MaterialTheme.typography.bodyLarge)
Text(stringResource(R.string.weather_humidity, w.humidity), style = MaterialTheme.typography.bodyLarge)
Text(stringResource(R.string.weather_wind, "${w.wind} ${w.windUnit}"), style = MaterialTheme.typography.bodyLarge)
}
}
}
Text("Next hours", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
Text(stringResource(R.string.weather_next_hours), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
LazyRow(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
items(w.hours) { h ->
Surface(color = MaterialTheme.colorScheme.surfaceContainerHigh, shape = MaterialTheme.shapes.large) {
@@ -101,7 +103,7 @@ private fun Forecast(w: WeatherData) {
}
}
Text("This week", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
Text(stringResource(R.string.weather_this_week), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
w.days.forEachIndexed { i, d ->
Surface(color = MaterialTheme.colorScheme.surfaceContainerHigh, shape = MaterialTheme.shapes.large) {
@@ -111,11 +113,11 @@ private fun Forecast(w: WeatherData) {
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
if (i == 0) "Today" else SimpleDateFormat("EEEE", Locale.getDefault()).format(Date(d.date)),
if (i == 0) stringResource(R.string.weather_today) else SimpleDateFormat("EEEE", Locale.getDefault()).format(Date(d.date)),
Modifier.width(120.dp), style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold,
)
WeatherIcon(d.code, true, size = 28.dp)
Text(Weather.describe(d.code), Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium)
Text(Weather.condition(d.code), Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium)
if (d.rainChance > 0) Text("${d.rainChance}%", color = MaterialTheme.colorScheme.primary)
Text("${d.low}° / ${d.high}°", style = MaterialTheme.typography.bodyLarge)
}
@@ -123,7 +125,7 @@ private fun Forecast(w: WeatherData) {
}
}
Text(
"Weather data by Open-Meteo.com (CC BY 4.0)",
stringResource(R.string.weather_credit),
style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
@@ -1,8 +1,17 @@
package app.seb3thehacker.gearslip.car
import androidx.compose.ui.res.stringResource
import app.seb3thehacker.gearslip.R
import android.os.Handler
import android.os.Looper
import android.view.View
import android.view.ViewGroup
import android.webkit.JavascriptInterface
import android.webkit.WebChromeClient
import android.widget.FrameLayout
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.LaunchedEffect
import app.seb3thehacker.gearslip.car.theme.GsIconButton
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
@@ -24,6 +33,10 @@ import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.viewinterop.AndroidView
import app.seb3thehacker.gearslip.GearslipLog
import org.json.JSONObject
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.automirrored.filled.ArrowForward
/**
* A browser inside the car UI. [content] is a URL, or raw HTML when it does not look like one.
@@ -43,11 +56,35 @@ fun WebApp(content: String) {
val ring = String.format("#%06X", MaterialTheme.colorScheme.primary.toArgb() and 0xFFFFFF)
var web by remember { mutableStateOf<WebView?>(null) }
var fieldText by remember { mutableStateOf<String?>(null) }
// A page's own full screen, such as a video's: the view to show, and how to hand it back.
var pageFullscreen by remember { mutableStateOf<Pair<View, WebChromeClient.CustomViewCallback>?>(null) }
// Whether the video asked for full screen, rather than the driver from the header: ending
// the video's then leaves full screen too, instead of stranding them in it.
var videoMadeFullscreen by remember { mutableStateOf(false) }
val fullscreen = CarFullscreen.active
// What the header shows and what its buttons can do, refreshed as pages load.
var pageTitle by remember { mutableStateOf("") }
var canGoForward by remember { mutableStateOf(false) }
// The address being typed, or null while the header shows the page title.
var address by remember { mutableStateOf<String?>(null) }
// Leaving the browser ends full screen, whoever started it.
DisposableEffect(Unit) { onDispose { CarFullscreen.exit() } }
// The way out of full screen also ends the page's.
LaunchedEffect(fullscreen) {
if (!fullscreen) pageFullscreen?.let { (_, callback) ->
pageFullscreen = null
callback.onCustomViewHidden()
}
}
DisposableEffect(web) {
navigator.backInterceptor = {
val view = web
if (view != null && view.canGoBack()) {
if (CarFullscreen.active) {
CarFullscreen.exit()
true
} else if (view != null && view.canGoBack()) {
view.goBack()
true
} else {
@@ -59,25 +96,57 @@ fun WebApp(content: String) {
Column(Modifier.fillMaxSize()) {
// Back steps through the page's history first (the interceptor above), then leaves.
ScreenHeader("Browser", Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
if (!fullscreen) {
ScreenHeader(pageTitle.ifEmpty { stringResource(R.string.app_browser) }, Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
GsIconButton(androidx.compose.material.icons.Icons.Filled.Search, stringResource(R.string.web_search), {
fieldText = null
address = ""
})
if (canGoForward) {
GsIconButton(androidx.compose.material.icons.Icons.AutoMirrored.Filled.ArrowForward, stringResource(R.string.web_forward), { web?.goForward() })
}
GsIconButton(androidx.compose.material.icons.Icons.Filled.Refresh, stringResource(R.string.web_reload), { web?.reload() })
GsIconButton(androidx.compose.material.icons.Icons.Filled.Home, stringResource(R.string.web_start_page), {
web?.let { view -> loadContent(view, content) }
})
GsIconButton(MediaIcons.Fullscreen, stringResource(R.string.web_fullscreen), CarFullscreen::enter)
}
}
Box(Modifier.weight(1f).fillMaxSize()) {
AndroidView(
modifier = Modifier.weight(1f).fillMaxSize(),
modifier = Modifier.fillMaxSize(),
factory = { context ->
WebView(context).apply {
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
settings.loadWithOverviewMode = true
settings.useWideViewPort = true
webViewClient = LoggingClient(ring)
webViewClient = LoggingClient(ring) { view ->
canGoForward = view.canGoForward()
pageTitle = view.title.orEmpty().takeUnless { it.startsWith("http") || it == "about:blank" }.orEmpty()
}
webChromeClient = object : WebChromeClient() {
override fun onReceivedTitle(view: WebView?, title: String?) {
pageTitle = title.orEmpty().takeUnless { it.startsWith("http") || it == "about:blank" }.orEmpty()
}
override fun onShowCustomView(view: View, callback: CustomViewCallback) {
videoMadeFullscreen = !CarFullscreen.active
pageFullscreen = view to callback
CarFullscreen.enter()
}
override fun onHideCustomView() {
if (pageFullscreen == null) return
pageFullscreen = null
if (videoMadeFullscreen) CarFullscreen.exit()
}
}
addJavascriptInterface(
FieldFocusBridge(onFocus = { fieldText = it }, onBlur = { fieldText = null }),
"GearslipField",
)
if (content.startsWith("http://") || content.startsWith("https://")) {
loadUrl(content)
} else {
loadDataWithBaseURL(null, content, "text/html", "utf-8", null)
}
loadContent(this, content)
web = this
}
},
@@ -85,6 +154,33 @@ fun WebApp(content: String) {
update = { it.settings.isAlgorithmicDarkeningAllowed = darkOutside },
onRelease = { it.destroy() },
)
pageFullscreen?.let { (view, _) ->
androidx.compose.runtime.key(view) {
AndroidView(
factory = { context ->
FrameLayout(context).apply {
setBackgroundColor(android.graphics.Color.BLACK)
(view.parent as? ViewGroup)?.removeView(view)
addView(view, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
}
},
modifier = Modifier.fillMaxSize(),
onRelease = { it.removeAllViews() },
)
}
}
}
address?.let { typed ->
CarKeyboard(
text = typed,
onTextChange = { address = it },
onSubmit = {
web?.loadUrl(addressToUrl(typed))
address = null
},
onDismiss = { address = null },
)
}
fieldText?.let { text ->
CarKeyboard(
text = text,
@@ -102,9 +198,29 @@ fun WebApp(content: String) {
}
}
private class LoggingClient(private val ring: String) : WebViewClient() {
/** The start page: a web address, or a page of HTML Gearslip made itself. */
private fun loadContent(view: WebView, content: String) {
if (content.startsWith("http://") || content.startsWith("https://")) {
view.loadUrl(content)
} else {
view.loadDataWithBaseURL(null, content, "text/html", "utf-8", null)
}
}
/** Something that looks like an address opens as one; anything else is searched for. */
private fun addressToUrl(typed: String): String {
val text = typed.trim()
return when {
text.startsWith("http://") || text.startsWith("https://") -> text
' ' !in text && '.' in text -> "https://$text"
else -> "https://duckduckgo.com/?q=" + java.net.URLEncoder.encode(text, "UTF-8")
}
}
private class LoggingClient(private val ring: String, private val onLoaded: (WebView) -> Unit) : WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) {
GearslipLog.i("car web page loaded: $url")
view?.let(onLoaded)
view?.evaluateJavascript(FOCUS_TRACKING_JS, null)
view?.evaluateJavascript(focusRingJs(ring), null)
}
@@ -0,0 +1,84 @@
package app.seb3thehacker.gearslip.car.theme
import androidx.compose.material3.ColorScheme
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.graphics.lerp
/**
* The phone's Material You colours, with its accent washed over the surfaces. Material You's own
* surfaces are nearly neutral, which reads as flat grey on a car screen; tinting them with the
* phone's primary colour (purple on a purple phone) gives the whole UI the phone's hue, the way
* Material 3's surface tint does, and it changes with the wallpaper like everything else.
* Higher containers take more of the tint, so cards still stand off the background.
*/
fun ColorScheme.withTintedSurfaces(dark: Boolean, style: DarkStyle = DarkStyle.GREY): ColorScheme {
if (dark && style != DarkStyle.GREY) return withDarkStyle(style)
val base = if (dark) 0.03f else 0.02f
fun tint(own: Color, step: Int) = primary.copy(alpha = base + step * (if (dark) 0.015f else 0.01f)).compositeOver(own)
return copy(
background = tint(background, 0),
surface = tint(surface, 0),
surfaceDim = tint(surfaceDim, 0),
surfaceContainerLowest = tint(surfaceContainerLowest, 0),
surfaceContainerLow = tint(surfaceContainerLow, 1),
surfaceContainer = tint(surfaceContainer, 2),
surfaceContainerHigh = tint(surfaceContainerHigh, 3),
surfaceContainerHighest = tint(surfaceContainerHighest, 4),
surfaceBright = tint(surfaceBright, 4),
surfaceVariant = tint(surfaceVariant, 3),
)
}
/**
* How dark the car's dark mode goes. [GREY] is Material You's own dark, with the tint above;
* the others are darker, and the two colour ones take their hue from the phone's accent.
*/
enum class DarkStyle { GREY, BLACK, DEEP_COLOUR, BLACK_COLOUR }
/**
* The surfaces rebuilt at [style]'s brightness, from the background up to the highest container.
* The levels are 0-255 greys; each surface keeps Material You's own slight hue at that level.
*/
private fun ColorScheme.withDarkStyle(style: DarkStyle): ColorScheme {
val levels = when (style) {
DarkStyle.BLACK -> listOf(0f, 12f, 17f, 25f, 34f)
DarkStyle.DEEP_COLOUR -> listOf(11f, 19f, 24f, 32f, 41f)
else -> listOf(0f, 17f, 22f, 30f, 39f)
}
val colour = style != DarkStyle.BLACK
// The accent's saturated container colour, not the pastel accent itself: tinting with the
// pastel only makes a surface lighter, where this one gives it colour at the same brightness.
fun at(own: Color, step: Int): Color {
val level = levels[step] / 255f
if (level == 0f) return Color.Black
val neutral = own.scaledTo(level)
return if (colour) {
lerp(neutral, primaryContainer.scaledTo(level), (0.6f + 0.08f * step).coerceAtMost(0.95f))
} else {
primary.copy(alpha = 0.02f + 0.015f * step).compositeOver(neutral)
}
}
return copy(
// Grey text that read fine on grey looks dim on black, so it moves a little towards white.
onSurfaceVariant = lerp(onSurfaceVariant, onSurface, 0.3f),
background = at(background, 0),
surface = at(surface, 0),
surfaceDim = at(surfaceDim, 0),
surfaceContainerLowest = at(surfaceContainerLowest, 0),
surfaceContainerLow = at(surfaceContainerLow, 1),
surfaceContainer = at(surfaceContainer, 2),
surfaceContainerHigh = at(surfaceContainerHigh, 3),
surfaceContainerHighest = at(surfaceContainerHighest, 4),
surfaceBright = at(surfaceBright, 4),
surfaceVariant = at(surfaceVariant, 3),
)
}
/** This colour's hue at an average brightness of [level], 0 to 1. */
private fun Color.scaledTo(level: Float): Color {
val mean = (red + green + blue) / 3f
if (mean <= 0f) return Color(level, level, level)
val k = level / mean
return Color((red * k).coerceAtMost(1f), (green * k).coerceAtMost(1f), (blue * k).coerceAtMost(1f))
}
@@ -9,6 +9,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.dropShadow
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.drawOutline
import androidx.compose.ui.graphics.drawscope.DrawScope
@@ -172,6 +173,46 @@ fun Modifier.gsSurface(
* outlines: cheap, works on every API level, and already soft enough that the car's video
* compression has nothing crisp to smear.
*/
/**
* The current theme's shadow under a panel floating over the map, such as the docked player.
* Draws nothing in a look without one.
*/
@Composable
fun Modifier.gsPanelShadow(shape: Shape): Modifier {
val look = gsLook()
if (look.panelShadow <= 0.dp || look.panelShadowAlpha <= 0f) return this
// A real blur, as soft as the nav bar's gradient and as strong: stacked outlines showed as
// rings around a card this big.
return dropShadow(
shape,
androidx.compose.ui.graphics.shadow.Shadow(
radius = look.panelShadow * 3,
color = Color.Black.copy(alpha = look.panelShadowAlpha * 0.6f),
offset = androidx.compose.ui.unit.DpOffset(0.dp, look.panelShadow / 2),
),
)
}
/** The same shadow cast upwards from the top edge of a bar along the bottom of the screen. */
@Composable
fun Modifier.gsBarShadow(): Modifier {
val look = gsLook()
if (look.panelShadow <= 0.dp || look.panelShadowAlpha <= 0f) return this
return drawBehind {
val reach = look.panelShadow.toPx() * 1.5f
drawRect(
Brush.verticalGradient(
0f to Color.Transparent,
1f to Color.Black.copy(alpha = look.panelShadowAlpha * 0.6f),
startY = -reach,
endY = 0f,
),
topLeft = androidx.compose.ui.geometry.Offset(0f, -reach),
size = Size(size.width, reach),
)
}
}
private fun DrawScope.drawSoftShadow(shape: Shape, elevation: Float, alpha: Float, lift: Float) {
if (alpha <= 0f) return
val blur = elevation * 2f
@@ -64,6 +64,12 @@ data class ControlLook(
* from; a flat look takes the dot.
*/
val holdMarker: HoldMarkerStyle = HoldMarkerStyle.PEEL,
/**
* The shadow a panel floating over the map casts onto it (the docked player, the lyrics), and
* the nav bar casts up. 0 for none, so a flat look stays flat.
*/
val panelShadow: Dp = 0.dp,
val panelShadowAlpha: Float = 0f,
/** Replaces this look when the UI is light, for effects that only work on one background. */
val light: ControlLook? = null,
)
@@ -72,7 +78,11 @@ object GsThemes {
val Keycap = GsTheme(
id = "keycap",
name = "Keycap",
control = ControlLook(lip = 4.dp, pressedScale = 1f, holdMarker = HoldMarkerStyle.PEEL),
// Short and dark, like the keys' own solid base.
control = ControlLook(
lip = 4.dp, pressedScale = 1f, holdMarker = HoldMarkerStyle.PEEL,
panelShadow = 4.dp, panelShadowAlpha = 0.45f,
),
)
val Tactile = GsTheme(
@@ -89,6 +99,8 @@ object GsThemes {
pressedDim = 0.12f,
pressedScale = 1f,
holdMarker = HoldMarkerStyle.PEEL,
panelShadow = 12.dp,
panelShadowAlpha = 0.42f,
// A wide soft shadow bands once the car's video stream compresses it on a light
// background, so light mode gets a tight one and leans on the bevel instead.
light = ControlLook(
@@ -102,6 +114,8 @@ object GsThemes {
pressedDim = 0.12f,
pressedScale = 1f,
holdMarker = HoldMarkerStyle.PEEL,
panelShadow = 4.dp,
panelShadowAlpha = 0.22f,
),
),
)
@@ -13,6 +13,9 @@ class TemplateApp(
) {
val isNavigation: Boolean get() = CarAppService.CATEGORY_NAVIGATION_APP in categories
/** A media car screen, or one that names no category (older media apps). */
val isMediaScreen: Boolean get() = kind == "media" || kind == "app"
/** "navigation", "media", ... - what the app told the system it is. */
val kind: String
get() = categories.firstOrNull { it.startsWith(CATEGORY_PREFIX) }
@@ -91,6 +91,9 @@ class CarAppConnection(private val context: Context) {
private var navigationManager: INavigationManager? = null
/** Main thread only, both of these - the handoff is a race between them either way round. */
private var surfaceCallback: ISurfaceCallback? = null
/** Last visible and stable areas the UI reported, kept for the next surface. */
private var areas: Pair<Rect, Rect>? = null
private var lent: Lent? = null
private var binding: ServiceConnection? = null
@@ -223,6 +226,8 @@ class CarAppConnection(private val context: Context) {
fun disconnect() = disconnect(keepLocation = false)
private fun disconnect(keepLocation: Boolean) {
main.removeCallbacks(handshakeTimeout)
hardware.stop()
clearToasts()
lent?.let { sendSurfaceDestroyed(it) }
lent = null
@@ -254,11 +259,24 @@ class CarAppConnection(private val context: Context) {
// --- handshake ---------------------------------------------------------------------
/**
* An app that won't have Gearslip as its host never answers the first question. Car App
* Library 1.9 and later throw inside the app when a host that isn't Google's asks, and since
* the call is one-way the error never comes back, so without this the car would show
* "Starting..." for ever.
*/
private val handshakeTimeout = Runnable {
if (_status.value.phase == Phase.HANDSHAKE) {
fail(Phase.REJECTED, "It didn't answer Gearslip. Apps built for newer versions of Android Auto only run on Google's.")
}
}
private fun onBound(service: IBinder) {
val app = ICarApp.Stub.asInterface(service)
carApp = app
update { it.copy(phase = Phase.HANDSHAKE, detail = "bound, asking for app info") }
GearslipLog.i("host: bound; requesting app info")
main.postDelayed(handshakeTimeout, HANDSHAKE_TIMEOUT_MS)
call("getAppInfo", onValue = { value ->
val info = value as? AppInfo ?: return@call fail(Phase.FAILED, "app info was ${value?.javaClass?.simpleName}")
@@ -271,6 +289,7 @@ class CarAppConnection(private val context: Context) {
* offers cannot be rendered correctly, so it is refused here rather than half-drawn.
*/
private fun onAppInfo(info: AppInfo) {
main.removeCallbacks(handshakeTimeout)
val negotiated = minOf(HOST_API_LEVEL, info.latestCarAppApiLevel)
GearslipLog.i(
"host: app api ${info.minCarAppApiLevel}..${info.latestCarAppApiLevel} " +
@@ -385,6 +404,8 @@ class CarAppConnection(private val context: Context) {
// failure worth surfacing.
runCatching {
app.getManager(CarContext.NAVIGATION_SERVICE, object : IOnDoneCallback.Stub() {
// Car App Library 1.8 versions its host interfaces; each answers with the version it was built from.
override fun getInterfaceVersion(): Int = IOnDoneCallback.VERSION
override fun onSuccess(response: Bundleable?) {
val value = response?.let { runCatching { it.get() }.getOrNull() }
navigationManager = when (value) {
@@ -552,9 +573,11 @@ class CarAppConnection(private val context: Context) {
// The app draws for the whole surface; the visible area is what isn't covered by host
// chrome, and the stable area is the part that never gets covered. Both depend on the
// template (see visibleAreaFor) and are reported by the UI via [reportAreas] once it
// has measured. Until then the whole surface is fair game.
// has measured. A fresh surface the same size keeps the last ones, since the UI only
// reports again when they change; until then the whole surface is fair game.
val whole = Rect(0, 0, surface.width, surface.height)
reportAreas(whole, whole)
val last = areas?.takeIf { (visible, stable) -> whole.contains(visible) && whole.contains(stable) }
reportAreas(last?.first ?: whole, last?.second ?: whole)
GearslipLog.i(
"host: lent the app a ${surface.width}x${surface.height} @${surface.dpi}dpi surface"
@@ -575,7 +598,9 @@ class CarAppConnection(private val context: Context) {
/** Tells the app which part of its surface the driver can actually see. */
fun reportAreas(visible: Rect, stable: Rect) {
areas = visible to stable
val callback = surfaceCallback ?: return
GearslipLog.i("host: areas for $appPackage: visible=${visible.toShortString()} stable=${stable.toShortString()}")
runCatching { callback.onStableAreaChanged(stable, noop("onStableAreaChanged")) }
runCatching { callback.onVisibleAreaChanged(visible, noop("onVisibleAreaChanged")) }
}
@@ -605,6 +630,7 @@ class CarAppConnection(private val context: Context) {
// --- host interfaces the app calls back into -----------------------------------------
private fun createAppHost(owner: Any) = object : IAppHost.Stub() {
override fun getInterfaceVersion(): Int = IAppHost.VERSION
override fun invalidate() {
GearslipLog.i("host: app invalidated its template")
main.post { requestTemplate() }
@@ -643,6 +669,7 @@ class CarAppConnection(private val context: Context) {
val trip: StateFlow<androidx.car.app.navigation.model.Trip?> = _trip.asStateFlow()
private val navigationHost = object : INavigationHost.Stub() {
override fun getInterfaceVersion(): Int = INavigationHost.VERSION
override fun navigationStarted() {
GearslipLog.i("host: navigation started")
navigating = true
@@ -659,37 +686,25 @@ class CarAppConnection(private val context: Context) {
}
/**
* Answers car-hardware questions with "this car doesn't have that".
* Answers car-hardware questions from what the car itself sends: see [CarHardwareFeed].
*
* A phone knows nothing about a car's sensors, so every answer here is "unsupported" - but
* it has to be an actual answer. Returning no binder for this service at all is what made
* Organic Maps crash in onAppStart: its CarSensorsManager assumes a host that offers the
* service, and the library's own "unsupported" path only runs once a reply arrives.
*
* The reply's Bundleable is ignored by the library whenever isSupported is false, but the
* IBinder must be a real IOnDoneCallback - the app dispatches the reply through it.
* Every question has to get an actual answer, even "unsupported". Returning no binder for
* this service at all is what made Organic Maps crash in onAppStart: its CarSensorsManager
* assumes a host that offers the service, and the library's own "unsupported" path only runs
* once a reply arrives. The reply's IBinder must be a real IOnDoneCallback: the app
* dispatches the reply through it.
*/
private val hardware = CarHardwareFeed { name -> noop(name).asBinder() }
private val hardwareHost = object : ICarHardwareHost.Stub() {
override fun getCarHardwareResult(
type: Int,
params: Bundleable?,
result: ICarHardwareResult?,
) = unsupported(type, result)
override fun getInterfaceVersion(): Int = ICarHardwareHost.VERSION
override fun getCarHardwareResult(type: Int, params: Bundleable?, result: ICarHardwareResult?) =
hardware.get(type, result)
override fun subscribeCarHardwareResult(
type: Int,
params: Bundleable?,
result: ICarHardwareResult?,
) = unsupported(type, result)
override fun subscribeCarHardwareResult(type: Int, params: Bundleable?, result: ICarHardwareResult?) =
hardware.subscribe(type, result)
override fun unsubscribeCarHardwareResult(type: Int, params: Bundleable?) = Unit
private fun unsupported(type: Int, result: ICarHardwareResult?) {
val target = result ?: return
runCatching {
target.onCarHardwareResult(type, false, null, noop("carHardwareResult").asBinder())
}.onFailure { GearslipLog.w("host: could not answer hardware result $type") }
}
override fun unsubscribeCarHardwareResult(type: Int, params: Bundleable?) = hardware.unsubscribe(type)
}
/**
@@ -700,6 +715,7 @@ class CarAppConnection(private val context: Context) {
* Library documents for a projected head unit; they are about the screen, not about us.
*/
private val constraintHost = object : IConstraintHost.Stub() {
override fun getInterfaceVersion(): Int = IConstraintHost.VERSION
override fun getContentLimit(type: Int): Int = when (type) {
ConstraintManager.CONTENT_LIMIT_TYPE_GRID -> 12
ConstraintManager.CONTENT_LIMIT_TYPE_PANE -> 4
@@ -718,10 +734,12 @@ class CarAppConnection(private val context: Context) {
* that pushes suggestions shouldn't fail because its host doesn't surface them.
*/
private val suggestionHost = object : ISuggestionHost.Stub() {
override fun getInterfaceVersion(): Int = ISuggestionHost.VERSION
override fun updateSuggestions(suggestions: Bundleable?) = Unit
}
private fun createCarHost(owner: Any) = object : ICarHost.Stub() {
override fun getInterfaceVersion(): Int = ICarHost.VERSION
private val appHost = createAppHost(owner)
override fun getHost(type: String?): IBinder? = when (type) {
@@ -754,6 +772,7 @@ class CarAppConnection(private val context: Context) {
*/
private fun call(name: String, onValue: (Any?) -> Unit, send: (IOnDoneCallback) -> Unit) {
val callback = object : IOnDoneCallback.Stub() {
override fun getInterfaceVersion(): Int = IOnDoneCallback.VERSION
override fun onSuccess(response: Bundleable?) {
val value = response?.let { runCatching { it.get() }.getOrNull() }
main.post { onValue(value) }
@@ -769,6 +788,7 @@ class CarAppConnection(private val context: Context) {
}
private fun noop(name: String) = object : IOnDoneCallback.Stub() {
override fun getInterfaceVersion(): Int = IOnDoneCallback.VERSION
override fun onSuccess(response: Bundleable?) = Unit
override fun onFailure(response: Bundleable?) = GearslipLog.w("host: $name failed")
}
@@ -833,6 +853,9 @@ class CarAppConnection(private val context: Context) {
/** How long a new destination waits for the old route to end; see [navigateTo]. */
const val REROUTE_WAIT_MS = 1_500L
/** How long an app gets to answer the first question before it counts as refusing. */
const val HANDSHAKE_TIMEOUT_MS = 10_000L
/** Context.BIND_FOREGROUND_SERVICE; the constant itself is hidden from the SDK. */
const val BIND_FOREGROUND_SERVICE = 0x04000000
}
@@ -0,0 +1,171 @@
package app.seb3thehacker.gearslip.host
import android.os.IBinder
import androidx.car.app.hardware.ICarHardwareResult
import androidx.car.app.hardware.common.CarUnit
import androidx.car.app.hardware.common.CarValue
import androidx.car.app.hardware.info.CarHardwareLocation
import androidx.car.app.hardware.info.Compass
import androidx.car.app.hardware.info.EnergyLevel
import androidx.car.app.hardware.info.Mileage
import androidx.car.app.hardware.info.Model
import androidx.car.app.hardware.info.Speed
import androidx.car.app.serialization.Bundleable
import app.seb3thehacker.gearslip.GearslipLog
import app.seb3thehacker.gearslip.car.CarData
import app.seb3thehacker.gearslip.car.CarSettings
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CopyOnWriteArrayList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
/**
* What a car app gets when it asks the car hardware API (CarInfo, CarSensors) about the car:
* the model, speed, odometer, fuel and range, compass and the car's own GPS, read from [CarData].
* Anything else, or anything the car never sends, is answered "this car doesn't have that".
*
* A one-off question ([get]) is answered at once. A subscription gets the current value, if
* there is one, and then each new one as the car sends it, until the app unsubscribes or the
* connection ends ([stop]).
*/
internal class CarHardwareFeed(private val callback: (name: String) -> IBinder) {
private val subscribers = ConcurrentHashMap<Int, CopyOnWriteArrayList<ICarHardwareResult>>()
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@Volatile private var watching: Job? = null
fun get(type: Int, result: ICarHardwareResult?) {
val target = result ?: return
send(target, type, valueOf(type, CarData.state.value) ?: return unsupported(target, type))
}
fun subscribe(type: Int, result: ICarHardwareResult?) {
val target = result ?: return
if (type !in SENSOR_OF) return unsupported(target, type)
subscribers.getOrPut(type) { CopyOnWriteArrayList() }.add(target)
GearslipLog.i("host: an app subscribed to car hardware ${NAMES[type] ?: type}")
valueOf(type, CarData.state.value)?.let { send(target, type, it) }
watch()
}
fun unsubscribe(type: Int) {
subscribers.remove(type)
}
fun stop() {
subscribers.clear()
watching?.cancel()
watching = null
}
fun close() {
stop()
scope.cancel()
}
/** Sends each subscribed kind again when the car's reading of it changes. */
private fun watch() {
if (watching?.isActive == true) return
watching = scope.launch {
var seen = CarData.state.value.updatedAt
CarData.state.collect { s ->
for ((type, targets) in subscribers) {
val sensor = SENSOR_OF[type] ?: continue
if (s.updatedAt[sensor] == seen[sensor]) continue
val value = valueOf(type, s) ?: continue
targets.forEach { send(it, type, value) }
}
seen = s.updatedAt
}
}
}
private fun valueOf(type: Int, s: CarData.Snapshot): Any? {
val imperial = CarSettings.imperial()
val distanceUnit = known(if (imperial) CarUnit.MILE else CarUnit.KILOMETER)
val at = s.updatedAt[SENSOR_OF[type]] ?: System.currentTimeMillis()
fun <T : Any> v(value: T) = CarValue(value, at, CarValue.STATUS_SUCCESS)
return when (type) {
TYPE_MODEL -> CarData.model?.let { m ->
Model.Builder().setName(v(m.name)).apply { m.year?.let { setYear(v(it)) } }.build()
}
TYPE_SPEED -> s.speedMps?.let {
Speed.Builder()
.setRawSpeedMetersPerSecond(v(it))
.setDisplaySpeedMetersPerSecond(v(it))
.setSpeedDisplayUnit(known(if (imperial) CarUnit.MILES_PER_HOUR else CarUnit.KILOMETERS_PER_HOUR))
.build()
}
TYPE_MILEAGE -> s.odometerKm?.let {
Mileage.Builder().setOdometerMeters(v(it * 1000f)).setDistanceDisplayUnit(distanceUnit).build()
}
TYPE_ENERGY_LEVEL -> if (s.fuelPercent == null && s.rangeKm == null && s.fuelLow == null) null else {
EnergyLevel.Builder().apply {
s.fuelPercent?.let { setFuelPercent(v(it.toFloat())) }
s.rangeKm?.let { setRangeRemainingMeters(v(it * 1000f)) }
s.fuelLow?.let { setEnergyIsLow(v(it)) }
setDistanceDisplayUnit(distanceUnit)
}.build()
}
TYPE_COMPASS -> s.compassDegrees?.let { Compass(v(listOf(it))) }
TYPE_CAR_LOCATION -> s.location?.let { l ->
val location = android.location.Location("car").apply {
latitude = l.latitude
longitude = l.longitude
l.accuracyMeters?.let { accuracy = it }
l.altitudeMeters?.let { altitude = it }
l.speedMps?.let { speed = it }
l.bearingDegrees?.let { bearing = it }
time = at
}
CarHardwareLocation(v(location))
}
else -> null
}
}
private fun known(value: Int) = CarValue(value, System.currentTimeMillis(), CarValue.STATUS_SUCCESS)
private fun send(target: ICarHardwareResult, type: Int, value: Any) {
runCatching {
target.onCarHardwareResult(type, true, Bundleable.create(value), callback("carHardwareResult"))
}.onFailure {
// The app went away: stop telling it.
subscribers.values.forEach { list -> list.remove(target) }
GearslipLog.w("host: could not send car hardware ${NAMES[type] ?: type}: ${it.javaClass.simpleName}")
}
}
private fun unsupported(target: ICarHardwareResult, type: Int) {
runCatching { target.onCarHardwareResult(type, false, null, callback("carHardwareResult")) }
.onFailure { GearslipLog.w("host: could not answer hardware result $type") }
}
companion object {
// ICarHardwareResultTypes in the car library.
private const val TYPE_MODEL = 1
private const val TYPE_ENERGY_LEVEL = 4
private const val TYPE_SPEED = 5
private const val TYPE_MILEAGE = 6
private const val TYPE_COMPASS = 21
private const val TYPE_CAR_LOCATION = 23
/** Which of the car's sensors each kind comes from, for knowing when it changed. */
private val SENSOR_OF = mapOf(
TYPE_ENERGY_LEVEL to CarData.FUEL,
TYPE_SPEED to CarData.SPEED,
TYPE_MILEAGE to CarData.ODOMETER,
TYPE_COMPASS to CarData.COMPASS,
TYPE_CAR_LOCATION to CarData.LOCATION,
)
private val NAMES = mapOf(
TYPE_MODEL to "model", TYPE_ENERGY_LEVEL to "energy level", TYPE_SPEED to "speed",
TYPE_MILEAGE to "mileage", TYPE_COMPASS to "compass", TYPE_CAR_LOCATION to "car location",
)
}
}
@@ -31,7 +31,41 @@ import kotlin.math.roundToInt
*/
/** The app's text, preferring the longest variant that will fit; variants are ordered longest first. */
fun CarText?.text(): String = this?.takeIf { !it.isEmpty }?.toCharSequence()?.toString().orEmpty()
fun CarText?.text(): String {
val chars = this?.takeIf { !it.isEmpty }?.toCharSequence() ?: return ""
return chars.withSpansFilledIn()
}
/**
* Apps write a route's time and length as spans over placeholder characters ("\u0000 · \u0000"
* with a DurationSpan and a DistanceSpan), leaving the host to format them. Plain toString()
* keeps only the placeholders, which is how a route option ended up reading "·".
*/
private fun CharSequence.withSpansFilledIn(): String {
if (this !is android.text.Spanned) return toString()
val spans = getSpans(0, length, androidx.car.app.model.CarSpan::class.java)
.filter { it is androidx.car.app.model.DurationSpan || it is androidx.car.app.model.DistanceSpan }
.sortedBy { getSpanStart(it) }
if (spans.isEmpty()) return toString()
val out = StringBuilder()
var at = 0
spans.forEach { span ->
val start = getSpanStart(span)
val end = getSpanEnd(span)
if (start < at || end > length) return@forEach
out.append(this, at, start)
out.append(
when (span) {
is androidx.car.app.model.DurationSpan -> remainingTime(span.durationSeconds)
is androidx.car.app.model.DistanceSpan -> span.distance.display()
else -> ""
},
)
at = end
}
out.append(this, at, length)
return out.toString().replace("\u0000", "").trim()
}
/**
* Icons keep coming back under fresh [CarIcon]/IconCompat objects - every template refetch
@@ -53,7 +87,7 @@ private val iconCache = android.util.LruCache<String, ImageBitmap>(200)
fun CarIcon?.image(context: Context): ImageBitmap? {
val icon = this?.icon ?: return null
val key = icon.cacheKey(context)
iconCache.get(key)?.let { return it }
key?.let { k -> iconCache.get(k)?.let { return it } }
val drawable: Drawable = runCatching { icon.loadDrawable(context) }
.onFailure { GearslipLog.w("icon: loadDrawable threw for ${icon.describe()}: ${it.javaClass.simpleName}: ${it.message}") }
.getOrNull()
@@ -73,7 +107,7 @@ fun CarIcon?.image(context: Context): ImageBitmap? {
val bitmap = runCatching { drawable.toBitmap(width, height).asImageBitmap() }
.onFailure { GearslipLog.w("icon: toBitmap threw for ${icon.describe()}: ${it.javaClass.simpleName}: ${it.message}") }
.getOrNull() ?: return null
iconCache.put(key, bitmap)
key?.let { iconCache.put(it, bitmap) }
return bitmap
}
@@ -83,7 +117,15 @@ fun CarIcon?.image(context: Context): ImageBitmap? {
* so a cache keyed on the bare id kept drawing the old build's picture on the wrong button (Vela's
* new Saved button came up as its search glyph).
*/
private fun androidx.core.graphics.drawable.IconCompat.cacheKey(context: Context): String {
/**
* Null for an icon that carries its own pixels: two different bitmaps describe the same, so a
* key would hand one app's turn arrow the picture of whatever bitmap was cached first.
*/
private fun androidx.core.graphics.drawable.IconCompat.cacheKey(context: Context): String? {
if (type == androidx.core.graphics.drawable.IconCompat.TYPE_BITMAP ||
type == androidx.core.graphics.drawable.IconCompat.TYPE_ADAPTIVE_BITMAP ||
type == androidx.core.graphics.drawable.IconCompat.TYPE_DATA
) return null
if (type != androidx.core.graphics.drawable.IconCompat.TYPE_RESOURCE) return describe()
val installed = runCatching { context.packageManager.getPackageInfo(resPackage, 0).lastUpdateTime }.getOrDefault(0L)
return "${describe()}@$installed"
@@ -24,6 +24,7 @@ object KnownApps {
"nl.flitsmeister", // Flitsmeister: map, routing, alerts - works well
"com.mapquest.android.ace", // MapQuest: map, routing, navigation
"com.mapswithme.maps.pro", // MAPS.ME: map, search, place pages, navigation
"app.vela", // Vela Maps: map, search, route options, turn-by-turn with lanes
)
fun works(packageName: String): Boolean = packageName in working
@@ -99,7 +100,6 @@ object KnownApps {
* call working. Wins over [works] and [playerWorks] for the badge.
*/
private val partial = setOf(
"app.vela", // Vela Maps: connects and draws, but rough enough not to call it working yet
"com.spotify.music", // Spotify: plays through Gearslip's player, but the app has to be opened on the phone first
"deezer.android.app", // Deezer: the same - it has to be opened on the phone first
)
@@ -37,6 +37,10 @@ class MediaEntry(
val iconBitmap: Bitmap?,
val browsable: Boolean,
val playable: Boolean,
/** Shown as a cover in a grid rather than a row, as the app asked. */
val grid: Boolean = false,
/** The heading the app files this under, when it splits a list into sections. */
val group: String? = null,
)
/** [icon] is a drawable id inside the media app's own package, not Gearslip's; 0 for none. */
@@ -271,7 +275,10 @@ class CarMedia(private val context: Context) {
GearslipLog.w("media: ${app.label} went away")
_phase.value = Phase.REJECTED
}
}, null)
}, Bundle().apply {
// Tells the app it may ask for grids and section headings, as it would of Android Auto.
putBoolean(STYLE_SUPPORTED, true)
})
browser = mb
GearslipLog.i("media: connecting to ${app.label}'s library")
runCatching { mb.connect() }.onFailure {
@@ -342,6 +349,8 @@ class CarMedia(private val context: Context) {
GearslipLog.i("media: connected to ${app.label}, root=${mb.root}")
attach(MediaController(context, mb.sessionToken))
rootId = mb.root
childStyles.clear()
mb.extras?.let { childStyles[mb.root] = it.getInt(STYLE_BROWSABLE, 0) to it.getInt(STYLE_PLAYABLE, 0) }
path.clear()
_phase.value = Phase.READY
open(mb.root)
@@ -427,7 +436,7 @@ class CarMedia(private val context: Context) {
if (parentId != subscribed) return
_browse.value = BrowseState(
trail = path.map { it.second },
entries = children.map(::entryOf),
entries = children.map { entryOf(it, childStyles[parentId]) },
loading = false,
// An empty top level is an app keeping its library to itself, not an empty one.
unavailable = path.isEmpty() && children.isEmpty(),
@@ -441,8 +450,21 @@ class CarMedia(private val context: Context) {
})
}
private fun entryOf(item: MediaBrowser.MediaItem): MediaEntry {
/**
* How a folder asked for its children to be shown, browsable ones and playable ones: by
* folder id, read off the folder itself (or the root's extras) before it's opened.
*/
private val childStyles = HashMap<String, Pair<Int, Int>>()
private fun entryOf(item: MediaBrowser.MediaItem, parentStyle: Pair<Int, Int>?): MediaEntry {
val d = item.description
val extras = d.extras
if (extras != null && item.isBrowsable) {
childStyles[item.mediaId.orEmpty()] = extras.getInt(STYLE_BROWSABLE, 0) to extras.getInt(STYLE_PLAYABLE, 0)
}
// The item's own hint first, then what its folder asked for its kind of child.
val style = extras?.getInt(STYLE_SINGLE_ITEM, 0)?.takeIf { it != 0 }
?: (if (item.isBrowsable) parentStyle?.first else parentStyle?.second) ?: 0
return MediaEntry(
id = item.mediaId.orEmpty(),
title = d.title?.toString().orEmpty(),
@@ -451,6 +473,8 @@ class CarMedia(private val context: Context) {
iconBitmap = d.iconBitmap,
browsable = item.isBrowsable,
playable = item.isPlayable,
grid = style == STYLE_GRID || style == STYLE_CATEGORY_GRID,
group = extras?.getString(STYLE_GROUP_TITLE)?.takeIf { it.isNotBlank() },
)
}
@@ -772,3 +796,12 @@ class CarMedia(private val context: Context) {
const val SEARCH_NOTICE_MS = 6_000L
}
}
/** Android Auto's browse layout hints, which any app built for it already sends. */
private const val STYLE_SUPPORTED = "android.media.browse.CONTENT_STYLE_SUPPORTED"
private const val STYLE_BROWSABLE = "android.media.browse.CONTENT_STYLE_BROWSABLE_HINT"
private const val STYLE_PLAYABLE = "android.media.browse.CONTENT_STYLE_PLAYABLE_HINT"
private const val STYLE_SINGLE_ITEM = "android.media.browse.CONTENT_STYLE_SINGLE_ITEM_HINT"
private const val STYLE_GROUP_TITLE = "android.media.browse.CONTENT_STYLE_GROUP_TITLE_HINT"
private const val STYLE_GRID = 2
private const val STYLE_CATEGORY_GRID = 4
@@ -31,7 +31,7 @@ class CarMediaStatus(
fun onMessage(messageId: Int, body: ByteArray) {
when (messageId) {
MSG_CHANNEL_OPEN_RESPONSE -> {
val status = Protobuf.readInt32Field(body, 1) ?: 0
val status = Protobuf.readInt32Field(body, 1)
GearslipLog.i("media status: <- ChannelOpenResponse: status=$status")
if (status == 0) start()
}
@@ -85,7 +85,9 @@ class CarMediaStatus(
}
private fun sendTrack(now: NowPlaying) {
val key = "${now.title}\u0000${now.artist}\u0000${now.album}\u0000${now.durationMs}\u0000${now.art?.generationId}"
// Whether there's a cover, not which bitmap: some apps (Metrolist) hand over a fresh copy
// of the same cover with every update, and resending it each second wastes the link.
val key = "${now.title}\u0000${now.artist}\u0000${now.album}\u0000${now.durationMs}\u0000${now.art != null}"
if (key == sentTrack) return
sentTrack = key
var body = Protobuf.stringField(1, now.title) +
@@ -1,28 +1,55 @@
package app.seb3thehacker.gearslip.media
import android.content.Context
import android.util.Base64
import android.util.Xml
import app.seb3thehacker.gearslip.GearslipLog
import app.seb3thehacker.gearslip.car.CarSettings
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import org.json.JSONArray
import org.json.JSONObject
import org.xmlpull.v1.XmlPullParser
import java.io.File
import java.io.StringReader
import java.net.HttpURLConnection
import java.net.URL
import java.net.URLEncoder
import java.security.MessageDigest
class LyricLine(val timeMs: Long, val text: String)
/** [synced] means every line carries a time, so the current one can be followed along. */
class LyricsResult(val lines: List<LyricLine>, val synced: Boolean)
import kotlin.math.abs
/**
* Looks lyrics up on LRCLIB (lrclib.net), a free community database that needs no key.
* One line. [words] is filled only for word-timed lyrics, and joined back together it reads as
* [text]; each word carries the space after it, if any, so a word split into syllables stays whole.
*/
class LyricLine(val timeMs: Long, val text: String, val words: List<LyricWord> = emptyList())
/** A word, or a syllable of one, sung from [startMs] to [endMs]. */
class LyricWord(val startMs: Long, val endMs: Long, val text: String)
/**
* [synced] means every line carries a time, so the current one can be followed along. [source]
* names the service that had them, shown on debug builds.
*/
class LyricsResult(val lines: List<LyricLine>, val synced: Boolean, val source: String = "")
/**
* Looks lyrics up online, in the order Metrolist uses: BetterLyrics, then LRCLIB, then KuGou.
* Metrolist's other sources match songs by YouTube video id, which a song from another app
* doesn't have, and Paxsenix calls Apple Music with a borrowed token, so they're left out.
*
* Media apps almost never publish lyrics in their session metadata, so this is the only general
* source. It sends the track's title, artist, album and length to lrclib.net, which is why the
* lookup happens only when the driver opens the lyrics view and never in the background.
* - BetterLyrics (lyrics-api.boidu.dev): word-timed lyrics, but only for songs its server has
* already fetched; anything else needs an API key and falls through.
* - LRCLIB (lrclib.net): a free community database that needs no key.
* - KuGou (kugou.com): a large Chinese music service, strong on Asian music.
*
* Media apps almost never publish lyrics in their session metadata, so these are the only general
* source. Each lookup sends the track's title, artist, album and length to these services, which
* is why it happens only when the driver opens the lyrics view, never in the background. With
* [CarSettings.moreLyrics] off, only LRCLIB is asked.
*
* Results are cached twice over: in memory for the life of the process, and on disk under the
* cache directory so a song looked up on a past drive doesn't need the network again on this one.
@@ -33,33 +60,104 @@ object Lyrics {
private val cache = HashMap<String, LyricsResult?>()
/** Longest any one source may take, DNS included: a server that never answers can't hold the rest up. */
private const val SOURCE_TIMEOUT_MS = 12_000L
/** How long a cached song waits before its sources are asked again for better lyrics. */
private const val RECHECK_MS = 24 * 60 * 60 * 1000L
private val io = CoroutineScope(Dispatchers.IO)
/** Word-timed beats line-timed, which beats plain text. */
private const val BEST = 3
private fun quality(result: LyricsResult?): Int = when {
result == null -> 0
!result.synced -> 1
result.lines.any { it.words.isNotEmpty() } -> BEST
else -> 2
}
/** One place to look, with the best [quality] it can give. */
private class Source(val name: String, val best: Int, val lookup: () -> LyricsResult?)
private fun sources(title: String, artist: String, album: String, durationMs: Long): List<Source> {
val more = CarSettings.moreLyrics.value
val seconds = (durationMs / 1000).toInt()
return buildList {
if (more) add(Source("BetterLyrics", BEST) { betterLyrics(title, artist, album, seconds) })
add(Source("LRCLIB", 2) { exact(title, artist, album, durationMs) ?: search(title, artist) })
if (more) add(Source("KuGou", 2) { kuGou(title, artist, album, seconds) })
}
}
private fun key(title: String, artist: String, album: String, durationMs: Long) = "$title|$artist|$album|$durationMs"
suspend fun find(context: Context, title: String, artist: String, album: String, durationMs: Long): LyricsResult? {
if (title.isBlank()) return null
val key = "$title|$artist|$album|$durationMs"
synchronized(cache) { if (cache.containsKey(key)) return cache[key] }
val key = key(title, artist, album, durationMs)
// A miss with only LRCLIB isn't a miss once more sources are on, so the mode is part of it.
val memoryKey = "${CarSettings.moreLyrics.value}|$key"
synchronized(cache) { if (cache.containsKey(memoryKey)) return cache[memoryKey] }
val fromDisk = withContext(Dispatchers.IO) { readDisk(context, key) }
if (fromDisk != null) {
synchronized(cache) { cache[key] = fromDisk }
synchronized(cache) { cache[memoryKey] = fromDisk }
return fromDisk
}
val result = withContext(Dispatchers.IO) {
runCatching {
exact(title, artist, album, durationMs) ?: search(title, artist)
}.onFailure { GearslipLog.w("lyrics: lookup failed: ${it.message}") }.getOrNull()
}
synchronized(cache) { cache[key] = result }
if (result != null) withContext(Dispatchers.IO) { writeDisk(context, key, result) }
val result = firstBetter(sources(title, artist, album, durationMs), than = 0)
synchronized(cache) { cache[memoryKey] = result }
if (result != null) withContext(Dispatchers.IO) { writeDisk(context, key, result, System.currentTimeMillis()) }
return result
}
/**
* After [find] answered with [current], looks for better lyrics than it: word-timed where it has
* line-timed, timed where it has plain. Only the sources that could beat it are asked, and only
* once a day per song, so an old cache entry improves when a source gains the song without every
* play sending the song out again. Returns the better lyrics, or null to keep [current].
*/
suspend fun upgrade(
context: Context, title: String, artist: String, album: String, durationMs: Long, current: LyricsResult,
): LyricsResult? {
val have = quality(current)
if (have >= BEST || title.isBlank()) return null
val key = key(title, artist, album, durationMs)
val checked = withContext(Dispatchers.IO) { checkedAt(context, key) }
if (System.currentTimeMillis() - checked < RECHECK_MS) return null
val candidates = sources(title, artist, album, durationMs).filter { it.best > have && it.name != current.source }
val better = firstBetter(candidates, than = have)
withContext(Dispatchers.IO) { writeDisk(context, key, better ?: current, System.currentTimeMillis()) }
if (better == null) return null
GearslipLog.i("lyrics: replaced ${current.source.ifEmpty { "cached" }} lyrics with ${better.source}'s")
synchronized(cache) { cache["${CarSettings.moreLyrics.value}|$key"] = better }
return better
}
/** Asks [sources] in order and returns the first answer of a higher [quality] than [than]. */
private suspend fun firstBetter(sources: List<Source>, than: Int): LyricsResult? {
for (source in sources) {
val job = io.async {
runCatching(source.lookup).onFailure { GearslipLog.w("lyrics: ${source.name} failed: ${it.message}") }.getOrNull()
}
val result = withTimeoutOrNull(SOURCE_TIMEOUT_MS) { job.await() }
?.let { LyricsResult(it.lines, it.synced, source.name) }
if (quality(result) > than) {
GearslipLog.i("lyrics: found on ${source.name} (${if (result!!.synced) "synced" else "plain"})")
return result
}
}
return null
}
private fun cacheFile(context: Context, key: String): File {
val digest = MessageDigest.getInstance("SHA-1").digest(key.toByteArray())
val name = digest.joinToString("") { "%02x".format(it) }
return File(context.cacheDir, "lyrics").apply { mkdirs() }.resolve("$name.json")
return File(context.cacheDir, LYRICS_DIR).apply { mkdirs() }.resolve("$name.json")
}
/** Where the lyrics cache lives under the app's cache directory, for the storage screen too. */
const val LYRICS_DIR = "lyrics"
private fun readDisk(context: Context, key: String): LyricsResult? = runCatching {
val file = cacheFile(context, key)
if (!file.exists()) return null
@@ -67,23 +165,153 @@ object Lyrics {
val lines = json.getJSONArray("lines").let { arr ->
(0 until arr.length()).map { i ->
val line = arr.getJSONObject(i)
LyricLine(line.getLong("t"), line.getString("x"))
val words = line.optJSONArray("w")?.let { w ->
(0 until w.length()).map { j ->
val word = w.getJSONArray(j)
LyricWord(word.getLong(0), word.getLong(1), word.getString(2))
}
}.orEmpty()
LyricLine(line.getLong("t"), line.getString("x"), words)
}
}
LyricsResult(lines, json.getBoolean("synced"))
val source = json.optString("source")
// Saved before word timings were kept: fetch again rather than wait a day for an upgrade
// that skips the source it came from.
if (source == "BetterLyrics" && lines.none { it.words.isNotEmpty() }) return null
LyricsResult(lines, json.getBoolean("synced"), source)
}.getOrNull()
private fun writeDisk(context: Context, key: String, result: LyricsResult) {
/** When the song's sources were last asked; 0 for entries from before the date was kept. */
private fun checkedAt(context: Context, key: String): Long =
runCatching { JSONObject(cacheFile(context, key).readText()).optLong("checked") }.getOrDefault(0L)
private fun writeDisk(context: Context, key: String, result: LyricsResult, checked: Long) {
runCatching {
val lines = JSONArray()
result.lines.forEach { line ->
lines.put(JSONObject().put("t", line.timeMs).put("x", line.text))
val entry = JSONObject().put("t", line.timeMs).put("x", line.text)
if (line.words.isNotEmpty()) {
entry.put("w", JSONArray(line.words.map { JSONArray().put(it.startMs).put(it.endMs).put(it.text) }))
}
lines.put(entry)
}
val json = JSONObject().put("synced", result.synced).put("lines", lines)
val json = JSONObject().put("synced", result.synced).put("source", result.source)
.put("checked", checked).put("lines", lines)
cacheFile(context, key).writeText(json.toString())
}.onFailure { GearslipLog.w("lyrics: could not write disk cache: ${it.message}") }
}
/** Forgets every cached song, on disk and in memory. */
fun clearCache(context: Context) {
synchronized(cache) { cache.clear() }
File(context.cacheDir, LYRICS_DIR).deleteRecursively()
}
// --- BetterLyrics ---
private fun betterLyrics(title: String, artist: String, album: String, seconds: Int): LyricsResult? {
if (artist.isBlank()) return null
val query = buildString {
append("s=").append(enc(title)).append("&a=").append(enc(artist))
if (seconds > 0) append("&d=").append(seconds)
if (album.isNotBlank()) append("&al=").append(enc(album))
}
val body = get("https://lyrics-api.boidu.dev/getLyrics?$query") ?: return null
val ttml = JSONObject(body).optString("ttml").takeIf { it.isNotBlank() && it != "null" } ?: return null
return parseTtml(ttml)
}
/**
* Apple-style TTML: one `<p begin=…>` per line, its words in `<span begin=… end=…>`s with
* spaces between; a word sung in syllables is several spans with no space. Background vocals,
* translations and romanisations are left out, so a line reads as sung.
*/
private fun parseTtml(ttml: String): LyricsResult? {
val parser = Xml.newPullParser().apply {
setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true)
setInput(StringReader(ttml))
}
val lines = mutableListOf<LyricLine>()
var inP = false
var lineBegin: Long? = null
var firstSpanBegin: Long? = null
var skipDepth = 0
val text = StringBuilder()
// The span being read, and the words of the line so far, each with the space after it.
var spanBegin: Long? = null
var spanEnd: Long? = null
val words = mutableListOf<Triple<Long, Long, StringBuilder>>()
var event = parser.eventType
while (event != XmlPullParser.END_DOCUMENT) {
when (event) {
XmlPullParser.START_TAG -> when {
parser.name == "p" -> {
inP = true; text.clear(); firstSpanBegin = null; words.clear()
lineBegin = attr(parser, "begin")?.let(::ttmlMs)
}
inP && skipDepth > 0 -> skipDepth++
inP && parser.name == "span" -> {
val role = attr(parser, "role")
if (role == "x-bg" || role == "x-translation" || role == "x-roman") skipDepth = 1
else {
spanBegin = attr(parser, "begin")?.let(::ttmlMs)
spanEnd = attr(parser, "end")?.let(::ttmlMs)
if (firstSpanBegin == null) firstSpanBegin = spanBegin
}
}
}
XmlPullParser.TEXT -> if (inP && skipDepth == 0) {
val raw = parser.text
text.append(raw)
val begin = spanBegin
val end = spanEnd
if (begin != null && end != null && raw.isNotBlank()) {
val word = raw.replace(Regex("\\s+"), " ").trimStart()
words.add(Triple(begin, end, StringBuilder(word)))
} else if (raw.isNotEmpty() && raw.isBlank() && words.isNotEmpty() && !words.last().third.endsWith(" ")) {
words.last().third.append(' ')
}
}
XmlPullParser.END_TAG -> when {
inP && skipDepth > 0 -> skipDepth--
inP && parser.name == "span" -> { spanBegin = null; spanEnd = null }
parser.name == "p" -> {
inP = false
val timed = words.map { (b, e, w) -> LyricWord(b, e, w.toString()) }
val line = if (timed.isNotEmpty()) timed.joinToString("") { it.text }.trim()
else text.toString().replace(Regex("\\s+"), " ").trim()
if (line.isNotEmpty()) lines.add(LyricLine(lineBegin ?: firstSpanBegin ?: -1, line, timed))
}
}
}
event = parser.next()
}
if (lines.isEmpty()) return null
val synced = lines.all { it.timeMs >= 0 } && lines.any { it.timeMs > 0 }
return if (synced) LyricsResult(lines.sortedBy { it.timeMs }, synced = true)
else LyricsResult(lines.map { LyricLine(0, it.text) }, synced = false)
}
private fun attr(parser: XmlPullParser, localName: String): String? =
(0 until parser.attributeCount).firstOrNull { parser.getAttributeName(it) == localName }
?.let { parser.getAttributeValue(it) }
/** TTML times: `1:23.456`, `1:02:03.4`, `83.456s`, `250ms` or a bare number of seconds. */
private fun ttmlMs(raw: String): Long? {
val t = raw.trim()
val seconds = when {
':' in t -> t.split(':').fold(0.0) { acc, part -> acc * 60 + (part.toDoubleOrNull() ?: return null) }
t.endsWith("ms") -> (t.dropLast(2).toDoubleOrNull() ?: return null) / 1000
t.endsWith("s") -> t.dropLast(1).toDoubleOrNull() ?: return null
t.endsWith("m") -> (t.dropLast(1).toDoubleOrNull() ?: return null) * 60
t.endsWith("h") -> (t.dropLast(1).toDoubleOrNull() ?: return null) * 3600
else -> t.toDoubleOrNull() ?: return null
}
return (seconds * 1000).toLong()
}
// --- LRCLIB ---
private fun exact(title: String, artist: String, album: String, durationMs: Long): LyricsResult? {
if (artist.isBlank()) return null
val query = buildString {
@@ -111,6 +339,73 @@ object Lyrics {
return LyricsResult(plain.lines().map { LyricLine(0, it) }, synced = false)
}
// --- KuGou ---
/** Songs within this many seconds of the playing one count as the same recording. */
private const val KUGOU_DURATION_SLACK = 8
private val KUGOU_TIMED = Regex("""\[(\d\d):(\d\d)\.(\d{2,3})].*""")
/** A credit line such as `[00:01.00]作词:…` or `[00:02.00]Composer: …`. */
private val KUGOU_CREDIT = Regex(""".+].+[::].+""")
private const val KUGOU_CREDIT_LINES = 30
private fun kuGou(title: String, artist: String, album: String, seconds: Int): LyricsResult? {
val keyword = buildString {
append(kuGouTitle(title)).append(" - ").append(kuGouArtist(artist))
if (album.isNotBlank()) append(' ').append(album)
}
val encoded = URLEncoder.encode(keyword, "UTF-8").replace("+", "%20")
// First the song itself, matched by length, then its lyrics by the song's hash.
val songs = get(
"https://mobileservice.kugou.com/api/v3/search/song?version=9108&plat=0&pagesize=8&showtype=0&keyword=$encoded",
)?.let { JSONObject(it).optJSONObject("data")?.optJSONArray("info") }
if (songs != null) for (i in 0 until songs.length()) {
val song = songs.getJSONObject(i)
if (seconds > 0 && abs(song.optInt("duration") - seconds) > KUGOU_DURATION_SLACK) continue
val hash = song.optString("hash").takeIf { it.isNotBlank() } ?: continue
kuGouCandidate("hash=" + enc(hash))?.let { return it }
}
// Then a plain lyrics search by name.
val byName = "keyword=$encoded" + if (seconds > 0) "&duration=${seconds * 1000}" else ""
return kuGouCandidate(byName)
}
private fun kuGouCandidate(query: String): LyricsResult? {
val body = get("https://lyrics.kugou.com/search?ver=1&man=yes&client=pc&$query") ?: return null
val candidate = JSONObject(body).optJSONArray("candidates")?.optJSONObject(0) ?: return null
val id = candidate.optLong("id").takeIf { it != 0L } ?: return null
val accessKey = candidate.optString("accesskey").takeIf { it.isNotBlank() } ?: return null
val download = get(
"https://lyrics.kugou.com/download?fmt=lrc&charset=utf8&client=pc&ver=1&id=$id&accesskey=${enc(accessKey)}",
) ?: return null
val content = JSONObject(download).optString("content").takeIf { it.isNotBlank() } ?: return null
val lrc = String(Base64.decode(content, Base64.DEFAULT), Charsets.UTF_8)
val lines = parseLrc(kuGouTrim(lrc).joinToString("\n")).filter { it.text.isNotEmpty() }
return if (lines.isEmpty()) null else LyricsResult(lines, synced = true)
}
/** Keeps the timed lines and drops the songwriter and producer credits KuGou puts at either end. */
private fun kuGouTrim(lrc: String): List<String> {
val timed = lrc.lines().filter { it.matches(KUGOU_TIMED) }
if (timed.isEmpty()) return timed
val headLimit = minOf(KUGOU_CREDIT_LINES, timed.lastIndex)
val head = (headLimit downTo 0).firstOrNull { timed[it].matches(KUGOU_CREDIT) }?.plus(1) ?: 0
val rest = timed.drop(head)
if (rest.isEmpty()) return rest
val tailLimit = minOf(KUGOU_CREDIT_LINES, rest.lastIndex)
val tail = (tailLimit downTo 0).firstOrNull { rest[rest.lastIndex - it].matches(KUGOU_CREDIT) }?.plus(1) ?: 0
return rest.dropLast(tail)
}
/** KuGou's titles leave out bracketed extras such as "(feat. …)" or "(Live)". */
private fun kuGouTitle(title: String) = listOf("\\(.*\\)", "(.*)", "「.*」", "『.*』", "<.*>", "《.*》", "〈.*〉", "<.*>")
.fold(title) { acc, pattern -> acc.replace(Regex(pattern), "") }.trim()
/** KuGou joins several artists with "、". */
private fun kuGouArtist(artist: String) = artist.replace(", ", "、").replace(" & ", "、").replace(".", "")
.replace(Regex("\\(.*\\)"), "").replace(Regex("(.*)"), "").trim()
// --- shared ---
/** `[01:23.45] words` - a line may carry several stamps when a chorus repeats. */
private fun parseLrc(text: String): List<LyricLine> {
val stamp = Regex("""\[(\d+):(\d+(?:\.\d+)?)]""")
@@ -129,6 +424,7 @@ object Lyrics {
connectTimeout = 5_000
readTimeout = 8_000
setRequestProperty("User-Agent", "Gearslip (car media host)")
setRequestProperty("Accept", "application/json")
}
return try {
if (connection.responseCode != 200) null else connection.inputStream.bufferedReader().readText()
@@ -0,0 +1,36 @@
package app.seb3thehacker.gearslip.protocol
import app.seb3thehacker.gearslip.TouchPoint
import app.seb3thehacker.gearslip.Wire
/** Decodes the touch coordinates and pointer index carried by an InputReport. */
internal object InputProtocol {
data class Touch(val action: Int, val actionIndex: Int, val points: List<TouchPoint>)
/** [shiftX] and [shiftY] move each point by the margin the car crops off before measuring. */
fun touch(body: ByteArray, size: Pair<Int, Int>?, shiftX: Int = 0, shiftY: Int = 0): Touch? {
val fields = Wire.fields(body)
val action = Wire.varint(fields, 3)?.toInt() ?: 0
if (action !in setOf(0, 1, 2, 5, 6)) return null
val pointers = Wire.allBytes(fields, 1)
if (pointers.isEmpty() || pointers.size > 32) return null
val actionIndex = Wire.varint(fields, 2)?.toInt() ?: 0
if (actionIndex !in pointers.indices) return null
val points = pointers.map { pointer ->
val values = Wire.fields(pointer)
val x = Wire.varint(values, 1)?.toInt() ?: return null
val y = Wire.varint(values, 2)?.toInt() ?: return null
val id = Wire.varint(values, 3) ?: return null
if (id !in 0L..31L) return null
TouchPoint(id.toInt(), clamp(x + shiftX, size?.first), clamp(y + shiftY, size?.second))
}
if (points.map { it.id }.toSet().size != points.size) return null
return Touch(action, actionIndex, points)
}
private fun clamp(value: Int, limit: Int?): Float = when {
limit == null -> value.toFloat()
limit <= 0 -> 0f
else -> value.coerceIn(0, limit - 1).toFloat()
}
}
@@ -251,6 +251,17 @@ object UsageStats {
if (c.phone) put("phone", "${Build.MANUFACTURER} ${Build.MODEL}")
}
private var signerCache: String? = null
/** The first 16 hex digits of the SHA-256 of this APK's signing certificate. */
private fun signer(context: Context): String = signerCache ?: runCatching {
val cert = context.packageManager
.getPackageInfo(context.packageName, android.content.pm.PackageManager.GET_SIGNING_CERTIFICATES)
.signingInfo?.apkContentsSigners?.firstOrNull()?.toByteArray() ?: return@runCatching ""
java.security.MessageDigest.getInstance("SHA-256").digest(cert)
.take(8).joinToString("") { "%02x".format(it) }
}.getOrDefault("").also { signerCache = it }
/**
* What a note carries. With [everything], every field the driver shares, as Settings shows it;
* otherwise exactly what the next note would send, which leaves out unchanged phone details.
@@ -263,6 +274,9 @@ object UsageStats {
.put("version", BuildConfig.VERSION_NAME)
.put("code", BuildConfig.VERSION_CODE)
.put("build", if (BuildConfig.DEBUG) "debug" else "release")
// Who signed this copy, so a fork's builds can be told from Gearslip's own. Every copy
// from the same publisher sends the same value, so it says nothing about the phone.
.put("signer", signer(context))
// Which boxes are ticked, so the server knows what this phone shares and can drop
// anything it kept from a box since unticked.
.put("shares", JSONArray().apply {
@@ -26,7 +26,7 @@ object WorkedPrompt {
private var loaded = false
private val flow = MutableStateFlow<String?>(null)
/** The car's name while the ask is waiting for an answer, otherwise null. */
/** The car's name while the ask is waiting for an answer (empty when the car didn't say), otherwise null. */
fun pending(context: Context): StateFlow<String?> {
if (!loaded) {
loaded = true
@@ -99,17 +99,14 @@ object WorkedPrompt {
private fun saved(context: Context): JSONObject? =
prefs(context).getString(KEY_CAR, null)?.let { runCatching { JSONObject(it) }.getOrNull() }
/** "your 2019 Clio", "your Renault Media Nav", or "your car" when it didn't say. */
/** "2019 Clio", "Renault Media Nav", or empty when the car didn't say. */
private fun carName(car: JSONObject): String {
val model = car.optString("car").trim()
val year = car.optString("year").trim().takeIf { it.isNotEmpty() && it != "0" }
val unit = "${car.optString("make")} ${car.optString("model")}".trim()
return when {
model.isNotEmpty() -> "your " + listOfNotNull(year, model).joinToString(" ")
unit.isNotEmpty() -> "your $unit"
else -> "your car"
model.isNotEmpty() -> listOfNotNull(year, model).joinToString(" ")
else -> unit
}
}
const val TITLE_START = "Gearslip worked with "
}
@@ -1,5 +1,7 @@
package app.seb3thehacker.gearslip.ui
import androidx.compose.ui.res.stringResource
import app.seb3thehacker.gearslip.R
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
@@ -11,6 +13,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.Build
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -44,6 +47,10 @@ internal object GearslipLinks {
const val MATRIX = "https://matrix.to/#/!mf0_mgBd5KqKpc9FfX_wHp1jFCcwAtPrndIn_oGIyMk?via=matrix.org"
const val SOURCE = "https://github.com/twinksoftware/gearslip"
const val CARS = "$SOURCE/blob/main/CARS.md"
const val TRANSLATE = "https://hosted.weblate.org/engage/gearslip/"
/** This version's release notes on GitHub; every release is tagged v plus its version name. */
val RELEASE_NOTES = "$SOURCE/releases/tag/v${app.seb3thehacker.gearslip.BuildConfig.VERSION_NAME}"
fun open(context: Context, url: String) {
runCatching {
@@ -56,14 +63,14 @@ internal object GearslipLinks {
runCatching {
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("monero:$MONERO")).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
}.onFailure {
Toast.makeText(context, "No Monero wallet app found. Copy the address instead.", Toast.LENGTH_LONG).show()
Toast.makeText(context, context.getString(R.string.about_no_wallet), Toast.LENGTH_LONG).show()
}
}
fun copyMonero(context: Context) {
context.getSystemService(ClipboardManager::class.java)
?.setPrimaryClip(ClipData.newPlainText("Monero address", MONERO))
Toast.makeText(context, "Monero address copied", Toast.LENGTH_SHORT).show()
Toast.makeText(context, context.getString(R.string.about_monero_copied), Toast.LENGTH_SHORT).show()
}
}
@@ -81,7 +88,7 @@ internal fun DonateRows() {
SettingsRow(
"Liberapay",
Modifier.clickable { GearslipLinks.open(context, GearslipLinks.LIBERAPAY) },
subtitle = "Give weekly or monthly",
subtitle = stringResource(R.string.about_give_regularly),
icon = Icons.Filled.Favorite,
trailing = arrow,
)
@@ -89,16 +96,16 @@ internal fun DonateRows() {
SettingsRow(
"Buy Me a Coffee",
Modifier.clickable { GearslipLinks.open(context, GearslipLinks.BUY_ME_A_COFFEE) },
subtitle = "Give once or monthly, by card. Buy Me a Coffee keeps 5%",
subtitle = stringResource(R.string.about_bmac_sub),
icon = Icons.Filled.Favorite,
trailing = arrow,
)
if (GearslipLinks.PAYMENT_LINK.isNotEmpty()) {
SettingsDivider()
SettingsRow(
"Give once",
stringResource(R.string.about_give_once),
Modifier.clickable { GearslipLinks.open(context, GearslipLinks.PAYMENT_LINK) },
subtitle = "Any amount, by card",
subtitle = stringResource(R.string.about_any_amount),
icon = Icons.Filled.Favorite,
trailing = arrow,
)
@@ -109,7 +116,7 @@ internal fun DonateRows() {
SettingsRow(
"Monero",
Modifier.clickable { moneroOpen = true },
subtitle = "Open your wallet or copy the address",
subtitle = stringResource(R.string.about_monero_sub),
icon = Icons.Filled.Favorite,
trailing = arrow,
)
@@ -124,10 +131,10 @@ internal fun MoneroDialog(onDismiss: () -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
icon = { Icon(Icons.Filled.Favorite, contentDescription = null) },
title = { Text("Support with Monero") },
title = { Text(stringResource(R.string.about_monero_title)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("Send any amount to this address from your Monero wallet.")
Text(stringResource(R.string.about_monero_body))
SelectionContainer {
Text(
GearslipLinks.MONERO,
@@ -140,20 +147,20 @@ internal fun MoneroDialog(onDismiss: () -> Unit) {
confirmButton = {
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = { GearslipLinks.openMoneroWallet(context) }, Modifier.fillMaxWidth()) {
Text("Open in wallet")
Text(stringResource(R.string.about_open_wallet))
}
OutlinedButton(onClick = { GearslipLinks.copyMonero(context) }, Modifier.fillMaxWidth()) {
Text("Copy address")
Text(stringResource(R.string.about_copy_address))
}
TextButton(onClick = onDismiss, Modifier.fillMaxWidth()) {
Text("Close")
Text(stringResource(R.string.close))
}
}
},
)
}
/** The About section's links: the chat room, the source code, and credits. */
/** The About section's links: the chat room, translations, the source code, and credits. */
@Composable
internal fun AboutLinkRows() {
val context = LocalContext.current
@@ -165,25 +172,32 @@ internal fun AboutLinkRows() {
)
}
SettingsRow(
"Join the chat on Matrix",
stringResource(R.string.about_matrix),
Modifier.clickable { GearslipLinks.open(context, GearslipLinks.MATRIX) },
subtitle = "Talk with other drivers about cars, apps, and Gearslip",
subtitle = stringResource(R.string.about_matrix_sub),
icon = Icons.AutoMirrored.Filled.Send,
trailing = arrow,
)
SettingsDivider()
SettingsRow(
"Source code",
stringResource(R.string.about_translate),
Modifier.clickable { GearslipLinks.open(context, GearslipLinks.TRANSLATE) },
subtitle = stringResource(R.string.about_translate_sub),
icon = Icons.Filled.Edit,
trailing = arrow,
)
SettingsDivider()
SettingsRow(
stringResource(R.string.about_source),
Modifier.clickable { GearslipLinks.open(context, GearslipLinks.SOURCE) },
subtitle = "Read or change Gearslip's code under the AGPL",
subtitle = stringResource(R.string.about_source_sub),
icon = Icons.Filled.Build,
trailing = arrow,
)
SettingsDivider()
SettingsRow(
"Voice credits",
subtitle = "Speech recognition by sherpa-onnx (Apache 2.0), with Kroko ASR by Banafo " +
"(CC BY-SA 4.0), and Parakeet and Canary by NVIDIA (CC BY 4.0)",
stringResource(R.string.about_voice_credits),
subtitle = stringResource(R.string.about_voice_credits_sub),
icon = Icons.Filled.Favorite,
)
}
@@ -1,7 +1,16 @@
package app.seb3thehacker.gearslip.ui
import androidx.compose.ui.res.stringResource
import app.seb3thehacker.gearslip.R
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.OutlinedButton
import androidx.compose.ui.Modifier
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -24,27 +33,30 @@ fun AudioChoiceDialog(onUseBluetooth: () -> Unit, onKeepAsking: () -> Unit) {
var showCommand by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = onKeepAsking,
title = { Text("Play car audio over Bluetooth?") },
title = { Text(stringResource(R.string.audio_bt_title)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
"Android treats music sent over USB like sharing your screen, so it asks every " +
"drive. Over Bluetooth it never asks. You can change this later in Settings.",
)
Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(stringResource(R.string.audio_bt_body))
if (showCommand) {
Text(
"Or keep USB and stop the question for good: plug the phone into a " +
"computer with USB debugging on, and run this once.",
stringResource(R.string.audio_keep_usb),
style = MaterialTheme.typography.bodyMedium,
)
CopyableCommand("adb shell appops set ${BuildConfig.APPLICATION_ID} PROJECT_MEDIA allow")
} else {
TextButton(onClick = { showCommand = true }) { Text("Never ask, using a computer") }
}
// Stacked full width: three labels this long never fit the dialog's one button row.
Column(Modifier.fillMaxWidth().padding(top = 4.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = onUseBluetooth, Modifier.fillMaxWidth()) { Text(stringResource(R.string.audio_use_bt)) }
OutlinedButton(onClick = onKeepAsking, Modifier.fillMaxWidth()) { Text(stringResource(R.string.audio_keep_asking)) }
if (!showCommand) {
TextButton(onClick = { showCommand = true }, Modifier.fillMaxWidth()) {
Text(stringResource(R.string.audio_never_ask))
}
}
}
}
},
confirmButton = { TextButton(onClick = onUseBluetooth) { Text("Use Bluetooth") } },
dismissButton = { TextButton(onClick = onKeepAsking) { Text("Keep asking") } },
confirmButton = {},
)
}
@@ -57,15 +69,13 @@ fun AudioChoiceDialog(onUseBluetooth: () -> Unit, onKeepAsking: () -> Unit) {
fun MicBlockedDialog(onOpenSettings: () -> Unit, onDismiss: () -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Car audio needs the microphone permission") },
title = { Text(stringResource(R.string.audio_mic_title)) },
text = {
Text(
"Android lets an app send other apps' sound to the car only if it has the " +
"microphone permission. It's turned off for Gearslip, so music plays on the " +
"phone. Turn it on under Permissions, then Microphone.",
stringResource(R.string.audio_mic_body),
)
},
confirmButton = { TextButton(onClick = onOpenSettings) { Text("Open app settings") } },
dismissButton = { TextButton(onClick = onDismiss) { Text("Not now") } },
confirmButton = { TextButton(onClick = onOpenSettings) { Text(stringResource(R.string.audio_open_settings)) } },
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.worked_not_now)) } },
)
}
@@ -4,15 +4,8 @@ import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import android.content.res.Configuration
import android.graphics.Bitmap
import android.graphics.PixelFormat
import android.hardware.HardwareBuffer
import android.media.ImageReader
import android.os.Handler
import android.os.Looper
import android.view.MotionEvent
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
@@ -39,9 +32,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.input.pointer.pointerInteropFilter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
@@ -59,7 +50,7 @@ import app.seb3thehacker.gearslip.car.CarUi
* Runs the real car UI on the bench and shows it on the phone.
*
* This is the same pipeline the car gets - a VirtualDisplay hosting a Presentation, driven by
* the same [ScreenProjector] and fed the same touches - with an ImageReader standing in for the
* the same [ScreenProjector] and fed the same touches - with a SurfaceView standing in for the
* H.264 encoder. That matters for anything composited rather than drawn by Compose, such as the
* SurfaceView a templated app draws its map into: if it appears here, it will reach the car.
*
@@ -69,49 +60,34 @@ import app.seb3thehacker.gearslip.car.CarUi
fun CarPreviewScreen(onBack: () -> Unit) {
val context = LocalContext.current
val frame by CarEnvironment.frame.collectAsStateWithLifecycle()
var bitmap by remember { mutableStateOf<Bitmap?>(null) }
var viewSize by remember { mutableStateOf(0 to 0) }
// Tied to how the phone itself is held, not a button: turn it sideways and the preview goes
// fullscreen, turn it back and the phone's own chrome (the Back/title above the frame) returns.
val fullscreen = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
val width = frame.width
val height = frame.height
val density = frame.densityDpi
val previewSize by app.seb3thehacker.gearslip.car.CarSettings.previewSize.collectAsStateWithLifecycle()
val custom by app.seb3thehacker.gearslip.car.CarSettings.previewCustom.collectAsStateWithLifecycle()
val (width, height, density) = previewFrame(context, frame, previewSize, custom)
val projector = remember { ScreenProjector(context) }
// The preview is something to watch, like a map on a dash mount, so the phone stays awake on
// it. Only here: the flag goes with this screen, and every other screen sleeps as usual.
val view = androidx.compose.ui.platform.LocalView.current
DisposableEffect(view) {
view.keepScreenOn = true
onDispose { view.keepScreenOn = false }
}
DisposableEffect(width, height, density) {
CarEnvironment.start(context)
// COMPOSER_OVERLAY is what makes SurfaceFlinger willing to composite into this reader;
// CPU_READ_OFTEN is what makes the resulting buffer lockable, which is how the frame
// gets copied into a Bitmap. Without both, frames arrive but every copy fails.
val reader = ImageReader.newInstance(
width, height, PixelFormat.RGBA_8888, 3,
HardwareBuffer.USAGE_COMPOSER_OVERLAY or
HardwareBuffer.USAGE_GPU_SAMPLED_IMAGE or
HardwareBuffer.USAGE_CPU_READ_OFTEN,
)
val handler = Handler(Looper.getMainLooper())
reader.setOnImageAvailableListener({ source ->
val image = runCatching { source.acquireLatestImage() }.getOrNull() ?: return@setOnImageAvailableListener
try {
bitmap = image.toBitmap(width, height)
} catch (t: Throwable) {
GearslipLog.e("preview: could not copy a frame", t)
} finally {
image.close()
}
}, handler)
projector.start(reader.surface, width, height, density, name = "gearslip-preview") { CarUi() }
onDispose {
projector.stop()
reader.close()
bitmap = null
// No surface yet: the SurfaceView below hands its own over once it exists, and the
// display draws straight into it. Copying each frame into a Bitmap instead cost two
// full-size allocations per frame on the main thread, and taps lagged with the size.
projector.start(null, width, height, density, name = "gearslip-preview") {
androidx.compose.runtime.CompositionLocalProvider(app.seb3thehacker.gearslip.car.LocalCarPreview provides true) { CarUi() }
}
onDispose { projector.stop() }
}
// The frame is drawn the same way in both layouts; only what surrounds it differs.
@@ -140,19 +116,32 @@ fun CarPreviewScreen(onBack: () -> Unit) {
},
contentAlignment = Alignment.Center,
) {
bitmap?.let {
Image(
it.asImageBitmap(),
contentDescription = "Car screen",
androidx.compose.runtime.key(width, height) {
androidx.compose.ui.viewinterop.AndroidView(
factory = { viewContext ->
android.view.SurfaceView(viewContext).apply {
// The car's own size; the compositor scales it to the view.
holder.setFixedSize(width, height)
holder.addCallback(object : android.view.SurfaceHolder.Callback {
override fun surfaceCreated(holder: android.view.SurfaceHolder) =
projector.setSurface(holder.surface)
override fun surfaceChanged(holder: android.view.SurfaceHolder, format: Int, w: Int, h: Int) =
projector.setSurface(holder.surface)
override fun surfaceDestroyed(holder: android.view.SurfaceHolder) =
projector.setSurface(null)
})
}
},
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Fit,
)
} ?: Text("Waiting for a frame…", color = Color.White)
}
}
}
if (fullscreen) {
Fullscreen(onBack) {
Fullscreen(width.toFloat() / height, onBack) {
// No fillMax here: left to the bounds alone, aspectRatio takes the whole width or the
// whole height, whichever the car's shape allows, and the rest stays black.
frameContent(Modifier)
@@ -192,6 +181,7 @@ fun CarPreviewScreen(onBack: () -> Unit) {
*/
@Composable
private fun Fullscreen(
aspect: Float,
onBack: () -> Unit,
content: @Composable BoxScope.() -> Unit,
) {
@@ -213,19 +203,31 @@ private fun Fullscreen(
// is on and turning it sideways/back isn't an option.
BackHandler(onBack = onBack)
Box(
androidx.compose.foundation.layout.BoxWithConstraints(
Modifier.fillMaxSize().background(Color.Black),
contentAlignment = Alignment.Center,
) {
content()
// The frame swallows every touch inside it, so leaving needs either the back gesture or
// a button of its own. This one sits in the corner the car UI leaves emptiest.
FilledTonalIconButton(
onClick = onBack,
modifier = Modifier.align(Alignment.BottomStart).padding(8.dp),
) {
Icon(Icons.Filled.Close, contentDescription = "Leave the car preview")
// a button of its own. The button goes in the black beside the frame, never on it: over
// the frame it covered the car's own buttons. A frame the screen's own shape leaves no
// room, and then the back gesture is the way out.
val frameWidth = minOf(maxWidth, maxHeight * aspect)
val frameHeight = minOf(maxHeight, maxWidth / aspect)
val room = 56.dp
val corner = when {
maxWidth - frameWidth >= room * 2 -> Alignment.BottomStart
maxHeight - frameHeight >= room * 2 -> Alignment.TopStart
else -> null
}
if (corner != null) {
FilledTonalIconButton(
onClick = onBack,
modifier = Modifier.align(corner).padding(8.dp),
) {
Icon(Icons.Filled.Close, contentDescription = "Leave the car preview")
}
}
}
}
@@ -250,14 +252,26 @@ private fun Int.toCarAction(): Int = when (this) {
* Copies one frame out of the reader. The reader's rows are padded to a hardware-friendly
* stride, so the copy is made at the padded width and then cropped back.
*/
private fun android.media.Image.toBitmap(width: Int, height: Int): Bitmap {
val plane = planes[0]
val pixelStride = plane.pixelStride
val rowPadding = plane.rowStride - pixelStride * width
val padded = Bitmap.createBitmap(
width + rowPadding / pixelStride, height, Bitmap.Config.ARGB_8888,
)
padded.copyPixelsFromBuffer(plane.buffer)
return if (padded.width == width) padded
else Bitmap.createBitmap(padded, 0, 0, width, height).also { padded.recycle() }
/**
* The size the preview draws at. The last car's frame by default; this device's whole screen,
* for a head unit running Gearslip on its own (issue #12); or a size typed into Settings. The
* two new ones get a car-like density: 160dpi for every 480 pixels of height.
*/
private fun previewFrame(
context: android.content.Context,
lastCar: CarEnvironment.Frame,
size: app.seb3thehacker.gearslip.car.PreviewSize,
custom: app.seb3thehacker.gearslip.car.ScreenSize?,
): Triple<Int, Int, Int> {
fun carLike(w: Int, h: Int) = Triple(w, h, (160 * h / 480).coerceIn(120, 640))
return when (size) {
app.seb3thehacker.gearslip.car.PreviewSize.LAST_CAR -> Triple(lastCar.width, lastCar.height, lastCar.densityDpi)
app.seb3thehacker.gearslip.car.PreviewSize.CUSTOM ->
custom?.let { carLike(it.width, it.height) } ?: Triple(lastCar.width, lastCar.height, lastCar.densityDpi)
app.seb3thehacker.gearslip.car.PreviewSize.FILL -> {
val bounds = context.getSystemService(android.view.WindowManager::class.java).maximumWindowMetrics.bounds
// Landscape, whichever way the device is held right now.
carLike(maxOf(bounds.width(), bounds.height()) / 2 * 2, minOf(bounds.width(), bounds.height()) / 2 * 2)
}
}
}
@@ -0,0 +1,159 @@
package app.seb3thehacker.gearslip.ui
import androidx.compose.ui.res.stringResource
import app.seb3thehacker.gearslip.R
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import app.seb3thehacker.gearslip.car.CarSettings
import app.seb3thehacker.gearslip.car.PreviewSize
import app.seb3thehacker.gearslip.car.ScreenFit
import app.seb3thehacker.gearslip.car.ScreenSize
/**
* The car screen's real size, for a head unit that reports the wrong one and stretches the
* picture, and the size the phone's car preview draws at. The same screen size and fit are in
* the car's own Settings; typing in any size is only here.
*/
@Composable
internal fun CarScreenSection() {
val size by CarSettings.screenSize.collectAsState()
val fit by CarSettings.screenFit.collectAsState()
val preview by CarSettings.previewSize.collectAsState()
val previewCustom by CarSettings.previewCustom.collectAsState()
var editingCar by remember { mutableStateOf(false) }
var editingPreview by remember { mutableStateOf(false) }
if (editingCar) {
SizeDialog(
title = stringResource(R.string.screen_car_size),
note = stringResource(R.string.screen_car_size_note),
initial = size,
clearLabel = stringResource(R.string.screen_use_cars),
onDismiss = { editingCar = false },
onSave = { CarSettings.setScreenSize(it); editingCar = false },
)
}
if (editingPreview) {
SizeDialog(
title = stringResource(R.string.screen_preview_size),
note = stringResource(R.string.screen_preview_note),
initial = previewCustom,
clearLabel = null,
onDismiss = { editingPreview = false },
onSave = { custom ->
CarSettings.setPreviewSize(if (custom == null) PreviewSize.LAST_CAR else PreviewSize.CUSTOM, custom)
editingPreview = false
},
)
}
SettingsSection(
stringResource(R.string.screen_car_screen),
footer = stringResource(R.string.screen_footer),
) {
SettingsRow(
stringResource(R.string.screen_size),
Modifier.clickable { editingCar = true },
subtitle = size?.let { stringResource(R.string.screen_set_by_you, it.toString()) } ?: stringResource(R.string.screen_from_car),
)
if (size != null) {
SettingsDivider()
SettingsRow(
stringResource(R.string.screen_crop),
Modifier.selectable(fit == ScreenFit.CROP, role = Role.RadioButton) { CarSettings.setScreenFit(ScreenFit.CROP) },
subtitle = stringResource(R.string.screen_crop_sub),
trailing = { RadioButton(selected = fit == ScreenFit.CROP, onClick = null) },
)
SettingsDivider()
SettingsRow(
stringResource(R.string.screen_squash),
Modifier.selectable(fit == ScreenFit.SQUASH, role = Role.RadioButton) { CarSettings.setScreenFit(ScreenFit.SQUASH) },
subtitle = stringResource(R.string.screen_squash_sub),
trailing = { RadioButton(selected = fit == ScreenFit.SQUASH, onClick = null) },
)
}
}
SettingsSection(stringResource(R.string.screen_preview_title)) {
SettingsRow(
stringResource(R.string.screen_last_car),
Modifier.selectable(preview == PreviewSize.LAST_CAR, role = Role.RadioButton) {
CarSettings.setPreviewSize(PreviewSize.LAST_CAR)
},
subtitle = stringResource(R.string.screen_last_car_sub),
trailing = { RadioButton(selected = preview == PreviewSize.LAST_CAR, onClick = null) },
)
SettingsDivider()
SettingsRow(
stringResource(R.string.screen_this_screen),
Modifier.selectable(preview == PreviewSize.FILL, role = Role.RadioButton) {
CarSettings.setPreviewSize(PreviewSize.FILL)
},
subtitle = stringResource(R.string.screen_this_screen_sub),
trailing = { RadioButton(selected = preview == PreviewSize.FILL, onClick = null) },
)
SettingsDivider()
SettingsRow(
stringResource(R.string.screen_custom),
Modifier.selectable(preview == PreviewSize.CUSTOM, role = Role.RadioButton) {
if (previewCustom == null) editingPreview = true else CarSettings.setPreviewSize(PreviewSize.CUSTOM)
},
subtitle = previewCustom?.toString() ?: stringResource(R.string.screen_enter_size),
trailing = { RadioButton(selected = preview == PreviewSize.CUSTOM, onClick = null) },
)
if (preview == PreviewSize.CUSTOM) {
SettingsDivider()
SettingsRow(stringResource(R.string.screen_change_custom), Modifier.clickable { editingPreview = true })
}
}
}
/** Asks for a width and height; [clearLabel], when given, offers going back to no size at all. */
@Composable
private fun SizeDialog(
title: String,
note: String,
initial: ScreenSize?,
clearLabel: String?,
onDismiss: () -> Unit,
onSave: (ScreenSize?) -> Unit,
) {
var width by remember { mutableStateOf(initial?.width?.toString().orEmpty()) }
var height by remember { mutableStateOf(initial?.height?.toString().orEmpty()) }
val parsed = ScreenSize.parse("${width}x$height")
val number = KeyboardOptions(keyboardType = KeyboardType.Number)
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(note)
OutlinedTextField(width, { width = it.filter(Char::isDigit) }, label = { Text(stringResource(R.string.screen_width)) }, singleLine = true, keyboardOptions = number)
OutlinedTextField(height, { height = it.filter(Char::isDigit) }, label = { Text(stringResource(R.string.screen_height)) }, singleLine = true, keyboardOptions = number)
}
},
confirmButton = { TextButton(onClick = { onSave(parsed) }, enabled = parsed != null) { Text(stringResource(R.string.save)) } },
dismissButton = {
if (clearLabel != null) TextButton(onClick = { onSave(null) }) { Text(clearLabel) }
else TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) }
},
)
}
@@ -1,5 +1,7 @@
package app.seb3thehacker.gearslip.ui
import androidx.compose.ui.res.stringResource
import app.seb3thehacker.gearslip.R
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
@@ -80,7 +82,7 @@ fun CertSetupScreen(onDone: () -> Unit) {
verticalArrangement = Arrangement.Center,
) {
Text(
"Certificate Setup",
stringResource(R.string.certsetup_title),
style = MaterialTheme.typography.headlineLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onBackground,
@@ -88,9 +90,9 @@ fun CertSetupScreen(onDone: () -> Unit) {
Spacer(Modifier.height(8.dp))
Text(
if (installed) {
"Cars accept only Google-issued certificates, and you have one loaded."
stringResource(R.string.certsetup_loaded)
} else {
"Cars accept only Google-issued certificates. Gearslip doesn't include one, so choose where yours comes from."
stringResource(R.string.certsetup_choose)
},
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
@@ -100,14 +102,24 @@ fun CertSetupScreen(onDone: () -> Unit) {
if (installed) {
Text(
if (manualProgress) "Certificate imported." else "Certificate installed.",
if (manualProgress) stringResource(R.string.cert_imported) else stringResource(R.string.certsetup_installed),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
)
// A downloaded certificate works in most cars, not all: say where to go when it doesn't.
if (!manualProgress) {
Text(
stringResource(R.string.certsetup_installed_hint),
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp),
)
}
Spacer(Modifier.height(20.dp))
Button(onClick = onDone, modifier = Modifier.fillMaxWidth().height(56.dp)) {
Text("Continue")
Text(stringResource(R.string.certsetup_continue))
}
Spacer(Modifier.height(40.dp))
return@Column
@@ -130,11 +142,10 @@ fun CertSetupScreen(onDone: () -> Unit) {
},
modifier = Modifier.fillMaxWidth().height(56.dp),
) {
Text("Download from aasdk")
Text(stringResource(R.string.cert_download))
}
Text(
"The open-source aasdk project publishes this certificate at github.com/opencardev/aasdk. " +
"Most cars accept it; some from around 2020 on don't.",
stringResource(R.string.certsetup_aasdk_sub),
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -146,7 +157,7 @@ fun CertSetupScreen(onDone: () -> Unit) {
Box(Modifier.fillMaxWidth().height(56.dp), contentAlignment = Alignment.Center) {
DownloadingRow {
CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp)
Text("Downloading…", style = MaterialTheme.typography.bodyLarge)
Text(stringResource(R.string.cert_downloading), style = MaterialTheme.typography.bodyLarge)
}
}
}
@@ -157,7 +168,7 @@ fun CertSetupScreen(onDone: () -> Unit) {
Surface(color = MaterialTheme.colorScheme.errorContainer, shape = MaterialTheme.shapes.medium) {
Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
"Download failed",
stringResource(R.string.certsetup_failed),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onErrorContainer,
@@ -168,7 +179,7 @@ fun CertSetupScreen(onDone: () -> Unit) {
color = MaterialTheme.colorScheme.onErrorContainer,
)
Button(onClick = { downloadState = DownloadState.Idle }, modifier = Modifier.fillMaxWidth()) {
Text("Retry")
Text(stringResource(R.string.certsetup_retry))
}
}
}
@@ -180,9 +191,9 @@ fun CertSetupScreen(onDone: () -> Unit) {
OutlinedButton(
onClick = { importLauncher.launch(arrayOf("*/*")) },
modifier = Modifier.fillMaxWidth().height(52.dp),
) { Text("Import my own file") }
) { Text(stringResource(R.string.cert_import)) }
Text(
"A .p12 file with its private key, from wherever you choose.",
stringResource(R.string.certsetup_import_sub),
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -197,7 +208,7 @@ fun CertSetupScreen(onDone: () -> Unit) {
},
modifier = Modifier.fillMaxWidth().height(52.dp),
) {
Text("Skip car connection setup")
Text(stringResource(R.string.certsetup_skip))
}
Spacer(Modifier.height(40.dp))
@@ -216,7 +227,7 @@ fun CertSetupScreen(onDone: () -> Unit) {
pendingUri = null
manualProgress = true
}.onFailure {
reportError("Wrong password or not a valid PKCS#12 file with a private key.")
reportError(context.getString(R.string.cert_import_failed))
}
}
},
@@ -224,7 +235,7 @@ fun CertSetupScreen(onDone: () -> Unit) {
}
}
/** A tight horizontal row for the inline "Downloading…" spinner state. */
/** A tight horizontal row for the inline stringResource(R.string.cert_downloading) spinner state. */
@Composable
private fun DownloadingRow(content: @Composable RowScope.() -> Unit) {
Row(
@@ -248,20 +259,20 @@ private fun PasswordDialog(onDismiss: () -> Unit, onConfirm: (String, (String) -
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Certificate password") },
title = { Text(stringResource(R.string.cert_password_title)) },
text = {
OutlinedTextField(
value = password,
onValueChange = { password = it; error = null },
singleLine = true,
label = { Text("Password") },
label = { Text(stringResource(R.string.cert_password)) },
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
isError = error != null,
supportingText = { Text(error ?: "Leave empty if the file has none.") },
supportingText = { Text(error ?: stringResource(R.string.cert_password_hint)) },
)
},
confirmButton = { TextButton(onClick = { onConfirm(password) { error = it } }) { Text("Import") } },
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
confirmButton = { TextButton(onClick = { onConfirm(password) { error = it } }) { Text(stringResource(R.string.cert_import_button)) } },
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } },
)
}
@@ -1,5 +1,6 @@
package app.seb3thehacker.gearslip.ui
import app.seb3thehacker.gearslip.R
import android.content.Context
import app.seb3thehacker.gearslip.CertProvider
import java.text.DateFormat
@@ -28,20 +29,20 @@ data class CertSummary(
val identity = CertProvider.loadSupplied(context)
?: return CertSummary(
CertProvider.Kind.SELF_SIGNED,
"None loaded (self-signed; cars reject it)",
context.getString(R.string.cert_none),
null, null, null,
)
val cert = identity.certificate
val name = friendlyName(cert.subjectX500Principal.name)
val prefix = when (identity.kind) {
CertProvider.Kind.IMPORTED -> "Imported"
CertProvider.Kind.DOWNLOADED -> "Downloaded"
CertProvider.Kind.ADB_STAGED -> "Staged over adb"
CertProvider.Kind.SELF_SIGNED -> "Self-signed"
val headline = when (identity.kind) {
CertProvider.Kind.IMPORTED -> R.string.cert_kind_imported
CertProvider.Kind.DOWNLOADED -> R.string.cert_kind_downloaded
CertProvider.Kind.ADB_STAGED -> R.string.cert_kind_adb
CertProvider.Kind.SELF_SIGNED -> R.string.cert_kind_self
}
return CertSummary(
identity.kind,
"$prefix: $name",
context.getString(headline, name),
cert.subjectX500Principal.name,
cert.issuerX500Principal.name,
DateFormat.getDateInstance(DateFormat.MEDIUM).format(Date(cert.notAfter.time)),
@@ -1,5 +1,7 @@
package app.seb3thehacker.gearslip.ui
import androidx.compose.ui.res.stringResource
import app.seb3thehacker.gearslip.R
import android.widget.Toast
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -29,7 +31,7 @@ fun CopyableCommand(command: String) {
shape = MaterialTheme.shapes.small,
onClick = {
clipboard.setText(AnnotatedString(command))
Toast.makeText(context, "Command copied", Toast.LENGTH_SHORT).show()
Toast.makeText(context, context.getString(R.string.command_copied), Toast.LENGTH_SHORT).show()
},
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
) {
@@ -41,7 +43,7 @@ fun CopyableCommand(command: String) {
color = MaterialTheme.colorScheme.onSurface,
)
Text(
"Tap to copy",
stringResource(R.string.command_tap_copy),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
)
@@ -1,5 +1,7 @@
package app.seb3thehacker.gearslip.ui
import androidx.compose.ui.res.stringResource
import app.seb3thehacker.gearslip.R
import android.content.Context
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -100,31 +102,30 @@ internal fun DonateDialog(live: Boolean) {
AlertDialog(
onDismissRequest = { DonatePrompt.answer(context, donated = false) },
icon = { Icon(Icons.Filled.Favorite, contentDescription = null) },
title = { Text("Glad that drive worked") },
title = { Text(stringResource(R.string.donate_title)) },
text = {
Text(
"Gearslip is free, with no ads and no tracking. If it gets you on the road, your " +
"support keeps it going.",
stringResource(R.string.donate_body),
)
},
confirmButton = {
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = { give(GearslipLinks.LIBERAPAY) }, Modifier.fillMaxWidth()) {
Text("Support on Liberapay")
Text(stringResource(R.string.donate_liberapay))
}
OutlinedButton(onClick = { give(GearslipLinks.BUY_ME_A_COFFEE) }, Modifier.fillMaxWidth()) {
Text("Buy me a coffee")
Text(stringResource(R.string.donate_bmac))
}
if (GearslipLinks.PAYMENT_LINK.isNotEmpty()) {
OutlinedButton(onClick = { give(GearslipLinks.PAYMENT_LINK) }, Modifier.fillMaxWidth()) {
Text("Give once by card")
Text(stringResource(R.string.donate_card))
}
}
OutlinedButton(onClick = { monero = true }, Modifier.fillMaxWidth()) {
Text("Monero")
}
TextButton(onClick = { DonatePrompt.answer(context, donated = false) }, Modifier.fillMaxWidth()) {
Text("Not now")
Text(stringResource(R.string.worked_not_now))
}
}
},
Loaded 100 of 152 files, more files were not shown because too many files have changed in this diff. Show more