Author SHA1 Message Date
Seb3thehacker 4b627bffdd 0.3.02-alpha: notes cover better calls, the Home row, and call audio over Bluetooth 2026-10-24 00:29:39 -07:00
Seb3thehacker fed652f9d3 settings: What's new opens this version's release notes on GitHub 2026-10-24 00:21:24 -07:00
Seb3thehacker ef006dc695 home: Help, Preview and Logs in one row at the bottom, and What's new tops Settings
Small labelled buttons pinned at the bottom of Home, so cards above can't push them out of sight.
Help and tools leaves Settings, Connection help (the same page as Help) goes, What's new moves to
the top of Settings, the intro replay goes, and the debug build's test message gets its own
section there.
2026-10-24 00:17:12 -07:00
Seb3thehacker 55c25fe967 build: shrink debug builds with R8 too, so they behave like what ships 2026-10-23 23:44:19 -07:00
Seb3thehacker 46302b7d60 0.3.02-alpha: wireless connects by itself, calls from the car, and fixes for #35 and #36 2026-10-23 23:41:08 -07:00
Seb3thehacker 2c1fad6f8c wireless: auto-connect after the car ended the last session
A session the car ended left Gearslip thinking one was still running, so the next time the car
came back nothing happened. Only a live session counts now, and each reason not to connect is
logged.
2026-10-23 23:16:11 -07:00
Seb3thehacker 75d0df19c3 wireless: count a car as asked about only once the driver answers Android's dialog
A dialog that never showed, or was swiped away, used to stop Gearslip from ever asking again.
2026-10-23 23:08:09 -07:00
Seb3thehacker 456dda897e car audio: send Start once, even when the car answers the focus request while it's starting
Starting the stream at once and the car's focus answer could both get through, so the car got
Start twice and two AAC encoders ran. A 2014 aftermarket head unit dropped the connection on the
first audio. Directions now also log why a turn went unspoken.
2026-10-23 13:21:33 -07:00
Seb3thehacker 405cecf2e9 car apps: say an app won't start when it never answers, instead of showing Starting for ever
Apps on Car App Library 1.9 and later (Yandex Maps and 2GIS among them) don't reply to a host
that isn't Google's, so a map app on Home sat on its loading message. After 10 seconds without
an answer it now counts as refused, and Home offers another app.
2026-10-23 13:01:42 -07:00
Seb3thehacker 8fbd1701b1 wireless: connect by itself when a car Gearslip has connected to before comes up over Bluetooth 2026-10-22 03:24:43 -07:00
Seb3thehacker 6945f6755e calls: end and mute from the nav bar, with the call's name and time in place of the top banner 2026-10-22 03:10:34 -07:00
Seb3thehacker a26605c0ab car preview: type with the phone's keyboard instead of drawing the car's 2026-10-22 03:10:34 -07:00
Seb3thehacker 068288a0e7 calls: place calls through Telecom, since the call screen can't open on the car's display 2026-10-22 02:59:50 -07:00
Seb3thehacker dc61607028 directions: ask the car for its speakers before each turn when the music isn't ours 2026-10-21 12:44:46 -07:00
Seb3thehacker ac8488200d keep homemade head units without a certificate working, and read wheel buttons and the knob as main does 2026-10-21 12:30:38 -07:00
Seb3thehacker c7077f2a57 version: answer 1.7 to head units offering 4.x and 6.x too, as they accept it 2026-10-21 12:30:38 -07:00
Iscle 6efdad5e53 Validate input and correct navigation and sensor services 2026-10-21 12:30:38 -07:00
Iscle c04c35b5cd Fix protocol framing, TLS, and connection negotiation 2026-10-21 12:30:03 -07:00
Seb3thehacker 75c11a92d9 browser: full screen, for the page or a video in it
A button in the header hides it and the nav bar; a page's own full screen,
such as a video's, fills the browser too. A swipe in from the top or bottom
edge brings the nav bar back for a few seconds, with a button to leave, and
Back leaves full screen first.

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

The car preview can also draw at the last car's size, this device's whole
screen, or a size typed in, for head units running Gearslip on their own.
2026-10-19 22:19:18 -07:00
Seb3thehacker 86a1f968ec logs: share a log from an earlier launch
Opening Gearslip after a crash starts a new log, so the one that saw the
crash could only be pulled with a computer. Logs now has an Earlier button
listing past launches, newest first, marked when they crashed; tapping one
shares it. The newest 30 are kept.
2026-10-19 22:15:21 -07:00
Seb3thehacker 325e115752 nav bar: can hide while navigating, or until the screen is tapped
Set in car Settings. Off "Always show", the bar floats over the map, so
hiding it never resizes the map app's surface; the app is told the bar
covers the bottom while it shows. The arrival card no longer counts as
covering the map, so Waze's speedometer sits level with it.
2026-10-16 13:51:44 -07:00
Seb3thehacker 08ce09ad61 home: the player sits flush against the map, no gap between them 2026-10-16 13:45:14 -07:00
Seb3thehacker d78585fda1 navigation: directions in short bars, measured so apps keep their map clear of them
OsmAnd centred its arrow across the whole map because we only told it about
the arrival time, and Waze's speedometer sat under the map buttons. The turn
is now one short bar across the top and the arrival time one line along the
bottom; both bars and the button column are measured where they land and
left out of the area the app is told it can see.
2026-10-16 13:45:14 -07:00
Seb3thehacker 4e71689f8e audio: with music on Bluetooth, leave the car's media channel closed so the car keeps its Bluetooth source 2026-10-15 12:38:06 -07:00
Seb3thehacker e8f51f4947 0.3.01-alpha: wireless around VPNs and early Wi-Fi details, re-signed apps lose the red X 2026-10-15 12:38:06 -07:00
Seb3thehacker 2281347728 links: the repo now lives at twinksoftware/gearslip 2026-10-15 12:38:06 -07:00
Seb3thehacker 6d08fbd871 license: Gearslip now belongs to Twink Software LLC 2026-10-15 12:38:06 -07:00
Seb3thehacker 1322cbaf39 readme: package name and certificate hash for AppVerifier 2026-10-15 08:04:28 -07:00
Seb3thehacker db6f1b8e82 launcher: a patched Waze, signed with someone else's key, loses the red X and can start as the map app 2026-10-15 07:50:34 -07:00
Seb3thehacker 0177975bc8 wireless: wait for the address when the Wi-Fi details come first, and work around or name a VPN that blocks the car 2026-10-15 07:25:59 -07:00
Seb3thehacker 5f30e7b014 readme: an eighth screenshot, coffee nearby in Vela 2026-10-15 07:21:47 -07:00
Seb3thehacker c42406f353 support: Liberapay first again, then Buy Me a Coffee, Monero last 2026-10-14 23:41:34 -07:00
Seb3thehacker e8d025df47 support: say Buy Me a Coffee keeps 5% 2026-10-14 23:39:05 -07:00
Seb3thehacker c6d8d8e787 support: Buy Me a Coffee first in Settings, the popup, the thank-you page and the repo's Sponsor button; Monero last 2026-10-14 23:36:30 -07:00
Seb3thehacker 77b84212db readme: new screenshots for 0.3, with the assistant, a route in the bar, and the song title 2026-10-14 23:34:35 -07:00
Seb3thehacker b12e9651c1 wireless: when Android won't ask for the permissions, a button to enable Nearby devices in the app's settings 2026-10-14 22:06:24 -07:00
Seb3thehacker a5c46967b7 voice: drop the unused archive download and commons-compress with it, which CodeQL flagged 2026-10-14 21:11:40 -07:00
Seb3thehacker 30a4b65a08 release: notes list calls as a known issue, and new releases start as pre-releases 2026-10-14 19:57:32 -07:00
Seb3thehacker 88ff5b0606 voice: Parakeet small downloads as plain files, like the other models, instead of an archive the phone unpacks slowly 2026-10-14 19:56:34 -07:00
Seb3thehacker aa15884897 Merge branch 'experimental' 2026-10-14 19:49:37 -07:00
Seb3thehacker 84e1145459 intro: drop the Let's drive page, so it ends on thanks 2026-10-14 19:45:47 -07:00
Seb3thehacker 53de4a2512 0.3.00-alpha, with its notes in the app and on the GitHub release 2026-10-14 19:40:25 -07:00
Seb3thehacker ed46868d9b voice: say Canary knows English, Spanish, German and French, and that Gearslip listens in English for now 2026-10-14 19:36:23 -07:00
Seb3thehacker 2bf4753125 support: Monero, in a popup with the full address, Open in wallet and Copy address 2026-10-14 19:29:57 -07:00
Seb3thehacker 879b428d1f support: a Liberapay button on the intro's thank-you page, no "through a nonprofit", and room for Monero and a give-once card link, hidden until they're set 2026-10-14 19:21:41 -07:00
Seb3thehacker 35bd239c2f support: Liberapay only until GitHub Sponsors is approved, and "support" instead of "donate", since giving to the LLC isn't tax-deductible 2026-10-14 19:17:19 -07:00
Seb3thehacker 815a75ad6d car: say when the phone's location is off, ten seconds into a drive, with Don't show again and a setting to turn the warning off 2026-10-14 15:45:10 -07:00
Seb3thehacker 9b7734bec8 voice: "navigate to X" picks the nearest X in the map app's own search and stops at its start screen; it no longer sends the phone's position, which Organic Maps took as the destination 2026-10-14 15:45:10 -07:00
Seb3thehacker 7ffa203221 voice: a new destination mid-route ends the old route first instead of being ignored; switching or closing map apps ends the old app's route 2026-10-14 14:24:47 -07:00
Seb3thehacker 9f982875fe release builds shrink with R8: 65 MB down to 30 MB, small enough for IzzyOnDroid. -Pr8 does the same to a debug build for testing 2026-10-14 13:29:00 -07:00
Seb3thehacker 5c799fed38 Change Liberapay entry to username only
Updated Liberapay username to remove URL.
2026-10-14 12:53:27 -07:00
Seb3thehacker 249e6cefed Add Liberapay username to FUNDING.yml
Updated Liberapay entry with a specific username.
2026-10-14 12:51:45 -07:00
Seb3thehacker 3c9603175e debug builds send usage notes too, marked debug so the server keeps them out of the stats; call them debug builds everywhere instead of dev 2026-10-14 12:25:45 -07:00
Seb3thehacker 10dfb46a96 player: draw the app's own like, shuffle and repeat buttons, lit when their icon says they're on (Tidal showed every song liked and shuffle off) 2026-10-14 00:42:04 -07:00
Seb3thehacker e587ea97e1 intro: tell updates from new installs by setup having been done, so clearing data counts as new 2026-10-13 19:11:33 -07:00
Seb3thehacker daa6f24289 intro: new page order ending on thanks, a plainer welcome, and it resumes where it was after the app restarts 2026-10-13 19:08:48 -07:00
Seb3thehacker 76955669f5 intro: only after an update, never on a new install; recommend Organic Maps before Vela and ViVi Music before Metrolist; the thanks page links the list of cars that work 2026-10-13 19:00:51 -07:00
Seb3thehacker cd7b13c88e intro: Back and Next buttons like the setup guide, instead of tapping anywhere 2026-10-13 18:15:26 -07:00
Seb3thehacker 3d642c17c7 home: the Bluetooth warning is yellow, not red 2026-10-13 18:12:42 -07:00
Seb3thehacker cce3b50b52 music: an app whose library never answers falls back to its playback controls after 8 seconds, instead of spinning for good 2026-10-13 18:09:04 -07:00
Seb3thehacker 88e1885b2d car audio: with Bluetooth audio chosen, warn on the car and on Home when the phone isn't connected to the car 20 seconds in 2026-10-13 18:09:04 -07:00
Seb3thehacker a84d36f17a voice: app names match however the recognizer spaces them, so "with map quest" picks MapQuest 2026-10-13 17:57:30 -07:00
Seb3thehacker 8b269f4734 voice: opening a screen mid-download no longer deletes the download in progress 2026-10-13 17:54:36 -07:00
Seb3thehacker 3042e01e75 donations: at the top of Settings, and a popup on Home after the third drive that worked, then at most every three months 2026-10-13 17:42:05 -07:00
Seb3thehacker 71397a0777 settings: donation links are back, in their own section above About 2026-10-13 17:39:15 -07:00
Seb3thehacker cae05ecdc0 car launcher: only apps that don't work get a mark, a red X, and the badge key is gone 2026-10-13 17:39:15 -07:00
Seb3thehacker 73be554eb2 car audio: pause the phone's music when the car switches to the radio, send AAC to cars that take nothing else, and open GM's own channel in GM cars 2026-10-13 17:14:32 -07:00
Seb3thehacker 2812613bca wireless: say the car must be paired first, with a button to Bluetooth settings 2026-10-13 17:14:32 -07:00
Seb3thehacker 014942338b usage notes: shorter intro and options, so all four fit on screen 2026-10-13 17:04:47 -07:00
Seb3thehacker 3c81980385 usage notes: name every field each option sends 2026-10-13 16:58:46 -07:00
Seb3thehacker c029a0894a voice: one button downloads Kroko and Canary in setup, with progress on Home and in a silent notification 2026-10-13 16:56:56 -07:00
Seb3thehacker eba8ac1127 home: What's new and which cars work sit at the bottom 2026-10-13 16:45:59 -07:00
Seb3thehacker 1086e7c8fa cars: a list of cars drivers have confirmed, linked from the README and the help screen 2026-10-13 16:40:21 -07:00
Seb3thehacker 470ec2f093 usage notes: say whether each drive was USB or wireless, and whether music went over USB or Bluetooth 2026-10-13 16:24:28 -07:00
Seb3thehacker 341e62d1d8 readme: notes say which boxes are ticked 2026-10-13 16:17:49 -07:00
Seb3thehacker 2891a170db usage notes: say which boxes are ticked, so the server can drop details from unticked ones 2026-10-13 16:17:49 -07:00
Seb3thehacker 3855cc4781 readme: wireless, voice, the Bluetooth or USB audio choice, and updates 2026-10-13 16:13:02 -07:00
Seb3thehacker f1a6901fbc usage notes: ask after the first good drive only if setup never asked 2026-10-13 16:11:43 -07:00
Seb3thehacker b848c5d910 home: What's new and which cars work under the status card; the usage notes switch stays in Settings 2026-10-13 16:06:26 -07:00
Seb3thehacker 4f1e6dcbfa wireless: Disconnect works while the car hasn't answered yet 2026-10-13 16:00:40 -07:00
Seb3thehacker 10e8568aba intro: thank note senders for confirming cars newer than 2020 2026-10-13 15:55:29 -07:00
Seb3thehacker 724dcf25c7 home: put Connect wirelessly in the status card, with the usage notes switch always under it 2026-10-13 15:48:54 -07:00
Seb3thehacker dd1fd0242f home: put Connect wirelessly in the status card, as a full button 2026-10-13 15:41:59 -07:00
Seb3thehacker 6faad9fdd5 help: say most cars work, group the steps, and show the adb steps only for USB audio 2026-10-13 15:39:20 -07:00
Seb3thehacker 3869258e0e home: show only the car's status; help, preview, logs and the dev test message move to Settings 2026-10-13 15:38:40 -07:00
Seb3thehacker 7d9eb20ff0 updates: hide the channel picker until a pre-release ships 2026-10-13 15:27:15 -07:00
Seb3thehacker 8fd840bd8d updates: offer the final release to people on its beta or alpha 2026-10-13 15:21:17 -07:00
Seb3thehacker 5947bc1e63 intro: add a step inviting drivers to the Matrix chat 2026-10-13 15:21:17 -07:00
Seb3thehacker e13b0618db voice: recommend Canary for the final words, at a third of Parakeet large's size 2026-10-13 15:15:41 -07:00
Seb3thehacker 9100dac290 settings: tidy the order, fold updates into one row with Stable, Beta and Alpha, and drop the donation asks and the after-drive notification 2026-10-13 15:05:45 -07:00
Seb3thehacker f1fe94c487 bluetooth: offer to pair with the car on first connect, and pick Bluetooth or USB audio in setup 2026-10-13 14:55:11 -07:00
Seb3thehacker 5fe59d350c wireless: send about a third of the cable bitrate at 30fps, so the video keeps up and the music stops cutting out 2026-10-13 14:31:03 -07:00
Seb3thehacker 909c3549d6 wireless: ask for Android 17's local network permission, without which the car never hears from the phone 2026-10-13 14:12:13 -07:00
Seb3thehacker 1514721498 wireless: connect over the phone's main Wi-Fi when it's already on the car's network, and log why traffic is held back 2026-10-13 14:03:55 -07:00
Seb3thehacker 4000e0a9e9 wireless: keep trying over the phone's own Wi-Fi when Android drops the car's network 2026-10-13 13:19:22 -07:00
Seb3thehacker 02d6cb546e Merge branch 'experimental' into wireless 2026-10-13 12:41:09 -07:00
Seb3thehacker 59f323ec06 directions: speak each turn on the car's guidance channel
Map apps' own voice prompts can't reach the car over USB, so Gearslip reads
the turn the cluster already gets and says it through the car's directions
sink, which ducks the music. On by default, with a switch in car settings.
2026-10-13 12:26:31 -07:00
Seb3thehacker 0d0bfd3d66 setup: the 0.3 look, with a badge, a big headline and shorter words on every step 2026-10-13 01:28:54 -07:00
Seb3thehacker b3f2088a1d intro: pick the car's button style, with sample buttons that redraw as you choose 2026-10-13 01:14:06 -07:00
Seb3thehacker 01a41c01c8 voice: "pause" keeps the music paused after the mic lets go 2026-10-13 00:52:00 -07:00
Seb3thehacker 62b1835444 theme: flat is the default 2026-10-13 00:45:39 -07:00
Seb3thehacker 1cd3d6f856 car mic: open a voice session and name the codec before asking for the car's microphone 2026-10-13 00:37:49 -07:00
Seb3thehacker 8912e88406 voice: "navigate to the nearest gas station" searches for a gas station near the car 2026-10-13 00:34:49 -07:00
Seb3thehacker 0bdc51ff6c voice: answer back when sworn at 2026-10-13 00:09:58 -07:00
Seb3thehacker 361caf5ea3 car audio: name the codec in the audio Setup, so cars that rejected it play music over USB 2026-10-13 00:09:27 -07:00
Seb3thehacker d53535e56c voice: download the models as plain files, skipping the slow bzip2 unpacking 2026-10-12 23:59:43 -07:00
Seb3thehacker 49a064acc0 video: cars that give no touchscreen size get the largest picture up to 1080p 2026-10-12 23:52:03 -07:00
Seb3thehacker 6d4389c7ab touch: shift taps past the car's cropped margin, so buttons land where they're pressed 2026-10-12 23:50:24 -07:00
Seb3thehacker 9fe463c4e7 Merge branch 'experimental' into wireless
# Conflicts:
#	gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeScreen.kt
2026-10-12 22:09:43 -07:00
Seb3thehacker a3761534bb security: scan the Kotlin code with CodeQL by building the app 2026-10-12 21:10:36 -07:00
Seb3thehacker ceed61def9 security: scan the Kotlin code with CodeQL by building the app 2026-10-12 21:10:35 -07:00
Seb3thehacker fd0ef64a42 security: a weekly Trivy scan and Dependabot updates 2026-10-12 21:00:25 -07:00
Seb3thehacker 0d29263f0a security: a weekly Trivy scan and Dependabot updates 2026-10-12 21:00:20 -07:00
Seb3thehacker 28d99a3d87 voice: "play X" reopens the music app used last when none is open or it was closed 2026-10-12 17:16:25 -07:00
Seb3thehacker a526db6cd1 templated search: keep the keyboard down when the app asks, so results opened by voice show 2026-10-12 17:09:51 -07:00
Seb3thehacker 000ca85479 voice: Canary 180M as a middle choice for the final words 2026-10-12 16:38:32 -07:00
Seb3thehacker e76e9ddcf7 navigation: search near the phone, so "navigate to Del Taco" finds the closest one 2026-10-12 16:21:13 -07:00
Seb3thehacker 8069f54d8f voice: free Parakeet after 5 minutes without use and both models when the car disconnects 2026-10-12 16:20:15 -07:00
Seb3thehacker df85b0d1f3 car keyboard: taller keys, and search sits in the bottom-right corner 2026-10-12 16:19:27 -07:00
Seb3thehacker 4ac3d172d3 voice: understand natural phrasing for every command, open Gearslip's own apps by name, and say "exit" to hand the screen back 2026-10-12 16:10:23 -07:00
Seb3thehacker d099193ead car: call the Web app Browser 2026-10-12 16:02:53 -07:00
Seb3thehacker bc8f2372b1 voice: start listening sooner, say so while it gets ready, and scroll the help list from anywhere on it 2026-10-12 16:02:53 -07:00
Seb3thehacker 000bd1f3f4 bigger install image 2026-10-12 15:49:07 -07:00
Seb3thehacker 5fa9ed17e1 remove . 2026-10-12 15:44:21 -07:00
Seb3thehacker e2b9118354 readme: drop the link to the stats server's code 2026-10-12 15:43:21 -07:00
Seb3thehacker e1233cb1e4 readme: how Gearslip is made, a contributing section, and some badges 2026-10-12 15:40:56 -07:00
Seb3thehacker 3ca87d1afc log: explain that answering every car with protocol 1.3 is on purpose 2026-10-12 14:16:51 -07:00
Seb3thehacker 8e98851e47 0.3 intro: a recommended apps screen, and AntennaPod in the recommended list 2026-10-12 14:12:04 -07:00
Seb3thehacker af5308a9f9 readme: say plainly that AI writes nearly all the code 2026-10-12 12:44:26 -07:00
Seb3thehacker 7d6b9cf578 car audio: run the audio threads at audio priority so a busy phone doesn't make it skip 2026-10-12 01:31:24 -07:00
Seb3thehacker b2df84c46b voice and media search are built in now, with a voice intro screen and a new overlay over the car screen 2026-10-12 01:31:24 -07:00
Seb3thehacker 30d94bcfb5 voice: replace Whisper with sherpa-onnx, live words from Kroko and the final words from Parakeet
Models download in Settings instead of shipping in the APK. Credits for the engine and models are in About.
2026-10-12 00:51:31 -07:00
Seb3thehacker af9e9a7571 voice: play songs by name, a help list, and the phone's speech recognition as an option; Spotify connects without the wait 2026-10-12 00:20:36 -07:00
Seb3thehacker 7163598352 car media: drop the "Audio to car" line; name every audio focus state 2026-10-12 00:20:36 -07:00
Seb3thehacker 94ed58e13f 0.3 intro, donation links on Home, and About links for Matrix and the source 2026-10-12 00:20:36 -07:00
Seb3thehacker 59657c4f80 ixon fix mono color 2026-10-11 20:20:02 -07:00
Seb3thehacker 8abc8fe973 remove the "Before you plug in" popup on first launch 2026-10-11 20:01:36 -07:00
Seb3thehacker 4fe9421884 car audio: explain a blocked microphone permission instead of failing silently 2026-10-11 20:01:36 -07:00
Seb3thehacker 04b820cb22 wireless: wait for the car to connect when it publishes no Android Auto service 2026-10-11 17:22:58 -07:00
Seb3thehacker 669bf1f7ce Merge branch 'experimental' into wireless
# Conflicts:
#	gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipActivity.kt
#	gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeScreen.kt
2026-10-11 17:22:58 -07:00
Seb3thehacker 83916a6397 wireless connection attempt 2026-10-11 16:10:24 -07:00
Seb3thehacker 5a57608591 matrix link 2026-10-11 16:08:50 -07:00
Seb3thehacker f58ecf303a explain testing process and define AI use 2026-10-11 15:37:57 -07:00
Seb3thehacker 61a7188ad2 Add a contributor license agreement
Contributors keep their copyright and license their work to Twink Software LLC, which keeps it available under the AGPL and may also offer it under the commercial license. CONTRIBUTING now points to it.
2026-10-11 14:48:52 -07:00
Seb3thehacker fff2db63b0 updates: beta channel, and a switch to turn off automatic checks
Beta updates counts GitHub pre-releases as updates. With Check automatically off, Gearslip contacts GitHub only when you tap Check for updates.
2026-10-11 14:48:46 -07:00
Seb3thehacker fb6bc018d5 Add a contributor license agreement
Contributors keep their copyright and license their work to Twink Software LLC, which keeps it available under the AGPL and may also offer it under the commercial license. CONTRIBUTING now points to it.
2026-10-11 14:48:46 -07:00
Seb3thehacker 580a8365eb car screen: stop vibrating the phone on long presses 2026-10-11 11:39:22 -07:00
Seb3thehacker 5721b9efb3 icon: remove unused archive images 2026-10-11 11:34:06 -07:00
Seb3thehacker ed331088cc stats: name the phone's OS instead of only GrapheneOS or other
Reports GrapheneOS, CalyxOS, /e/OS, iodeOS, DivestOS, LineageOS, or stock with the brand.
2026-10-11 11:34:06 -07:00
Seb3thehacker 33ed2d0653 setup: drop the GrapheneOS exploit protection step 2026-10-11 11:34:06 -07:00
Seb3thehacker 261e6a719a car audio: choose Bluetooth or USB, offer Bluetooth after a refused screen share
USB audio needs Android's screen-sharing consent every drive. A new Car audio setting on the phone and car screens picks Bluetooth instead, and the first refusal opens a dialog offering Bluetooth, asking again, or the one-time adb command.
2026-10-11 11:34:06 -07:00
Seb3thehacker f9e38cbc45 icon: drop the black square behind the README icon 2026-10-11 00:59:45 -07:00
Seb3thehacker cfa01d4195 icon: drop the black square behind the README icon 2026-10-11 00:59:36 -07:00
Seb3thehacker e843f2adfe car knob: move focus with the rotary knob, show where it is
The knob's turns reached the car screen but never moved focus: keys handed
straight to the view left it in touch mode, where buttons refuse focus.
Buttons now show an accent ring while the knob is on them, map buttons wake
on a knob turn instead of only on touch, and web pages get the same ring.

The car's Home button opens the dashboard, Map opens the map, and Music
reopens the last media app.

Refs #9
2026-10-10 17:55:36 -07:00
Seb3thehacker 0af16d3348 Merge pull request #16 from iscle/fix/connection-handshake-lifecycle
Stop cleanly and ignore messages after handshake rejection
2026-10-10 14:47:46 -07:00
Iscle 8743e992d7 Stop cleanly and ignore messages after handshake rejection 2026-10-10 23:34:39 +02:00
Seb3thehacker 6f9fc061e3 tell the car about the Wi-Fi join before TCP 2026-10-09 15:30:41 -07:00
Seb3thehacker 239be1256b place pages: name on top, actions as buttons, call a place's number
Panes shaped like a place page (MAPS.ME's) show the name and kind first, short
tappable rows as buttons, and the details compact below. A phone number gets a
Call button that asks first with the dialer's confirmation. Other panes draw as
before.
2026-10-09 15:26:48 -07:00
Seb3thehacker 23f12884da car toasts: take turns instead of replacing each other
A new toast waits until the one on screen has had 2 seconds. Repeats restart the
visible toast, and a burst keeps only the newest three waiting.
2026-10-09 15:26:48 -07:00
Seb3thehacker 75dfaa3013 car icons: drop app tints that can't be seen, mark MAPS.ME and MapQuest as working
MAPS.ME tints its map buttons dark grey for dark mode, which vanished on Gearslip's
dark buttons. A tint below 3:1 contrast now falls back to the normal text colour,
as Android Auto does.
2026-10-09 15:26:48 -07:00
Seb3thehacker 179a51ea25 car toasts: match the message popup and stay up longer
A driver only glances at the screen, so short toasts now last 4 seconds and long
ones 7. The card uses the popup's colours, which read better over a map.
2026-10-09 14:24:32 -07:00
Seb3thehacker 006823232d Merge remote-tracking branch 'origin/experimental' into experimental 2026-10-09 14:24:28 -07:00
Seb3thehacker 0838bbb3cc Merge pull request #11 from iscle/feature/host-toasts
Show app toasts on the projected car display
2026-10-09 14:23:50 -07:00
Seb3thehacker 52cb769adc Merge pull request #10 from iscle/feature/host-media-category
Recognize media templates and group them under Music
2026-10-09 14:18:12 -07:00
Seb3thehacker 9c8b82d66a Merge branch 'experimental' into feature/host-media-category 2026-10-09 14:14:28 -07:00
Seb3thehacker 92d7940f56 car apps: let hosted apps start their own location service, fixing MapQuest
Since Android 14, an app bound by a visible host may start a foreground service
only if the host binds it with BIND_ALLOW_ACTIVITY_STARTS. Without it, MapQuest
crashed as navigation began, and Flitsmeister and Vela ran their services without
location. MapQuest now gets the working check.
2026-10-09 13:50:40 -07:00
Iscle 4c2c8c6dda Show app toasts on the projected car display 2026-10-09 20:58:12 +02:00
Iscle 4a6305052d Recognize media template apps and group them under Music 2026-10-09 20:50:43 +02:00
Seb3thehacker fd658d4e57 certificate: say where it comes from, offer import alongside download, keep cert files out of the repo 2026-10-08 15:27:48 -07:00
Seb3thehacker ddaf3e5657 move the certificate screen changes to the experimental branch 2026-10-08 15:27:36 -07:00
Seb3thehacker a549077c13 certificate: say where it comes from, offer import alongside download, keep cert files out of the repo 2026-10-08 15:25:04 -07:00
Seb3thehacker 246f571a86 readme: around 2020 2026-10-08 10:37:53 -07:00
Seb3thehacker a22d7936d4 readme: move which cars work to the top 2026-10-08 10:37:06 -07:00
Seb3thehacker 74330db01c readme: say most cars work, and why some newer ones don't 2026-10-08 10:35:30 -07:00
Seb3thehacker 516f6c7f94 ask once to share after the first drive that works, cache the cert for settings 2026-10-08 00:27:54 -07:00
Seb3thehacker 40086e061b audio pause when unplugged, theme tweak 2026-10-08 00:06:34 -07:00
Seb3thehacker c212c2e2d1 usage notes changes, audio changes, video quality increase. 2026-10-07 23:50:47 -07:00
Seb3thehacker fb4b0e2914 connection proxy negotiation 2026-10-07 23:41:16 -07:00
Seb3thehacker d0fd6ecb5c wireless crash fix 2026-10-07 22:40:41 -07:00
Seb3thehacker 39e1a5ee09 wireless func added (testing) 2026-10-07 18:45:57 -07:00
Seb3thehacker 55320c4d98 example data 2026-10-07 16:37:40 -07:00
Seb3thehacker 9dc705c8cf direct download link to obtainium 2026-10-07 15:11:06 -07:00
Seb3thehacker be981b0899 start car audio as soon as the head unit answers setup 2026-10-07 00:10:28 -07:00
Seb3thehacker dd3b845c7f stream directions to car directly 2026-10-06 23:33:18 -07:00
Seb3thehacker c57abecc62 send song data to car screens 2026-10-06 23:33:18 -07:00
Seb3thehacker dfebae93b3 higher video bitrate, scaled to the car's screen
800x480 at 30fps gets 8 Mbps instead of 4, and larger screens scale from
there. The encoder now declares the H.264 level each size needs, and the
bitrate stays within that level's limit.
2026-10-06 23:33:18 -07:00
Seb3thehacker 1af5eae8e7 usage notes switch on the Home screen 2026-10-06 23:33:18 -07:00
Seb3thehacker 25b8bbb1a5 keep the stats server out of the app repo 2026-10-06 23:33:18 -07:00
Seb3thehacker 2d5000e313 keep the stats server out of the app repo 2026-10-06 23:25:28 -07:00
Seb3thehacker 17534d5784 update checker: finds new releases, downloads and installs them
Checks GitHub's latest release once a day when the app opens and after a
drive. A new version shows as a card on Home, a notification after the
drive, and a card on the car screen at plug-in while parked. Downloads are
checked against GitHub's SHA-256 and never install while the car is
connected. Update notifications can be turned off in Settings.
2026-10-06 00:06:06 -07:00
153 changed files with 11792 additions and 2204 deletions

No files matched your search

+15
View File
@@ -0,0 +1,15 @@
# These are supported funding model platforms
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: Seb3thehacker
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: seb3thehacker
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
+1 -1
View File
@@ -5,7 +5,7 @@ body:
- type: markdown - type: markdown
attributes: attributes:
value: | value: |
Thanks for reporting. Check the [open issues](https://github.com/Seb3thehacker/gearslip/issues) first, in case someone has already reported it. Thanks for reporting. Check the [open issues](https://github.com/twinksoftware/gearslip/issues) first, in case someone has already reported it.
- type: input - type: input
id: car id: car
+15
View File
@@ -0,0 +1,15 @@
# Weekly pull requests for newer Gradle libraries and GitHub Actions, aimed at experimental,
# where unreleased code lands. Security fixes come separately, as soon as an alert opens.
version: 2
updates:
- package-ecosystem: gradle
directory: /
target-branch: experimental
schedule:
interval: weekly
open-pull-requests-limit: 5
- package-ecosystem: github-actions
directory: /
target-branch: experimental
schedule:
interval: weekly
+27
View File
@@ -0,0 +1,27 @@
Gearslip 0.3.02 connects wirelessly by itself, and phone calls work much better.
## New
- Wireless connects by itself. After your first wireless drive, Android asks once whether Gearslip may watch for that car. From then on, Gearslip connects whenever the car's Bluetooth does. A USB cable still wins.
- Phone calls work much better. Calls you place from the car's dialer go through, and you can end or mute a call from the nav bar, which shows who you're talking to and for how long.
- The browser goes full screen, for the whole page or a video in it.
- The nav bar can hide while you navigate, or until you tap the screen.
- Directions sit in short bars, so map apps keep their maps clear of them.
- Share a log from an earlier launch, not just the current one.
- In the car preview, you type with the phone's keyboard.
## Changed
- Help, Preview, and Logs sit in a row at the bottom of the phone's home screen.
- What's new, at the top of Settings, opens the release notes here on GitHub.
## Fixed
- Before each spoken turn, Gearslip asks the car for its speakers, as Android Auto does. Some head units rebooted without this.
- An app that won't run in Gearslip, such as Yandex Maps or 2GIS, now says so after 10 seconds instead of showing "Starting" forever.
- Head units that take only AAC music get one start message, not two. A 2014 aftermarket unit dropped the connection over the second.
- With music on Bluetooth, the car keeps its Bluetooth source.
- Head units offering Android Auto 4.x and 6.x get a version they accept.
- Homemade head units without a certificate keep working, and the wheel buttons and knob work as they do in the main build.
- A car that reports the wrong screen size gets the right one.
- iscle fixed how Gearslip frames, secures, and negotiates the connection, and how it checks what the car sends.
## Known issues
- Call audio plays through the car's Bluetooth phone connection, as it does with Android Auto. If a call plays on the phone instead, open the car in the phone's Bluetooth settings and turn on Phone calls.
+50
View File
@@ -0,0 +1,50 @@
name: CodeQL
# Kotlin can only be scanned while it compiles, so this builds the debug app, the same way
# release.yml does but without a signing key. Workflow files need no build.
on:
push:
branches: [main, experimental]
pull_request:
schedule:
- cron: "0 9 * * 1"
workflow_dispatch:
jobs:
analyze:
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
actions: read
strategy:
fail-fast: false
matrix:
include:
- language: actions
build-mode: none
- language: java-kotlin
build-mode: manual
steps:
- uses: actions/checkout@v4
- uses: github/codeql-action/init@v4
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
- if: matrix.build-mode == 'manual'
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "21"
- if: matrix.build-mode == 'manual'
uses: gradle/actions/setup-gradle@v4
- if: matrix.build-mode == 'manual'
run: ./gradlew :gearslip:assembleDebug --no-daemon
- uses: github/codeql-action/analyze@v4
with:
category: "/language:${{ matrix.language }}"
+5
View File
@@ -45,4 +45,9 @@ jobs:
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
files: ${{ steps.apk.outputs.path }} files: ${{ steps.apk.outputs.path }}
# The hand-written notes go first; GitHub adds the full changelog link under them.
body_path: .github/release-notes.md
generate_release_notes: true generate_release_notes: true
# Every release starts as a pre-release: only phones on the Alpha update channel get it.
# Once it's tested, untick pre-release on GitHub and everyone gets it.
prerelease: true
+34
View File
@@ -0,0 +1,34 @@
name: Security scan
# Trivy looks through the repository for committed secrets and risky workflow or build
# settings. Findings show under the Security tab, next to CodeQL's and Dependabot's.
on:
push:
branches: [main, experimental]
pull_request:
schedule:
- cron: "0 9 * * 1"
workflow_dispatch:
jobs:
trivy:
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
steps:
- uses: actions/checkout@v4
- uses: aquasecurity/trivy-action@v0.36.0
with:
scan-type: fs
scanners: vuln,secret,misconfig
format: sarif
output: trivy.sarif
severity: MEDIUM,HIGH,CRITICAL
- uses: github/codeql-action/upload-sarif@v4
if: always()
with:
sarif_file: trivy.sarif
category: trivy
+10 -2
View File
@@ -11,7 +11,7 @@ local.properties
*.keystore *.keystore
keystore.properties keystore.properties
# Native build cache. # Native build cache
.cxx/ .cxx/
# Testing # Testing
@@ -29,4 +29,12 @@ CLAUDE.md
# Other # Other
archive/ archive/
.wrangler/ .wrangler/
stats-worker/
# Certificates and keys never go in the repo; the app downloads or imports them on the phone.
*.p12
*.pfx
*.pem
*.crt
*.key
+49
View File
@@ -0,0 +1,49 @@
# Cars
Drivers who share usage notes tell us which cars Gearslip works in. This list comes from
those notes, and from our own testing. A car shows up here once Gearslip has reached its
screen at least once.
The year is the one the car reports. Some cars report the year of the head unit instead,
and some don't say.
## Works
| Car | Year | Head unit |
|---|---|---|
| Audi | | MIB2+ (Alpine) |
| Audi | | Harman |
| Chevrolet, with myChevrolet | 2019, 2020, 2021 | myChevrolet |
| Chevrolet, with MyLink | 2017 | MyLink |
| Dacia | | Media Nav (LG ULC 4.1) |
| Dacia | | LG ULC 4.5 |
| Ford | 2018 | SYNC 3.0 (Panasonic) |
| Ford Fiesta | 2019 | SYNC 3.2 (Panasonic) |
| Ford Fusion | 2018 | SYNC 3.1 (Panasonic) |
| Jeep Cherokee | 2019 | Uconnect 8.4 (Delphi) |
| MG | 2019 | Desay SV |
| Ram 1500 | 2018 | Uconnect 8.4 (Panasonic) |
| Škoda | 2018 | MIB Standard2 (TechniSat) |
| Subaru Outback | 2020 | Denso |
| Toyota RAV4 | 2020 | Pioneer T1 |
| Volkswagen | 2018 | MIB Standard2 (Delphi) |
| Volkswagen | 2024 | ICAS3 |
| Volvo XC90 | 2022 | SPA Plus (Mitsubishi Electric) |
| Wuling | 2021 | Desay SV |
| [LIVI](https://github.com/f-io/LIVI), the open-source head unit | | |
## Not working yet
| Car | Year | What happens |
|---|---|---|
| Honda | 2016 | The connection drops before Gearslip reaches the screen. |
| Honda | 2019 | The connection drops before Gearslip reaches the screen. |
| Mitsubishi Attrage | 2020 | The connection drops before Gearslip reaches the screen. |
Some cars reject Gearslip's certificate before they say what they are, so they can't appear
here by name. Most of these are from around 2020 or later.
## Add your car
Turn on usage notes in Gearslip's setup or Settings, and drive once. If your car is missing
or listed wrongly, open an issue.
+102
View File
@@ -0,0 +1,102 @@
# Gearslip Contributor License Agreement
Version 1.0, October 2026
Thank you for contributing to Gearslip. This agreement lets Twink Software LLC, the company
behind Gearslip, use your contribution. You keep the copyright in your work. You sign once,
and the agreement covers every contribution you make to Gearslip, past and future.
## Why we ask
Gearslip is free software under the GNU Affero General Public License, version 3 (the
"AGPL"). Twink Software LLC also sells commercial licenses to companies that cannot meet the
AGPL's terms, and that income helps pay for the project. Offering your code on those terms
needs your permission, and this agreement gives it.
## 1. Definitions
- **"We"** and **"us"** mean Twink Software LLC, a California limited liability company
(Secretary of State entity number B20260081700), under that name or any other, and its
successors and assigns.
- **"You"** means the person who signs this agreement.
- **"Contribution"** means any work of authorship, such as code, documentation, or images,
that you submit to Gearslip, whether before or after you sign. A submission includes a
pull request, patch, or issue attachment sent to a repository or channel we manage. It
excludes anything you mark in writing as "Not a Contribution".
## 2. Copyright license
You grant us, and anyone who receives software from us, a perpetual, worldwide,
non-exclusive, no-charge, royalty-free, irrevocable license to reproduce, prepare derivative
works of, publicly display, publicly perform, sublicense, and distribute your Contributions
and derivative works of them.
This license lets us offer your Contributions under licenses other than the AGPL, including
commercial licenses.
## 3. Patent license
You grant us, and anyone who receives software from us, a perpetual, worldwide,
non-exclusive, no-charge, royalty-free, irrevocable patent license to make, have made, use,
offer to sell, sell, import, and otherwise transfer your Contributions. This license covers
only the patent claims you can license that your Contribution, alone or combined with
Gearslip, would infringe.
If anyone sues claiming that Gearslip or a Contribution infringes a patent, the patent
licenses granted to that party for Gearslip end on the date the suit is filed.
## 4. Our promise to you
As long as we distribute a Contribution, we will also offer it under the AGPL or another
license approved by the Open Source Initiative. Your work will always stay available as
free software.
## 5. Your promises
You promise that:
1. **The work is yours.** Each Contribution is your original creation, and you have the
right to grant these licenses.
2. **Your employer agrees.** If your employer has rights in what you create, you have its
permission to contribute on its behalf, or it has waived its rights in your
Contributions.
3. **You name other people's work.** If any part of a Contribution is not your own, you say
so when you submit it, and you name its source and its license.
4. **You'll tell us of changes.** If any of these promises stops being true, you will tell
us.
## 6. No warranty
You provide your Contributions "as is", without warranties or conditions of any kind. You do
not have to provide support for them.
## 7. No obligation
We are not required to use or include any Contribution.
## 8. General
- California law governs this agreement.
- This agreement does not change the rights you have under the AGPL in Gearslip as
released.
- We may publish a new version of this agreement. A new version applies only to
Contributions you make after you sign it; this version keeps covering the ones you made
under it.
## Signing
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.
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).
Name: ______________________________
GitHub username: ______________________________
Email: ______________________________
Signature: ______________________________
Date: ______________________________
+8 -4
View File
@@ -5,7 +5,7 @@ start there if you aren't sure where to begin.
## Reporting a bug ## Reporting a bug
Open an issue on [GitHub](https://github.com/Seb3thehacker/gearslip/issues) and include: Open an issue on [GitHub](https://github.com/twinksoftware/gearslip/issues) and include:
- your car's make, model, and year, and whether the head unit is the original one; and - your car's make, model, and year, and whether the head unit is the original one; and
- the log from the drive. Open Logs on the phone app's home screen and tap Share log. - the log from the drive. Open Logs on the phone app's home screen and tap Share log.
@@ -46,6 +46,10 @@ voice, state things positively, cut needless words, and use the serial comma.
## License ## License
Gearslip is licensed under AGPL-3.0, and your contributions are too. The author also sells a Gearslip is licensed under AGPL-3.0, and your contributions are too. Twink Software LLC, which
commercial license, described in [NOTICE.md](NOTICE.md). By opening a pull request, you owns Gearslip, also sells a commercial license, described in [NOTICE.md](NOTICE.md).
agree that your contribution may be offered under that license as well.
Before we can merge your first pull request, you sign the
[Contributor License Agreement](CLA.md). You keep the copyright in your work, and it stays
available as free software; the agreement lets us offer it under the commercial license too.
A bot comments on your pull request with a link, and signing takes one click.
+1 -1
View File
@@ -1,4 +1,4 @@
Copyright (C) 2026 seb Copyright (C) 2026 Twink Software LLC and contributors
GNU AFFERO GENERAL PUBLIC LICENSE GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007 Version 3, 19 November 2007
+1 -1
View File
@@ -6,4 +6,4 @@ You may use, run, and modify Gearslip freely. If you distribute a modified versi
## Commercial licensing ## Commercial licensing
If the AGPL's terms don't work for you, the author sells a separate commercial license. The usual case is a company that wants to use Gearslip privately without publishing its changes. Terms, including any limits on modification, are set case by case. Contact me on Matrix; the address is on my profile. If the AGPL's terms don't work for you, Twink Software LLC, which owns Gearslip, sells a separate commercial license. The usual case is a company that wants to use Gearslip privately without publishing its changes. Terms, including any limits on modification, are set case by case. Contact us on Matrix; the address is on the [Seb3thehacker GitHub profile](https://github.com/Seb3thehacker).
+91 -28
View File
@@ -4,28 +4,44 @@
An Android Auto alternative built for everyone. An Android Auto alternative built for everyone.
[![forthebadge](https://forthebadge.com/badges/compatibility-club-penguin.svg)](https://forthebadge.com)
[<img src="https://raw.githubusercontent.com/ImranR98/Obtainium/main/assets/graphics/badge_obtainium.png" alt="Get it on Obtainium" height="80">](https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://github.com/twinksoftware/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
cars drivers have confirmed.
## What Gearslip is ## What Gearslip is
Gearslip is an Android Auto client for the phone. It speaks the Android Auto protocol Gearslip is an Android Auto client for the phone. It speaks the Android Auto protocol
directly to a car head unit over USB, with no Google software in the path. Gearslip acts directly to a car head unit, over USB or wirelessly, with no Google software in the path.
as a Car App Library host: an app that ships a car interface hands Gearslip a template, Gearslip acts as a Car App Library host: an app that ships a car interface hands Gearslip
and Gearslip renders that template natively on the car screen. Nothing is mirrored from a template, and Gearslip renders that template natively on the car screen. Nothing is
the phone. For apps without a car interface, Gearslip offers a separate screen-sharing mirrored from the phone. For apps without a car interface, Gearslip offers a separate
mode. screen-sharing mode.
| | | | | |
|---|---| |---|---|
| ![The launcher, with apps grouped into phone, maps, music, and messaging](docs/screenshots/launcher.png) | ![Vela Maps giving turn-by-turn directions, with the music player beside the map](docs/screenshots/navigation.png) | | ![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) |
| Apps sorted by kind. A check marks the ones that work. | Vela Maps navigating, with the player beside it. | | 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'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. | | 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 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 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. |
## Features ## Features
- **Phone calls.** Dial from a keypad, or search your contacts, then confirm before the - **Phone calls.** Dial from a keypad, or search your contacts, then confirm before the
call goes out. call goes out. When a call comes in, answer or decline it on the car screen.
- **Navigation.** Gearslip hosts real Car App Library map apps on the car screen. - **Navigation.** Gearslip hosts real Car App Library map apps on the car screen.
Organic Maps, OsmAnd, and Flitsmeister work today. Vela runs, with rough Organic Maps, OsmAnd, and Flitsmeister work today. Vela runs, with rough
edges. edges.
@@ -36,6 +52,9 @@ mode.
doesn't ship a car interface of its own. doesn't ship a car interface of its own.
- **Weather and the day ahead.** A dashboard screen shows the week's calendar next to - **Weather and the day ahead.** A dashboard screen shows the week's calendar next to
the current and coming weather. the current and coming weather.
- **Voice.** Tap the mic, or press the talk button on the wheel, and say "navigate to the
nearest gas station", "play Daft Punk", or "call Mom". Say "help" for the full list.
Speech turns into text on the phone, and nothing you say leaves it.
- **Messages.** New messages appear as a small card on the car screen. Gearslip reads - **Messages.** New messages appear as a small card on the car screen. Gearslip reads
each one aloud, and you reply by voice or with a quick reply. Music pauses while each one aloud, and you reply by voice or with a quick reply. Music pauses while
Gearslip speaks or listens. Speech runs on the phone, so it needs no Google account. Gearslip speaks or listens. Speech runs on the phone, so it needs no Google account.
@@ -48,8 +67,9 @@ mode.
## Status ## Status
- **The connection is wired only, for now.** Plug the phone into the head unit's USB port; - **Wired or wireless.** Plug the phone into the car's USB port, or, in a car with wireless
Gearslip does not yet offer a wireless option. Android Auto, tap Connect wirelessly and pick the car. Pair the phone with the car over
Bluetooth first.
- **Setup walks you through it.** First launch asks for the Android Auto certificate, - **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 then one permission at a time, each with a plain reason and an Allow or Skip. A help
screen inside the car answers anything setup didn't cover. screen inside the car answers anything setup didn't cover.
@@ -61,18 +81,32 @@ mode.
- **Some apps refuse Gearslip.** Spotify, Waze, and Google Messages use a version of - **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 Google's car library that accepts only Android Auto itself. Spotify still plays through
Gearslip's own player. Gearslip's own player.
- **Gearslip works best with cars from before 2020.** Some newer head units reject its
certificate.
- **Calls use the car's Bluetooth,** as they do in Android Auto. Pair the phone with the - **Calls use the car's Bluetooth,** as they do in Android Auto. Pair the phone with the
car to hear calls through its speakers. car to hear calls through its speakers.
- **Audio reaches the car by capturing playback, not by mirroring the screen.** Android - **Music reaches the car over Bluetooth or USB.** Setup asks which. Bluetooth is the
shows a screen-share consent dialog for this, even though Gearslip never reads the default: Android never asks for permission, and your map app's voice reaches the car
screen; it is the only way an ordinary app can capture another app's audio. The dialog too. USB sounds a little clearer, with no delay, but Android shows a screen-share
appears once per drive. consent dialog once per drive. Gearslip never reads the screen; capturing playback is
the only way an ordinary app can pass another app's audio along.
- **Gearslip needs no adb, root, or Shizuku.** Every feature relies on an ordinary - **Gearslip needs no adb, root, or Shizuku.** Every feature relies on an ordinary
Android permission or an on-device consent dialog. The setup guide offers two optional Android permission or an on-device consent dialog. For USB audio, setup offers two
adb commands: one removes the audio dialog for good, and the other keeps notifications optional adb commands: one removes the audio dialog for good, and the other keeps
visible while audio plays. notifications visible while audio plays.
- **Updates come from GitHub.** Once a day, Gearslip checks this repository's latest
release. When a new version is out, it downloads the APK, checks it against GitHub's
SHA-256, and installs it through Android's own installer. Turn off automatic checks in
Settings.
## Verify your download
Check that your copy of Gearslip is genuine with
[AppVerifier](https://github.com/soupslurpr/AppVerifier): share this block to it, or compare it
with the output of `apksigner verify --print-certs`.
```
app.seb3thehacker.gearslip
4C:C5:6E:BD:D1:3A:54:D2:60:EC:A9:B7:E2:B2:AE:6B:30:E2:F2:25:BF:DA:8F:50:8F:07:09:ED:F0:84:94:8B
```
## Usage notes ## Usage notes
@@ -81,20 +115,37 @@ turn them on, in setup or in Settings.
- **When they go out.** When you open Gearslip, at most once a day, and after each drive. - **When they go out.** When you open Gearslip, at most once a day, and after each drive.
Nothing runs in the background. Nothing runs in the background.
- **What they carry.** The Gearslip version, plus whatever you tick: the Android version - **What they carry.** The Gearslip version and which boxes you ticked, plus whatever
and whether the phone runs GrapheneOS, the phone model, whether a car connected that those boxes cover: the Android version and whether the phone runs GrapheneOS, the phone
day, and each car's head unit, model, year, screen size, and connection result. model, whether a car connected that day, and each car's head unit, model, year, screen
Settings shows the exact note. 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
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 - **What they leave out.** Your name, accounts, location, contacts, messages, and
anything you type or play. anything you type or play.
- **How phones are counted.** A random number counts each phone once. The server keeps - **How phones are counted.** A random number counts each phone once. The server keeps
only a salted hash of it. only a salted hash of it.
- **Where they go.** To a Cloudflare Worker, whose code is in - **Where they go.** To Gearslip's server. Like any website, it sees your IP address when a
[stats-worker/](stats-worker/). Cloudflare sees your IP address in delivering a note, note arrives, and it doesn't store it.
as it would for any website; the Worker doesn't store it.
Turn the notes off, and they stop at once. Turn the notes off, and they stop at once.
## How Gearslip is made
Gearslip is my beloved child. I decide what it does and how it looks, and Claude writes nearly
all of the code. I test every change myself before it ships: in a real car, with both a
GrapheneOS phone and a stock Android phone, and on [LIVI](https://github.com/f-io/LIVI), the
open-source head unit software. I watch the phone's temperature too, so a new feature doesn't
cook it. Then I beg my friends to try it in their cars, so it works in as many makes, models,
and years as possible. If something breaks, that's on me, so please open an issue.
## Contributing
Bug reports from real cars help most, and pull requests are welcome.
[CONTRIBUTING.md](CONTRIBUTING.md) explains what to put in a report, which branch to work
on, and the one-time agreement you sign before your first pull request merges.
## Building ## Building
``` ```
@@ -113,6 +164,17 @@ keystore. Without them, the debug build still works.
[docs/BUILDING_APPS.md](docs/BUILDING_APPS.md) specifies how to write apps that Gearslip [docs/BUILDING_APPS.md](docs/BUILDING_APPS.md) specifies how to write apps that Gearslip
can render: templated apps, media apps, and the Gearslip app protocol. can render: templated apps, media apps, and the Gearslip app protocol.
## How Gearslip talks to cars
Gearslip is an independent program, written to work with car head units. What it knows of
the protocol comes from public open-source projects such as
[aasdk](https://github.com/opencardev/aasdk) and open-android-auto, and it contains none
of Google's Android Auto code.
Gearslip ships without a certificate. On first launch you choose where yours comes from:
download the one the aasdk project publishes, or import a file of your own. The
certificate stays on your phone. Gearslip never uploads, backs up, or logs it.
## Trademarks ## Trademarks
Android, Android Auto, Google Messages, and Waze are trademarks of Google LLC. Spotify is Android, Android Auto, Google Messages, and Waze are trademarks of Google LLC. Spotify is
@@ -121,5 +183,6 @@ any of these companies. All other names belong to their owners.
## License ## License
Gearslip is licensed under AGPL-3.0; see [LICENSE](LICENSE). The author offers a separate Gearslip belongs to Twink Software LLC and is licensed under AGPL-3.0; see [LICENSE](LICENSE).
commercial license for private use outside the AGPL's terms; see [NOTICE.md](NOTICE.md). The LLC also sells a separate commercial license for private use outside the AGPL's terms; see
[NOTICE.md](NOTICE.md).
Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

@@ -1,14 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="432" height="432" viewBox="0 0 108 108">
<defs>
<linearGradient id="trail" x1="73.4" y1="46.0" x2="95.8" y2="44.5" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#8C1F1F" stop-opacity="0"/>
<stop offset="100%" stop-color="#8C1F1F" stop-opacity="0.7"/>
</linearGradient>
</defs>
<rect width="108" height="108" fill="#0c0c0d"/>
<circle cx="54" cy="54" r="52" fill="#131415"/>
<path d="M 73.23,47.15 L 94.77,51.90 L 96.89,37.05 L 73.57,44.78 Z" fill="url(#trail)"/>
<path d="M 54.00,27.00 L 59.99,19.91 Q 61.02,18.69 62.50,19.30 L 72.52,23.45 Q 74.00,24.07 73.87,25.66 L 73.23,33.31 Q 73.09,34.91 73.70,36.39 L 80.39,52.52 Q 81.00,54.00 82.22,55.03 L 88.09,59.99 Q 89.31,61.02 88.70,62.50 L 84.55,72.52 Q 83.93,74.00 82.34,73.87 L 73.09,73.09 Q 73.09,73.09 73.09,73.09 L 73.09,73.09 Q 73.09,73.09 73.09,73.09 L 73.87,82.34 Q 74.00,83.93 72.52,84.55 L 62.50,88.70 Q 61.02,89.31 59.99,88.09 L 54.00,81.00 Q 54.00,81.00 54.00,81.00 L 54.00,81.00 Q 54.00,81.00 54.00,81.00 L 48.01,88.09 Q 46.98,89.31 45.50,88.70 L 35.48,84.55 Q 34.00,83.93 34.13,82.34 L 34.91,73.09 Q 34.91,73.09 34.91,73.09 L 34.91,73.09 Q 34.91,73.09 34.91,73.09 L 25.66,73.87 Q 24.07,74.00 23.45,72.52 L 19.30,62.50 Q 18.69,61.02 19.91,59.99 L 27.00,54.00 Q 27.00,54.00 27.00,54.00 L 27.00,54.00 Q 27.00,54.00 27.00,54.00 L 19.91,48.01 Q 18.69,46.98 19.30,45.50 L 23.45,35.48 Q 24.07,34.00 25.66,34.13 L 34.91,34.91 Q 34.91,34.91 34.91,34.91 L 34.91,34.91 Q 34.91,34.91 34.91,34.91 L 34.13,25.66 Q 34.00,24.07 35.48,23.45 L 45.50,19.30 Q 46.98,18.69 48.01,19.91 L 54.00,27.00 Q 54.00,27.00 54.00,27.00 L 54.00,27.00 Q 54.00,27.00 54.00,27.00 Z" fill="#8B9096"/>
<path d="M 92.78,34.42 L 99.94,37.20 Q 101.43,37.78 101.34,39.37 L 100.67,50.20 Q 100.58,51.80 99.03,52.19 L 91.58,54.08 Q 90.03,54.47 90.13,52.87 L 91.19,35.44 Q 91.29,33.84 92.78,34.42 Z" fill="#8C1F1F"/>
<circle cx="54" cy="54" r="12" fill="#0c0c0d"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 10 KiB

-1
View File
@@ -5,7 +5,6 @@
<stop offset="100%" stop-color="#8C1F1F" stop-opacity="0.7"/> <stop offset="100%" stop-color="#8C1F1F" stop-opacity="0.7"/>
</linearGradient> </linearGradient>
</defs> </defs>
<rect width="108" height="108" fill="#0c0c0d"/>
<circle cx="54" cy="54" r="52" fill="#131415"/> <circle cx="54" cy="54" r="52" fill="#131415"/>
<path d="M 73.23,47.15 L 94.77,51.90 L 96.89,37.05 L 73.57,44.78 Z" fill="url(#trail)"/> <path d="M 73.23,47.15 L 94.77,51.90 L 96.89,37.05 L 73.57,44.78 Z" fill="url(#trail)"/>
<path d="M 54.00,27.00 L 61.02,18.69 L 74.00,24.07 L 73.09,34.91 L 81.00,54.00 L 89.31,61.02 L 83.93,74.00 L 73.09,73.09 L 73.09,73.09 L 74.00,83.93 L 61.02,89.31 L 54.00,81.00 L 54.00,81.00 L 46.98,89.31 L 34.00,83.93 L 34.91,73.09 L 34.91,73.09 L 24.07,74.00 L 18.69,61.02 L 27.00,54.00 L 27.00,54.00 L 18.69,46.98 L 24.07,34.00 L 34.91,34.91 L 34.91,34.91 L 34.00,24.07 L 46.98,18.69 L 54.00,27.00 Z" fill="#8B9096"/> <path d="M 54.00,27.00 L 61.02,18.69 L 74.00,24.07 L 73.09,34.91 L 81.00,54.00 L 89.31,61.02 L 83.93,74.00 L 73.09,73.09 L 73.09,73.09 L 74.00,83.93 L 61.02,89.31 L 54.00,81.00 L 54.00,81.00 L 46.98,89.31 L 34.00,83.93 L 34.91,73.09 L 34.91,73.09 L 24.07,74.00 L 18.69,61.02 L 27.00,54.00 L 27.00,54.00 L 18.69,46.98 L 24.07,34.00 L 34.91,34.91 L 34.91,34.91 L 34.00,24.07 L 46.98,18.69 L 54.00,27.00 Z" fill="#8B9096"/>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 421 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 211 KiB

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 390 KiB

After

Width:  |  Height:  |  Size: 357 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 161 KiB

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 219 KiB

After

Width:  |  Height:  |  Size: 538 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 569 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 87 KiB

+23 -22
View File
@@ -24,6 +24,12 @@ val keystoreProperties = Properties().apply {
).firstOrNull { it.exists() }?.inputStream()?.use { load(it) } ).firstOrNull { it.exists() }?.inputStream()?.use { load(it) }
} }
// Keeps build output out of the module folder: <root>/build/gearslip/... instead of
// <root>/gearslip/build/..., so everything this project produces lands under one
// root-level build/ directory. Set before android {}, which works out the path to R8's default
// rules from it.
layout.buildDirectory.set(rootProject.layout.buildDirectory.dir("gearslip"))
android { android {
namespace = "app.seb3thehacker.gearslip" namespace = "app.seb3thehacker.gearslip"
compileSdk = 37 compileSdk = 37
@@ -32,8 +38,9 @@ android {
applicationId = "app.seb3thehacker.gearslip" applicationId = "app.seb3thehacker.gearslip"
minSdk = 31 minSdk = 31
targetSdk = 37 targetSdk = 37
versionCode = 8 versionCode = 11
versionName = "0.2.00-alpha" // -PgearslipVersionName=0.1.99-test makes a build that thinks it's older, to try the updater.
versionName = project.findProperty("gearslipVersionName")?.toString() ?: "0.3.02-alpha"
manifestPlaceholders["appLabel"] = "Gearslip" manifestPlaceholders["appLabel"] = "Gearslip"
// Where opted-in phones send their daily usage note: the stats-worker/ Worker's address, // Where opted-in phones send their daily usage note: the stats-worker/ Worker's address,
// set as gearslipStatsUrl in gradle.properties. Left empty, nothing is ever sent. // set as gearslipStatsUrl in gradle.properties. Left empty, nothing is ever sent.
@@ -56,14 +63,24 @@ android {
buildTypes { buildTypes {
release { release {
signingConfig = signingConfigs.findByName("release") signingConfig = signingConfigs.findByName("release")
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
} }
// Its own package, so a dev build installs beside the release one instead of over it. // Its own package, so a debug build installs beside the release one instead of over it.
// Signed with the release key when it's here: both builds declare the car template // Signed with the release key when it's here: both builds declare the car template
// permission, and Android refuses a second app declaring it under a different key. // permission, and Android refuses a second app declaring it under a different key.
debug { debug {
applicationIdSuffix = ".dev" applicationIdSuffix = ".dev"
signingConfigs.findByName("release")?.let { signingConfig = it } signingConfigs.findByName("release")?.let { signingConfig = it }
manifestPlaceholders["appLabel"] = "Gearslip Dev" manifestPlaceholders["appLabel"] = "Gearslip Debug"
// 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")
}
} }
} }
@@ -71,32 +88,16 @@ android {
buildConfig = true buildConfig = true
} }
// Keeps build output out of the module folder: <root>/build/gearslip/... instead of
// <root>/gearslip/build/..., so everything this project produces lands under one
// root-level build/ directory.
layout.buildDirectory.set(rootProject.layout.buildDirectory.dir("gearslip"))
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_21 sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21 targetCompatibility = JavaVersion.VERSION_21
} }
defaultConfig { defaultConfig {
// Speech-to-text ships for 64-bit ARM only: every phone Gearslip runs on, and one copy // Speech-to-text (sherpa-onnx, in jniLibs) ships for 64-bit ARM only: every phone
// of the native library instead of four. // Gearslip runs on, and one copy of the native library instead of four.
ndk { abiFilters += "arm64-v8a" } ndk { abiFilters += "arm64-v8a" }
} }
ndkVersion = "27.1.12297006"
externalNativeBuild {
cmake {
path = file("src/main/cpp/CMakeLists.txt")
version = "3.22.1"
}
}
androidResources {
// Model weights barely compress, and stored plainly they stream straight out of the APK.
noCompress += "bin"
}
testOptions { testOptions {
// GearslipLog calls android.util.Log, which is a stub on the local JVM. // GearslipLog calls android.util.Log, which is a stub on the local JVM.
+13
View File
@@ -0,0 +1,13 @@
# R8 shrinks the release build; these keep what it can't see being used.
# Names stay readable, so stack traces in shared logs need no mapping file. Shrinking does
# the real work on size.
-dontobfuscate
# Gearslip hosts other apps' car screens: their templates arrive as Bundles, and the car
# library rebuilds them by class name and reflection. Any model class an app might send has to
# survive, used by Gearslip's code or not.
-keep class androidx.car.app.** { *; }
# Speech-to-text: the native library calls back into these classes by name.
-keep class com.k2fsa.sherpa.onnx.** { *; }
+33 -1
View File
@@ -4,6 +4,7 @@
<!-- The projected WebView loads real pages; without this every load fails silently. --> <!-- The projected WebView loads real pages; without this every load fails silently. -->
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<!-- <!--
Phone mirroring. Android refuses to hand over a MediaProjection unless a foreground Phone mirroring. Android refuses to hand over a MediaProjection unless a foreground
@@ -19,6 +20,25 @@
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<!--
Wireless Android Auto: Bluetooth reaches the paired car's Android Auto service, which
hands over its Wi-Fi details; Gearslip then joins that network to reach the car.
-->
<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"
android:usesPermissionFlags="neverForLocation" />
<!-- Android 17 drops an app's traffic to the car's Wi-Fi without this. -->
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<!-- The dashboard opened from the car clock: today's agenda and current weather. --> <!-- The dashboard opened from the car clock: today's agenda and current weather. -->
<uses-permission android:name="android.permission.READ_CALENDAR" /> <uses-permission android:name="android.permission.READ_CALENDAR" />
@@ -74,8 +94,10 @@
LAUNCHER query below to find, so the setup guide's Android Auto check needs this to see LAUNCHER query below to find, so the setup guide's Android Auto check needs this to see
whether the package is installed at all. --> whether the package is installed at all. -->
<package android:name="com.google.android.projection.gearhead" /> <package android:name="com.google.android.projection.gearhead" />
<!-- Every GrapheneOS install has this; the opt-in usage note uses it to say "GrapheneOS". --> <!-- The opt-in usage note names the OS: GrapheneOS's setup app and /e/OS's app store give
them away (UsageStats.osName). -->
<package android:name="app.grapheneos.setupwizard" /> <package android:name="app.grapheneos.setupwizard" />
<package android:name="foundation.e.apps" />
<intent> <intent>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
@@ -225,5 +247,15 @@
<action android:name="android.telecom.CallScreeningService" /> <action android:name="android.telecom.CallScreeningService" />
</intent-filter> </intent-filter>
</service> </service>
<!-- Told when a car approved for wireless auto-connect comes up over Bluetooth. -->
<service
android:name=".wireless.CarPresenceService"
android:exported="true"
android:permission="android.permission.BIND_COMPANION_DEVICE_SERVICE">
<intent-filter>
<action android:name="android.companion.CompanionDeviceService" />
</intent-filter>
</service>
</application> </application>
</manifest> </manifest>
-36
View File
@@ -1,36 +0,0 @@
# Speech-to-text for voice replies and the assistant: whisper.cpp, pinned to one commit and
# fetched at build time, so nothing outside this folder (cloned-repos included) is needed.
cmake_minimum_required(VERSION 3.22)
project(gearslip_speech CXX C)
set(CMAKE_CXX_STANDARD 17)
# Optimised in debug builds too. Unoptimised, Whisper runs several times slower, and a dev build
# would make voice look far worse than what ships.
foreach(lang C CXX)
set(CMAKE_${lang}_FLAGS_DEBUG "-O3 -DNDEBUG")
endforeach()
include(FetchContent)
FetchContent_Declare(
whisper
URL https://github.com/ggml-org/whisper.cpp/archive/6e4ab854f67f743900934a703d5603419384c961.tar.gz
URL_HASH SHA256=55b585aec5e1bc03e2a5cea61da97c6406fa833996cf7daeed704972273cc039
)
set(BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE)
set(WHISPER_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE)
set(WHISPER_BUILD_TESTS OFF CACHE BOOL "" FORCE)
set(WHISPER_BUILD_SERVER OFF CACHE BOOL "" FORCE)
set(GGML_OPENMP OFF CACHE BOOL "" FORCE)
set(GGML_NATIVE OFF CACHE BOOL "" FORCE)
# ARMv8.2 with half floats and dot products: every phone that runs Android 12 or later in
# practice, and what makes Whisper fast enough to answer in under a second.
set(GGML_CPU_ARM_ARCH "armv8.2-a+dotprod+fp16" CACHE STRING "" FORCE)
FetchContent_MakeAvailable(whisper)
add_library(gearslip_speech SHARED speech_jni.cpp)
target_compile_options(gearslip_speech PRIVATE -O3)
find_library(LOG_LIB log)
target_link_libraries(gearslip_speech whisper android ${LOG_LIB})
target_link_options(gearslip_speech PRIVATE -Wl,--gc-sections -Wl,-z,max-page-size=16384)
-86
View File
@@ -1,86 +0,0 @@
// The bridge between Kotlin's SpeechEngine and whisper.cpp: load a model, turn one recorded
// phrase into text, free the model. Recording and deciding when a phrase ends stay in Kotlin.
#include <jni.h>
#include <android/asset_manager.h>
#include <android/asset_manager_jni.h>
#include <android/log.h>
#include <algorithm>
#include <cmath>
#include <string>
#include "whisper.h"
#define TAG "GearslipSpeech"
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, TAG, __VA_ARGS__)
static whisper_context_params context_params() {
whisper_context_params p = whisper_context_default_params();
p.use_gpu = false;
return p;
}
// Streams a model straight out of the APK, so the bundled one is never copied to storage.
static size_t asset_read(void *ctx, void *output, size_t size) {
int n = AAsset_read(static_cast<AAsset *>(ctx), output, size);
return n > 0 ? static_cast<size_t>(n) : 0;
}
static bool asset_eof(void *ctx) { return AAsset_getRemainingLength64(static_cast<AAsset *>(ctx)) <= 0; }
static void asset_close(void *ctx) { AAsset_close(static_cast<AAsset *>(ctx)); }
extern "C" JNIEXPORT jlong JNICALL
Java_app_seb3thehacker_gearslip_speech_SpeechEngine_nativeInitAsset(JNIEnv *env, jobject, jobject assets, jstring jpath) {
AAssetManager *manager = AAssetManager_fromJava(env, assets);
const char *path = env->GetStringUTFChars(jpath, nullptr);
AAsset *asset = AAssetManager_open(manager, path, AASSET_MODE_STREAMING);
env->ReleaseStringUTFChars(jpath, path);
if (!asset) return 0;
whisper_model_loader loader = {asset, asset_read, asset_eof, asset_close};
return reinterpret_cast<jlong>(whisper_init_with_params(&loader, context_params()));
}
// Bytes, not a jstring: NewStringUTF wants modified UTF-8 and aborts on some of what Whisper can
// write (four-byte characters), so Kotlin decodes the plain UTF-8 instead.
extern "C" JNIEXPORT jbyteArray JNICALL
Java_app_seb3thehacker_gearslip_speech_SpeechEngine_nativeTranscribe(
JNIEnv *env, jobject, jlong handle, jfloatArray jsamples, jstring jlang, jint threads) {
auto *ctx = reinterpret_cast<whisper_context *>(handle);
if (!ctx) return env->NewByteArray(0);
jsize n = env->GetArrayLength(jsamples);
jfloat *samples = env->GetFloatArrayElements(jsamples, nullptr);
const char *lang = env->GetStringUTFChars(jlang, nullptr);
whisper_full_params p = whisper_full_default_params(WHISPER_SAMPLING_GREEDY);
p.n_threads = threads;
p.language = lang;
p.translate = false;
p.no_context = true;
p.no_timestamps = true;
p.single_segment = true;
p.print_progress = false;
p.print_realtime = false;
p.print_special = false;
p.print_timestamps = false;
p.suppress_blank = true;
// The ACFT models are trained for a context cut to the audio's own length (1500 frames is
// 30 s, so 50 per second), which is what makes a three-second phrase fast. A little extra
// keeps the last word from being clipped.
p.audio_ctx = std::min(1500, static_cast<int>(std::ceil(n / 320.0)) + 32);
int rc = whisper_full(ctx, p, samples, n);
env->ReleaseStringUTFChars(jlang, lang);
env->ReleaseFloatArrayElements(jsamples, samples, JNI_ABORT);
if (rc != 0) {
LOGI("whisper_full failed: %d", rc);
return env->NewByteArray(0);
}
std::string text;
for (int i = 0; i < whisper_full_n_segments(ctx); i++) text += whisper_full_get_segment_text(ctx, i);
jbyteArray out = env->NewByteArray(static_cast<jsize>(text.size()));
env->SetByteArrayRegion(out, 0, static_cast<jsize>(text.size()), reinterpret_cast<const jbyte *>(text.data()));
return out;
}
extern "C" JNIEXPORT void JNICALL
Java_app_seb3thehacker_gearslip_speech_SpeechEngine_nativeFree(JNIEnv *, jobject, jlong handle) {
if (handle) whisper_free(reinterpret_cast<whisper_context *>(handle));
}
@@ -9,7 +9,6 @@ object AppSettings {
private const val PREFS = "gearslip_settings" private const val PREFS = "gearslip_settings"
private const val KEY_STARTUP_URL = "startup_url" private const val KEY_STARTUP_URL = "startup_url"
private const val KEY_SEEN_COMPAT_WARNING = "seen_compat_warning"
private const val KEY_SKIPPED_CERT_SETUP = "skipped_cert_setup" private const val KEY_SKIPPED_CERT_SETUP = "skipped_cert_setup"
private const val KEY_SEEN_PERMISSIONS_SETUP = "seen_permissions_setup" private const val KEY_SEEN_PERMISSIONS_SETUP = "seen_permissions_setup"
private const val KEY_GEARSLIP_ENABLED = "gearslip_enabled" private const val KEY_GEARSLIP_ENABLED = "gearslip_enabled"
@@ -32,14 +31,6 @@ object AppSettings {
prefs(context).edit().putString(KEY_STARTUP_URL, value.trim()).apply() prefs(context).edit().putString(KEY_STARTUP_URL, value.trim()).apply()
} }
/** Whether the newer-car compatibility warning has already been shown and dismissed once. */
fun hasSeenCompatWarning(context: Context): Boolean =
prefs(context).getBoolean(KEY_SEEN_COMPAT_WARNING, false)
fun setSeenCompatWarning(context: Context) {
prefs(context).edit().putBoolean(KEY_SEEN_COMPAT_WARNING, true).apply()
}
/** Whether the certificate setup screen has been deliberately skipped. */ /** Whether the certificate setup screen has been deliberately skipped. */
fun hasSkippedCertSetup(context: Context): Boolean = fun hasSkippedCertSetup(context: Context): Boolean =
prefs(context).getBoolean(KEY_SKIPPED_CERT_SETUP, false) prefs(context).getBoolean(KEY_SKIPPED_CERT_SETUP, false)
@@ -10,6 +10,26 @@ object Changelog {
class Release(val versionCode: Int, val versionName: String, val changes: List<String>) class Release(val versionCode: Int, val versionName: String, val changes: List<String>)
val releases = listOf( val releases = listOf(
Release(
9, "0.3.00-alpha",
listOf(
"Wireless Android Auto: pair the phone with the car once, and leave the cable at home.",
"Voice control is built in. Say \"navigate to a gas station\", \"play\" a song or artist, or open an app by name.",
"\"Navigate to\" picks the nearest place and stops at the map app's start screen, so you only tap Start.",
"Gearslip turns speech into text on the phone, with no Google account. Download the voice models once in setup.",
"The car's rotary knob moves between buttons, so cars without a touchscreen work too.",
"Choose how music reaches the car: Bluetooth, or USB with no delay. Gearslip offers to pair with the car the first time.",
"The car speaks each turn from your map app, and shows the directions on its own display where it can.",
"The car shows what's playing, and music apps get their own like, shuffle and repeat buttons.",
"Pick how the car's buttons look. Change it any time in the car's settings.",
"A new destination mid-route replaces the old route, and switching map apps ends the old app's route.",
"The car warns you when the phone's location is off.",
"Gearslip finds new versions and installs them for you.",
"More cars play music over USB, show a sharper picture, and put taps where you press.",
"On GrapheneOS, Gearslip no longer needs exploit protection compatibility mode.",
"The app is half the size it was.",
),
),
Release( Release(
8, "0.2.00-alpha", 8, "0.2.00-alpha",
listOf( listOf(
@@ -0,0 +1,190 @@
package app.seb3thehacker.gearslip
import android.graphics.SurfaceTexture
import android.opengl.EGL14
import android.opengl.EGLConfig
import android.opengl.EGLContext
import android.opengl.EGLDisplay
import android.opengl.EGLExt
import android.opengl.EGLSurface
import android.opengl.GLES11Ext
import android.opengl.GLES20
import android.os.Handler
import android.os.HandlerThread
import android.view.Surface
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
/**
* Stretches a [width] x [height] picture over the whole of an [outWidth] x [outHeight] encoder
* frame.
*
* For a head unit that squashes the frame onto a screen of another shape: Gearslip draws for the
* screen's real shape into [input], this stretches it the other way, and the car's squash brings
* it back. It works on the composited picture, so a map app's own surface is stretched with
* everything else - scaling Gearslip's UI alone would leave the map squashed.
*/
class FrameStretcher(
private val output: Surface,
val width: Int,
val height: Int,
private val outWidth: Int,
private val outHeight: Int,
) {
private val thread = HandlerThread("gearslip-stretch").apply { start() }
private val handler = Handler(thread.looper)
private var display: EGLDisplay = EGL14.EGL_NO_DISPLAY
private var context: EGLContext = EGL14.EGL_NO_CONTEXT
private var eglSurface: EGLSurface = EGL14.EGL_NO_SURFACE
private var program = 0
private var texture: SurfaceTexture? = null
private val transform = FloatArray(16)
/** Where to draw. Valid once [start] returns true. */
lateinit var input: Surface
private set
/** Sets up on its own thread and waits for it; false if the GPU wouldn't play along. */
fun start(): Boolean {
val done = CountDownLatch(1)
var ok = false
handler.post {
ok = runCatching { setUp() }
.onFailure { GearslipLog.e("stretch: could not set up", it) }
.isSuccess
done.countDown()
}
done.await(2, TimeUnit.SECONDS)
if (!ok) release()
return ok
}
fun release() {
handler.post {
runCatching { if (::input.isInitialized) input.release() }
runCatching { texture?.release() }
texture = null
if (display != EGL14.EGL_NO_DISPLAY) {
EGL14.eglMakeCurrent(display, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_CONTEXT)
if (eglSurface != EGL14.EGL_NO_SURFACE) EGL14.eglDestroySurface(display, eglSurface)
if (context != EGL14.EGL_NO_CONTEXT) EGL14.eglDestroyContext(display, context)
EGL14.eglTerminate(display)
}
display = EGL14.EGL_NO_DISPLAY
thread.quitSafely()
}
}
private fun setUp() {
display = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY)
val version = IntArray(2)
check(EGL14.eglInitialize(display, version, 0, version, 1)) { "eglInitialize failed" }
val configs = arrayOfNulls<EGLConfig>(1)
val count = IntArray(1)
val attributes = intArrayOf(
EGL14.EGL_RED_SIZE, 8, EGL14.EGL_GREEN_SIZE, 8, EGL14.EGL_BLUE_SIZE, 8,
EGL14.EGL_RENDERABLE_TYPE, EGL14.EGL_OPENGL_ES2_BIT,
EGL_RECORDABLE_ANDROID, 1,
EGL14.EGL_NONE,
)
check(EGL14.eglChooseConfig(display, attributes, 0, configs, 0, 1, count, 0) && count[0] > 0) {
"no EGL config"
}
context = EGL14.eglCreateContext(
display, configs[0], EGL14.EGL_NO_CONTEXT,
intArrayOf(EGL14.EGL_CONTEXT_CLIENT_VERSION, 2, EGL14.EGL_NONE), 0,
)
eglSurface = EGL14.eglCreateWindowSurface(display, configs[0], output, intArrayOf(EGL14.EGL_NONE), 0)
check(EGL14.eglMakeCurrent(display, eglSurface, eglSurface, context)) { "eglMakeCurrent failed" }
program = link(VERTEX, FRAGMENT)
val ids = IntArray(1)
GLES20.glGenTextures(1, ids, 0)
GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, ids[0])
GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR)
GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR)
GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE)
GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE)
texture = SurfaceTexture(ids[0]).apply {
setDefaultBufferSize(width, height)
setOnFrameAvailableListener({ draw() }, handler)
}
input = Surface(texture)
GearslipLog.i("stretch: drawing ${width}x$height, stretched to fill ${outWidth}x$outHeight")
}
private fun draw() {
val tex = texture ?: return
runCatching {
tex.updateTexImage()
tex.getTransformMatrix(transform)
GLES20.glViewport(0, 0, outWidth, outHeight)
GLES20.glUseProgram(program)
val position = GLES20.glGetAttribLocation(program, "aPosition")
val coord = GLES20.glGetAttribLocation(program, "aCoord")
GLES20.glEnableVertexAttribArray(position)
GLES20.glVertexAttribPointer(position, 2, GLES20.GL_FLOAT, false, 0, QUAD)
GLES20.glEnableVertexAttribArray(coord)
GLES20.glVertexAttribPointer(coord, 2, GLES20.GL_FLOAT, false, 0, COORDS)
GLES20.glUniformMatrix4fv(GLES20.glGetUniformLocation(program, "uTransform"), 1, false, transform, 0)
GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4)
EGLExt.eglPresentationTimeANDROID(display, eglSurface, tex.timestamp)
EGL14.eglSwapBuffers(display, eglSurface)
}.onFailure { GearslipLog.e("stretch: draw failed", it) }
}
private fun link(vertex: String, fragment: String): Int {
fun compile(type: Int, source: String): Int = GLES20.glCreateShader(type).also {
GLES20.glShaderSource(it, source)
GLES20.glCompileShader(it)
val status = IntArray(1)
GLES20.glGetShaderiv(it, GLES20.GL_COMPILE_STATUS, status, 0)
check(status[0] != 0) { "shader: ${GLES20.glGetShaderInfoLog(it)}" }
}
return GLES20.glCreateProgram().also {
GLES20.glAttachShader(it, compile(GLES20.GL_VERTEX_SHADER, vertex))
GLES20.glAttachShader(it, compile(GLES20.GL_FRAGMENT_SHADER, fragment))
GLES20.glLinkProgram(it)
val status = IntArray(1)
GLES20.glGetProgramiv(it, GLES20.GL_LINK_STATUS, status, 0)
check(status[0] != 0) { "program: ${GLES20.glGetProgramInfoLog(it)}" }
}
}
private companion object {
/** EGL_RECORDABLE_ANDROID: the config must be able to feed a video encoder. */
const val EGL_RECORDABLE_ANDROID = 0x3142
val QUAD = floats(-1f, -1f, 1f, -1f, -1f, 1f, 1f, 1f)
val COORDS = floats(0f, 0f, 1f, 0f, 0f, 1f, 1f, 1f)
fun floats(vararg values: Float) = ByteBuffer.allocateDirect(values.size * 4)
.order(ByteOrder.nativeOrder()).asFloatBuffer().apply { put(values); position(0) }
const val VERTEX = """
attribute vec4 aPosition;
attribute vec4 aCoord;
uniform mat4 uTransform;
varying vec2 vCoord;
void main() {
gl_Position = aPosition;
vCoord = (uTransform * aCoord).xy;
}
"""
const val FRAGMENT = """
#extension GL_OES_EGL_image_external : require
precision mediump float;
varying vec2 vCoord;
uniform samplerExternalOES sTexture;
void main() {
gl_FragColor = texture2D(sTexture, vCoord);
}
"""
}
}
@@ -1,20 +1,10 @@
package app.seb3thehacker.gearslip package app.seb3thehacker.gearslip
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.io.IOException
import java.io.OutputStream
/** /** Android Auto framing with plaintext fragmentation before per-frame encryption. */
* Android Auto frame layout, transcribed from aasdk.
*
* FrameHeader::getData (src/Messenger/FrameHeader.cpp:47-55),
* FrameSize::getData (src/Messenger/FrameSize.cpp:49-62) and
* MessageOutStream::compoundFrame (src/Messenger/MessageOutStream.cpp:100-120):
*
* byte 0 channel id
* byte 1 frameType | encryption | messageType
* bytes 2-3 uint16 BE payload length of THIS frame
* bytes 4-7 uint32 BE total message length - present ONLY when frameType == FIRST
* payload uint16 BE message id, then the body
*/
object Frames { object Frames {
const val CHANNEL_CONTROL = 0 const val CHANNEL_CONTROL = 0
@@ -36,8 +26,11 @@ object Frames {
const val MESSAGE_SPECIFIC = 0 const val MESSAGE_SPECIFIC = 0
const val MESSAGE_CONTROL = 1 shl 2 const val MESSAGE_CONTROL = 1 shl 2
/** aasdk splits at this size (MessageOutStream.hpp:62). Nothing the spike sends is close. */ // Android Auto's default fragment budget includes the framing header. TLS adds overhead.
const val MAX_FRAME_PAYLOAD = 0x4000 private const val FRAGMENT_SIZE = 0x3f00
private const val HEADER_SIZE = 4
private const val FIRST_HEADER_SIZE = 8
const val MAX_FRAME_PAYLOAD = FRAGMENT_SIZE - HEADER_SIZE
class Frame( class Frame(
val channel: Int, val channel: Int,
@@ -45,6 +38,7 @@ object Frames {
val encrypted: Boolean, val encrypted: Boolean,
val messageType: Int, val messageType: Int,
val payload: ByteArray, val payload: ByteArray,
val totalSize: Int? = null,
) )
/** /**
@@ -60,6 +54,11 @@ object Frames {
frameType: Int = TYPE_BULK, frameType: Int = TYPE_BULK,
totalSize: Int? = null, totalSize: Int? = null,
): ByteArray { ): ByteArray {
require(channel in 0..255 && payload.size <= 0xffff)
require(frameType in TYPE_MIDDLE..TYPE_BULK)
require(messageType == MESSAGE_SPECIFIC || messageType == MESSAGE_CONTROL)
require((frameType == TYPE_FIRST) == (totalSize != null))
require(totalSize == null || totalSize in 2..MAX_MESSAGE_SIZE)
val flags = frameType or messageType or (if (encrypted) ENCRYPTED else 0) val flags = frameType or messageType or (if (encrypted) ENCRYPTED else 0)
val header = byteArrayOf( val header = byteArrayOf(
channel.toByte(), channel.toByte(),
@@ -67,7 +66,7 @@ object Frames {
((payload.size shr 8) and 0xFF).toByte(), ((payload.size shr 8) and 0xFF).toByte(),
(payload.size and 0xFF).toByte(), (payload.size and 0xFF).toByte(),
) )
if (frameType != TYPE_FIRST || totalSize == null) return header + payload if (totalSize == null) return header + payload
val extended = byteArrayOf( val extended = byteArrayOf(
((totalSize shr 24) and 0xFF).toByte(), ((totalSize shr 24) and 0xFF).toByte(),
((totalSize shr 16) and 0xFF).toByte(), ((totalSize shr 16) and 0xFF).toByte(),
@@ -77,6 +76,43 @@ object Frames {
return header + extended + payload return header + extended + payload
} }
/** Splits plaintext before encryption; FIRST carries the complete plaintext length. */
fun writeMessage(
output: OutputStream,
channel: Int,
encrypted: Boolean,
messageType: Int,
message: ByteArray,
encrypt: (ByteArray) -> ByteArray,
): Int {
require(message.size in 2..MAX_MESSAGE_SIZE)
val fragmented = message.size > FRAGMENT_SIZE - HEADER_SIZE
require(messageType != MESSAGE_CONTROL || !fragmented) { "Channel control messages cannot be fragmented" }
var offset = 0
var frames = 0
while (offset < message.size) {
val first = fragmented && offset == 0
val headerSize = if (first) FIRST_HEADER_SIZE else HEADER_SIZE
val end = minOf(offset + FRAGMENT_SIZE - headerSize, message.size)
val type = when {
!fragmented -> TYPE_BULK
first -> TYPE_FIRST
end == message.size -> TYPE_LAST
else -> TYPE_MIDDLE
}
val chunk = message.copyOfRange(offset, end)
output.write(build(channel, encrypted, if (encrypted) encrypt(chunk) else chunk,
messageType, type, if (type == TYPE_FIRST) message.size else null))
offset = end
frames++
}
return frames
}
// Local resource limits, not protocol constants. No supported media packet needs more.
private const val MAX_MESSAGE_SIZE = 16 * 1024 * 1024
private const val MAX_BUFFERED_SIZE = 32 * 1024 * 1024
/** /**
* Pulls whole frames out of an accumulating buffer. * Pulls whole frames out of an accumulating buffer.
* *
@@ -87,6 +123,8 @@ object Frames {
private var pending = ByteArray(0) private var pending = ByteArray(0)
fun append(data: ByteArray, length: Int) { fun append(data: ByteArray, length: Int) {
require(length in 0..data.size)
if (length > MAX_BUFFERED_SIZE - pending.size) throw IOException("Frame buffer limit exceeded")
val grown = ByteArray(pending.size + length) val grown = ByteArray(pending.size + length)
System.arraycopy(pending, 0, grown, 0, pending.size) System.arraycopy(pending, 0, grown, 0, pending.size)
System.arraycopy(data, 0, grown, pending.size, length) System.arraycopy(data, 0, grown, pending.size, length)
@@ -106,30 +144,53 @@ object Frames {
val total = headerLength + frameSize val total = headerLength + frameSize
if (pending.size < total) return null if (pending.size < total) return null
val messageSize = if (frameType == TYPE_FIRST) {
var size = 0L
for (i in 4..7) size = (size shl 8) or (pending[i].toLong() and 0xff)
if (size !in 2..MAX_MESSAGE_SIZE.toLong()) throw IOException("Invalid fragmented message length: $size")
size.toInt()
} else null
val payload = pending.copyOfRange(headerLength, total) val payload = pending.copyOfRange(headerLength, total)
pending = pending.copyOfRange(total, pending.size) pending = pending.copyOfRange(total, pending.size)
return Frame( return Frame(
channel, frameType, (flags and ENCRYPTED) != 0, flags and MESSAGE_CONTROL, payload, channel, frameType, (flags and ENCRYPTED) != 0, flags and MESSAGE_CONTROL, payload, messageSize,
) )
} }
} }
/** Rejoins FIRST/MIDDLE/LAST runs. Payloads arrive already decrypted. */ /** Rejoins messages on independent channels, validating their plaintext length and flags. */
class Assembler { class Assembler {
private val partial = HashMap<Int, ByteArrayOutputStream>() private class Partial(val first: Frame, val data: ByteArrayOutputStream = ByteArrayOutputStream())
private val partial = HashMap<Int, Partial>()
private var buffered = 0
fun offer(frame: Frame, payload: ByteArray): ByteArray? = when (frame.frameType) { fun offer(frame: Frame, payload: ByteArray): ByteArray? {
TYPE_BULK -> payload if (frame.frameType == TYPE_BULK) {
TYPE_FIRST -> { if (partial.containsKey(frame.channel)) fail("Complete message interrupted fragments")
partial[frame.channel] = ByteArrayOutputStream().apply { write(payload) } return payload
null
} }
TYPE_MIDDLE -> { val message = if (frame.frameType == TYPE_FIRST) {
partial[frame.channel]?.write(payload) if (frame.totalSize == null || partial.containsKey(frame.channel)) fail("Invalid first fragment")
null Partial(frame).also { partial[frame.channel] = it }
} } else partial[frame.channel] ?: fail("Fragment without a first frame")
TYPE_LAST -> partial.remove(frame.channel)?.apply { write(payload) }?.toByteArray() val first = message.first
else -> null if (first.encrypted != frame.encrypted || first.messageType != frame.messageType) fail("Fragment flags changed")
val expected = first.totalSize ?: fail("Missing total size")
if (expected !in 2..MAX_MESSAGE_SIZE || payload.size > expected - message.data.size()) fail("Fragment exceeds message length")
if (payload.size > MAX_BUFFERED_SIZE - buffered) fail("Fragment buffer limit exceeded")
message.data.write(payload)
buffered += payload.size
if (frame.frameType != TYPE_LAST) return null
if (message.data.size() != expected) fail("Truncated fragmented message")
partial.remove(frame.channel)
buffered -= message.data.size()
return message.data.toByteArray()
}
private fun fail(message: String): Nothing {
partial.clear()
buffered = 0
throw IOException(message)
} }
} }
} }
@@ -8,13 +8,17 @@ import android.hardware.usb.UsbAccessory
import android.hardware.usb.UsbManager import android.hardware.usb.UsbManager
import android.media.projection.MediaProjection import android.media.projection.MediaProjection
import android.media.projection.MediaProjectionManager import android.media.projection.MediaProjectionManager
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.os.ParcelFileDescriptor import android.os.ParcelFileDescriptor
import android.provider.Settings
import android.view.MotionEvent import android.view.MotionEvent
import android.view.Surface import android.view.Surface
import android.view.WindowManager import android.view.WindowManager
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
import android.content.res.Configuration import android.content.res.Configuration
@@ -45,6 +49,8 @@ class GearslipActivity : ComponentActivity(), Projection {
private var descriptor: ParcelFileDescriptor? = null private var descriptor: ParcelFileDescriptor? = null
/** The wireless session's socket, when the car came over Wi-Fi instead of USB. */
private var wirelessSocket: java.net.Socket? = null
private var runner: GearslipRunner? = null private var runner: GearslipRunner? = null
private var screenProjector: ScreenProjector? = null private var screenProjector: ScreenProjector? = null
private var worker: Thread? = null private var worker: Thread? = null
@@ -72,7 +78,13 @@ class GearslipActivity : ComponentActivity(), Projection {
private val audioPermission = registerForActivityResult( private val audioPermission = registerForActivityResult(
androidx.activity.result.contract.ActivityResultContracts.RequestPermission(), androidx.activity.result.contract.ActivityResultContracts.RequestPermission(),
) { granted -> ) { granted ->
if (granted) askAudioCapture() else { if (granted) askAudioCapture()
else if (!shouldShowRequestPermissionRationale(android.Manifest.permission.RECORD_AUDIO)) {
// Refused twice: Android now says no without showing anything, so the phone must.
GearslipLog.w("audio: record permission blocked - Android no longer asks")
CarAudio.micBlocked()
micBlockedNotice.value = true
} else {
GearslipLog.w("audio: record permission declined") GearslipLog.w("audio: record permission declined")
CarAudio.denied() CarAudio.denied()
} }
@@ -85,18 +97,37 @@ class GearslipActivity : ComponentActivity(), Projection {
} else { } else {
GearslipLog.w("audio: capture consent declined") GearslipLog.w("audio: capture consent declined")
CarAudio.denied() CarAudio.denied()
if (!CarSettings.audioChoiceAsked) offerBluetooth.value = true
} }
} }
/** Raised when the microphone permission is blocked; see [MicBlockedDialog]. */
private val micBlockedNotice = kotlinx.coroutines.flow.MutableStateFlow(false)
/** Raised once, after the first "no" to the capture dialog; see [AudioChoiceDialog]. */
private val offerBluetooth = kotlinx.coroutines.flow.MutableStateFlow(false)
private val detachReceiver = object : BroadcastReceiver() { private val detachReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) { override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == UsbManager.ACTION_USB_ACCESSORY_DETACHED) { if (intent?.action == UsbManager.ACTION_USB_ACCESSORY_DETACHED) {
GearslipLog.w("accessory detached") GearslipLog.w("accessory detached")
closeAccessory() // A wireless session has no accessory to lose; leave it running.
if (wirelessSocket == null) closeAccessory()
} }
} }
} }
/** 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. */ /** Setting Gearslip as the phone's caller ID and spam app - what lets it decline a ringing call. */
private val callScreeningRole = registerForActivityResult(StartActivityForResult()) { result -> private val callScreeningRole = registerForActivityResult(StartActivityForResult()) { result ->
if (result.resultCode != RESULT_OK) GearslipLog.w("calls: call screening role declined") if (result.resultCode != RESULT_OK) GearslipLog.w("calls: call screening role declined")
@@ -115,6 +146,15 @@ class GearslipActivity : ComponentActivity(), Projection {
GearslipLog.init(this) GearslipLog.init(this)
GearslipLog.installCrashHandler() GearslipLog.installCrashHandler()
GearslipLog.i("Gearslip ready") GearslipLog.i("Gearslip ready")
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) CarSettings.init(this)
AppSettings.applyGearslipEnabled(this) AppSettings.applyGearslipEnabled(this)
// A capture that died without tidying up would leave notifications exposed to every // A capture that died without tidying up would leave notifications exposed to every
@@ -127,7 +167,33 @@ class GearslipActivity : ComponentActivity(), Projection {
setContent { setContent {
GearslipTheme { GearslipTheme {
GearslipApp(onDisconnect = ::closeAccessory, onRequestCallScreening = ::requestCallScreeningRole) GearslipApp(onDisconnect = { closeAccessory() }, onRequestCallScreening = ::requestCallScreeningRole)
val offer by offerBluetooth.collectAsState()
if (offer) {
fun close() {
CarSettings.markAudioChoiceAsked()
offerBluetooth.value = false
}
app.seb3thehacker.gearslip.ui.AudioChoiceDialog(
onUseBluetooth = {
CarSettings.setPipeAudio(false)
close()
},
onKeepAsking = ::close,
)
}
val micNotice by micBlockedNotice.collectAsState()
if (micNotice) {
app.seb3thehacker.gearslip.ui.MicBlockedDialog(
onOpenSettings = {
micBlockedNotice.value = false
startActivity(
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", packageName, null)),
)
},
onDismiss = { micBlockedNotice.value = false },
)
}
} }
} }
@@ -152,6 +218,11 @@ class GearslipActivity : ComponentActivity(), Projection {
super.onStart() super.onStart()
Prefetch.warm(this) Prefetch.warm(this)
UsageStats.onAppOpened(this) UsageStats.onAppOpened(this)
app.seb3thehacker.gearslip.update.UpdateChecker.onAppOpened(this)
// Back from the app's settings with the microphone turned on: try car audio again.
if (checkSelfPermission(android.Manifest.permission.RECORD_AUDIO) == android.content.pm.PackageManager.PERMISSION_GRANTED) {
CarAudio.micAllowed()
}
} }
override fun onNewIntent(intent: Intent) { override fun onNewIntent(intent: Intent) {
@@ -161,6 +232,8 @@ class GearslipActivity : ComponentActivity(), Projection {
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
app.seb3thehacker.gearslip.wireless.WirelessLink.onConnected = null
app.seb3thehacker.gearslip.wireless.WirelessAuto.showApproval = null
runCatching { unregisterReceiver(detachReceiver) } runCatching { unregisterReceiver(detachReceiver) }
PhoneMirror.onStartRequested = null PhoneMirror.onStartRequested = null
PhoneMirror.onTokenReady = null PhoneMirror.onTokenReady = null
@@ -249,6 +322,10 @@ class GearslipActivity : ComponentActivity(), Projection {
} }
private fun handleIntent(intent: Intent?) { private fun handleIntent(intent: Intent?) {
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 if (intent?.action != UsbManager.ACTION_USB_ACCESSORY_ATTACHED) return
// Switched off in Settings: the system shouldn't route the car here at all, but if it // Switched off in Settings: the system shouldn't route the car here at all, but if it
// does, leave the accessory alone. // does, leave the accessory alone.
@@ -266,6 +343,39 @@ class GearslipActivity : ComponentActivity(), Projection {
start(accessory) start(accessory)
} }
/** A known car came up over Bluetooth: connect wirelessly, unless USB or another try is already on it. */
private fun connectToNearbyCar(address: String?) {
if (address == null) return
if (!AppSettings.gearslipEnabled(this)) {
GearslipLog.i("auto-connect: Gearslip is switched off - not connecting")
return
}
if (descriptor != null) {
GearslipLog.i("auto-connect: the car is on USB already - staying on USB")
return
}
// Only a session that's still running counts. One the car ended (a ByeBye, a dropped
// network) leaves its runner behind, since only Gearslip ending a session clears it, and
// that used to make every later auto-connect quietly do nothing.
if (worker?.isAlive == true || app.seb3thehacker.gearslip.wireless.WirelessLink.busy) {
GearslipLog.i("auto-connect: a session is already running")
return
}
if (runner != null) closeAccessory(stopWireless = false)
if (checkSelfPermission(android.Manifest.permission.BLUETOOTH_CONNECT) != android.content.pm.PackageManager.PERMISSION_GRANTED) {
GearslipLog.w("auto-connect: needs the Nearby devices permission")
return
}
val adapter = getSystemService(android.bluetooth.BluetoothManager::class.java)?.adapter
val device = adapter?.let { runCatching { it.getRemoteDevice(address) }.getOrNull() }
if (device == null) {
GearslipLog.w("auto-connect: Bluetooth couldn't hand over the car")
return
}
GearslipLog.i("auto-connect: connecting")
app.seb3thehacker.gearslip.wireless.WirelessLink.start(this, device)
}
private fun start(accessory: UsbAccessory) { private fun start(accessory: UsbAccessory) {
// A fresh ATTACHED intent means the system wants a new session, whatever state the old // A fresh ATTACHED intent means the system wants a new session, whatever state the old
// one is in - closing first guarantees openAccessory() below never fights a descriptor // one is in - closing first guarantees openAccessory() below never fights a descriptor
@@ -296,16 +406,44 @@ class GearslipActivity : ComponentActivity(), Projection {
worker = Thread { runner.run() }.apply { name = "gearslip"; start() } worker = Thread { runner.run() }.apply { name = "gearslip"; start() }
} }
/** A wireless session: the same runner, on the socket [WirelessLink] opened to the car. */
private fun startWireless(socket: java.net.Socket) {
closeAccessory(stopWireless = false)
if (!AppSettings.gearslipEnabled(this)) {
GearslipLog.i("wireless: Gearslip is switched off - not connecting")
runCatching { socket.close() }
return
}
GearslipLog.i("wireless: starting a session over Wi-Fi to ${socket.inetAddress?.hostAddress}:${socket.port}")
wirelessSocket = socket
val runner = GearslipRunner(
input = socket.getInputStream(),
output = socket.getOutputStream(),
identityProvider = { CertProvider.load(this) },
projection = this,
vehicleProfileFor = { info -> VehicleProfiles.find(VehicleProfiles.load(this), info) },
wireless = true,
)
this.runner = runner
worker = Thread { runner.run() }.apply { name = "gearslip"; start() }
}
private fun describe(accessory: UsbAccessory) { private fun describe(accessory: UsbAccessory) {
GearslipLog.i("accessory: manufacturer=${accessory.manufacturer} model=${accessory.model}") GearslipLog.i("accessory: manufacturer=${accessory.manufacturer} model=${accessory.model}")
GearslipLog.i(" version=${accessory.version} description=${accessory.description}") GearslipLog.i(" version=${accessory.version} description=${accessory.description}")
} }
private fun closeAccessory() { private fun closeAccessory() = closeAccessory(stopWireless = true)
/** Ends the session, wired or wireless. [stopWireless] also drops the car's Bluetooth and Wi-Fi. */
private fun closeAccessory(stopWireless: Boolean) {
runner?.stop() runner?.stop()
SessionReport.print() SessionReport.print()
runCatching { descriptor?.close() } runCatching { descriptor?.close() }
descriptor = null descriptor = null
runCatching { wirelessSocket?.close() }
wirelessSocket = null
if (stopWireless) app.seb3thehacker.gearslip.wireless.WirelessLink.stop(this)
runner = null runner = null
} }
@@ -337,6 +475,8 @@ class GearslipActivity : ComponentActivity(), Projection {
} }
override fun onProjectionStopped() { override fun onProjectionStopped() {
// Before the car UI goes, while the media connection is still up.
app.seb3thehacker.gearslip.car.CarServices.pausePhoneMedia()
runOnUiThread { runOnUiThread {
PhoneMirror.requestStop() PhoneMirror.requestStop()
app.seb3thehacker.gearslip.car.CarKeys.focusSink = null app.seb3thehacker.gearslip.car.CarKeys.focusSink = null
@@ -52,6 +52,46 @@ object GearslipLog {
file = File(dir, "gearslip-$name.log") file = File(dir, "gearslip-$name.log")
i("log file: ${file?.absolutePath}") i("log file: ${file?.absolutePath}")
SessionReport.init(context) SessionReport.init(context)
// One file per launch adds up; the newest are the ones anyone asks for.
io.execute { logFiles(dir).drop(KEEP_FILES).forEach { it.delete() } }
}
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() } fun snapshot(): List<String> = synchronized(live) { live.toList() }
@@ -2,16 +2,29 @@ package app.seb3thehacker.gearslip
import android.os.Build import android.os.Build
import app.seb3thehacker.gearslip.audio.AudioLink import app.seb3thehacker.gearslip.audio.AudioLink
import app.seb3thehacker.gearslip.audio.CarAudio
import app.seb3thehacker.gearslip.audio.CarMic import app.seb3thehacker.gearslip.audio.CarMic
import app.seb3thehacker.gearslip.media.CarMediaStatus
import app.seb3thehacker.gearslip.car.BluetoothAudioCheck
import app.seb3thehacker.gearslip.car.LocationCheck
import app.seb3thehacker.gearslip.car.CarBluetooth
import app.seb3thehacker.gearslip.car.ClusterNav
import app.seb3thehacker.gearslip.car.DirectionsVoice
import app.seb3thehacker.gearslip.car.CarAssistant import app.seb3thehacker.gearslip.car.CarAssistant
import app.seb3thehacker.gearslip.car.CarEnvironment import app.seb3thehacker.gearslip.car.CarEnvironment
import app.seb3thehacker.gearslip.car.CarKeys import app.seb3thehacker.gearslip.car.CarKeys
import app.seb3thehacker.gearslip.car.ScreenFit
import app.seb3thehacker.gearslip.car.CarSensors import app.seb3thehacker.gearslip.car.CarSensors
import app.seb3thehacker.gearslip.car.CarServices import app.seb3thehacker.gearslip.car.CarServices
import app.seb3thehacker.gearslip.car.CarSettings import app.seb3thehacker.gearslip.car.CarSettings
import java.io.IOException
import app.seb3thehacker.gearslip.protocol.InputProtocol
import java.io.InputStream import java.io.InputStream
import java.io.OutputStream import java.io.OutputStream
import java.util.concurrent.Executors
import java.util.concurrent.FutureTask 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: * Drives the phone side of the control-channel handshake far enough to answer one question:
@@ -33,6 +46,8 @@ class GearslipRunner(
private val identityProvider: () -> CertProvider.Identity, private val identityProvider: () -> CertProvider.Identity,
private val projection: Projection? = null, private val projection: Projection? = null,
private val vehicleProfileFor: (ServiceDiscovery.HeadUnitInfo) -> VehicleProfile? = { null }, private val vehicleProfileFor: (ServiceDiscovery.HeadUnitInfo) -> VehicleProfile? = { null },
/** Over Wi-Fi rather than USB: the video is sent smaller so the link keeps up. */
private val wireless: Boolean = false,
) { ) {
private val log = GearslipLog.tagged("PROTO") private val log = GearslipLog.tagged("PROTO")
@@ -43,10 +58,16 @@ class GearslipRunner(
private lateinit var identityTask: FutureTask<PhoneTls> private lateinit var identityTask: FutureTask<PhoneTls>
/** Sends every message, in order, so no other thread ever blocks on the USB link. See [send]. */ /** Sends every message, in order, so no other thread ever blocks on the USB link. See [send]. */
private val writer = java.util.concurrent.Executors.newSingleThreadExecutor { Thread(it, "gearslip-writer") } private val writer = java.util.concurrent.Executors.newSingleThreadExecutor {
// Sound goes out through here too, so it gets audio's priority: late writes are skips.
Thread({ android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_AUDIO); it.run() }, "gearslip-writer")
}
/** Waits for the certificate only when TLS is first needed; see [run]. */ /** Waits for the certificate only when TLS is first needed; see [run]. */
private val tls: PhoneTls by lazy { identityTask.get() } 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 @Volatile private var state = State.WAIT_VERSION
@Volatile private var lastProgress = System.currentTimeMillis() @Volatile private var lastProgress = System.currentTimeMillis()
@@ -58,6 +79,12 @@ class GearslipRunner(
private var vehicleProfile: VehicleProfile? = null private var vehicleProfile: VehicleProfile? = null
@Volatile private var videoStarted = false @Volatile private var videoStarted = false
private var videoSource: VideoSource? = null 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 @Volatile private var inFlight = 0
/** Guards [inFlight]: the encoder thread waits on it for the head unit's acks. */ /** Guards [inFlight]: the encoder thread waits on it for the head unit's acks. */
private val ackLock = Object() private val ackLock = Object()
@@ -68,8 +95,12 @@ class GearslipRunner(
@Volatile private var inputChannelId: Int = -1 @Volatile private var inputChannelId: Int = -1
@Volatile private var inputKeycodes: List<Int> = emptyList() @Volatile private var inputKeycodes: List<Int> = emptyList()
@Volatile private var sensorChannelId: Int = -1 @Volatile private var sensorChannelId: Int = -1
@Volatile private var mediaStatus: CarMediaStatus? = null
@Volatile private var clusterNav: ClusterNav? = null
private var sensorTypes: List<Int> = emptyList() private var sensorTypes: List<Int> = emptyList()
@Volatile private var audioLink: AudioLink? = null @Volatile private var audioLink: AudioLink? = null
@Volatile private var guidanceLink: AudioLink? = null
@Volatile private var vendorChannelId = -1
private var audioMessagesSeen = 0 private var audioMessagesSeen = 0
private var touchWidth = 0 private var touchWidth = 0
private var touchHeight = 0 private var touchHeight = 0
@@ -82,7 +113,9 @@ class GearslipRunner(
@Volatile private var lastKeyframeSentAt = 0L @Volatile private var lastKeyframeSentAt = 0L
fun run() { fun run() {
SessionReport.begin() // This thread reads the car's acks; audio waits on them, so it can't be left at the back.
android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_AUDIO)
SessionReport.begin(wireless)
log.i("--- session starting ---") log.i("--- session starting ---")
SessionStatus.connecting("Connected to the head unit") SessionStatus.connecting("Connected to the head unit")
log.i("device=${Build.MANUFACTURER} ${Build.MODEL} android=${Build.VERSION.RELEASE} (sdk ${Build.VERSION.SDK_INT})") log.i("device=${Build.MANUFACTURER} ${Build.MODEL} android=${Build.VERSION.RELEASE} (sdk ${Build.VERSION.SDK_INT})")
@@ -107,6 +140,7 @@ class GearslipRunner(
try { try {
while (running) { while (running) {
val read = input.read(chunk) val read = input.read(chunk)
if (stopped) return
if (read < 0) { if (read < 0) {
log.w("head unit closed the connection (EOF) while in state $state") log.w("head unit closed the connection (EOF) while in state $state")
reportDisconnect() reportDisconnect()
@@ -120,16 +154,21 @@ class GearslipRunner(
} }
} }
} catch (t: Throwable) { } catch (t: Throwable) {
if (stopped) return
log.e("transport failed in state $state", t) log.e("transport failed in state $state", t)
SessionReport.fail(SessionReport.Category.USB, "transport failed", t, state.name) SessionReport.fail(SessionReport.Category.USB, "transport failed", t, state.name)
reportDisconnect() reportDisconnect()
} finally { } finally {
running = false running = false
writer.shutdown()
if (drainWriterOnExit) writer.awaitTermination(2, TimeUnit.SECONDS)
writer.shutdownNow()
log.flush() log.flush()
} }
} }
fun stop() { fun stop() {
stopped = true
running = false running = false
writer.shutdownNow() writer.shutdownNow()
CarFocus.request = null CarFocus.request = null
@@ -137,10 +176,22 @@ class GearslipRunner(
synchronized(ackLock) { ackLock.notifyAll() } // free an encoder waiting on an ack synchronized(ackLock) { ackLock.notifyAll() } // free an encoder waiting on an ack
videoSource?.stop() videoSource?.stop()
videoSource = null videoSource = null
stretcher?.release()
stretcher = null
CarAudio.openSink = null
audioLink?.close() audioLink?.close()
audioLink = null audioLink = null
guidanceLink?.close()
guidanceLink = null
CarSensors.clear() CarSensors.clear()
CarMic.current = null CarMic.current = null
CarBluetooth.onCarDisconnected()
BluetoothAudioCheck.onCarDisconnected()
LocationCheck.onCarDisconnected()
mediaStatus?.stop()
mediaStatus = null
clusterNav?.stop()
clusterNav = null
lastSensorNight = null lastSensorNight = null
CarEnvironment.setSensorNight(null) // back to the clock until a car says otherwise CarEnvironment.setSensorNight(null) // back to the clock until a car says otherwise
projection?.onProjectionStopped() projection?.onProjectionStopped()
@@ -148,6 +199,8 @@ class GearslipRunner(
} }
private fun handleFrame(frame: Frames.Frame) { private fun handleFrame(frame: Frames.Frame) {
// A rejected handshake cannot consume later messages as if authentication succeeded.
if (authenticationFailed || stopped || !running) return
val payload = if (frame.encrypted) { val payload = if (frame.encrypted) {
if (!tls.handshakeComplete) { if (!tls.handshakeComplete) {
log.w("encrypted frame arrived before the handshake finished - ignoring") log.w("encrypted frame arrived before the handshake finished - ignoring")
@@ -158,7 +211,10 @@ class GearslipRunner(
frame.payload 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) { if (message.size < 2) {
log.w("runt message on channel ${frame.channel}") log.w("runt message on channel ${frame.channel}")
return return
@@ -170,12 +226,23 @@ class GearslipRunner(
lastProgress = System.currentTimeMillis() lastProgress = System.currentTimeMillis()
if (frame.channel != Frames.CHANNEL_CONTROL) { if (frame.channel != Frames.CHANNEL_CONTROL) {
if (state != State.DONE || !frame.encrypted) return
if (frame.channel == videoChannelId) { if (frame.channel == videoChannelId) {
onVideoMessage(messageId, body) onVideoMessage(messageId, body)
} else if (frame.channel == inputChannelId) { } else if (frame.channel == inputChannelId) {
onInputMessage(messageId, body) onInputMessage(messageId, body)
} else if (frame.channel == sensorChannelId) { } else if (frame.channel == sensorChannelId) {
onSensorMessage(messageId, body) onSensorMessage(messageId, body)
} else if (frame.channel == clusterNav?.channelId) {
clusterNav?.onMessage(messageId, body)
} else if (frame.channel == mediaStatus?.channelId) {
mediaStatus?.onMessage(messageId, body)
} else if (frame.channel == guidanceLink?.channelId) {
guidanceLink?.onMessage(messageId, body)
} else if (frame.channel == CarBluetooth.channelId) {
CarBluetooth.onMessage(messageId, body)
} else if (frame.channel == vendorChannelId) {
onVendorMessage(messageId, body)
} else if (frame.channel == CarMic.current?.channelId) { } else if (frame.channel == CarMic.current?.channelId) {
CarMic.current?.onMessage(messageId, body) CarMic.current?.onMessage(messageId, body)
} else if (frame.channel == audioLink?.channelId) { } else if (frame.channel == audioLink?.channelId) {
@@ -195,6 +262,9 @@ class GearslipRunner(
} }
return 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) { when (messageId) {
MSG_VERSION_REQUEST -> onVersionRequest(body) MSG_VERSION_REQUEST -> onVersionRequest(body)
@@ -203,11 +273,25 @@ class GearslipRunner(
MSG_SERVICE_DISCOVERY_RESPONSE -> onServiceDiscoveryResponse(body) MSG_SERVICE_DISCOVERY_RESPONSE -> onServiceDiscoveryResponse(body)
MSG_PING_REQUEST -> onPingRequest(body) MSG_PING_REQUEST -> onPingRequest(body)
MSG_BYEBYE_REQUEST -> onByeByeRequest(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") 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 * 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 * we answer, so the point here is the reason: an Audi MIB2+ sent this about a second after
@@ -221,16 +305,23 @@ class GearslipRunner(
3 -> "NOT_SUPPORTED" 3 -> "NOT_SUPPORTED"
4 -> "NOT_CURRENTLY_SUPPORTED" 4 -> "NOT_CURRENTLY_SUPPORTED"
5 -> "PROBE_SUPPORTED" 5 -> "PROBE_SUPPORTED"
6 -> "WIRELESS_PROJECTION_DISABLED"
7 -> "POWER_DOWN"
8 -> "USER_PROFILE_SWITCH"
else -> "unknown" else -> "unknown"
} }
log.w("<- ByeByeRequest: reason=$reason ($name) in state $state") log.w("<- ByeByeRequest: reason=$reason ($name) in state $state")
log.hex(" byebye raw", body) log.hex(" byebye raw", body)
SessionReport.fail(SessionReport.Category.BYEBYE, "ByeByeRequest reason=$reason ($name)", state.name) 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") log.i("-> ByeByeResponse")
drainWriterOnExit = true
running = false
SessionStatus.disconnected()
} }
private fun onVersionRequest(body: ByteArray) { private fun onVersionRequest(body: ByteArray) {
if (state != State.WAIT_VERSION) return
if (body.size < 4) { if (body.size < 4) {
log.w("VersionRequest too short (${body.size} bytes)") log.w("VersionRequest too short (${body.size} bytes)")
return return
@@ -240,47 +331,43 @@ class GearslipRunner(
log.i("<- VersionRequest: head unit speaks $major.$minor") log.i("<- VersionRequest: head unit speaks $major.$minor")
SessionReport.protocolVersion(major, minor) SessionReport.protocolVersion(major, minor)
// PROTOCOL-DOWNGRADE EXPERIMENT. The 2018 Uconnect speaks 1.3 and ACCEPTS the JVC cert; val response = ProtocolVersion.respond(major, minor)
// the 2025 unit speaks 4.3 and rejects it at AuthComplete (-3). If the 4.3 identity check send(MSG_VERSION_RESPONSE, response.bytes, encrypted = false)
// is tied to the *negotiated* protocol version rather than baked into the firmware, log.i("-> VersionResponse: ${response.major}.${response.minor}, supported=${response.supported}")
// claiming an older version makes the newer unit skip the check entirely. CLAIM_VERSION if (!response.supported) {
// = null restores the previous echo-the-head-unit behaviour. authenticationFailed = true
val claim = CLAIM_VERSION ?: (major to minor) drainWriterOnExit = true
val response = byteArrayOf( running = false
((claim.first shr 8) and 0xFF).toByte(), (claim.first and 0xFF).toByte(), SessionStatus.failed("Unsupported protocol", "The head unit requested protocol $major.$minor.")
((claim.second shr 8) and 0xFF).toByte(), (claim.second and 0xFF).toByte(), return
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) " [FORCED; head unit offered $major.$minor]" else "",
)
state = State.TLS_HANDSHAKE state = State.TLS_HANDSHAKE
SessionStatus.connecting("Securing the link") SessionStatus.connecting("Securing the link")
log.i("awaiting ClientHello - the head unit is the TLS client, we are the server") log.i("awaiting ClientHello - the head unit is the TLS client, we are the server")
} }
private fun onHandshake(body: ByteArray) { private fun onHandshake(body: ByteArray) {
if (state != State.TLS_HANDSHAKE && state != State.WAIT_AUTH) return
log.i("<- EncapsulatedSSL (${body.size} bytes)") log.i("<- EncapsulatedSSL (${body.size} bytes)")
val replies = try { val replies = try {
tls.pumpHandshake(body) val preparedTls = tls
if (stopped) return
preparedTls.pumpHandshake(body)
} catch (t: Throwable) { } catch (t: Throwable) {
if (stopped) return
log.e("TLS handshake threw", t) log.e("TLS handshake threw", t)
SessionReport.fail(SessionReport.Category.TLS, "handshake threw", t, state.name) 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( SessionStatus.failed(
"Head unit rejected the certificate", "Secure connection failed",
"The head unit aborted the secure handshake. It validates the phone's certificate.", "The secure handshake could not be completed. Check the session log for details.",
) )
state = State.DONE state = State.DONE
authenticationFailed = true
running = false
SessionReport.print() SessionReport.print()
return return
} }
if (stopped) return
replies.forEach { replies.forEach {
send(MSG_ENCAPSULATED_SSL, it, encrypted = false) send(MSG_ENCAPSULATED_SSL, it, encrypted = false)
log.i("-> EncapsulatedSSL (${it.size} bytes)") log.i("-> EncapsulatedSSL (${it.size} bytes)")
@@ -293,6 +380,7 @@ class GearslipRunner(
private fun onAuthComplete(body: ByteArray) { private fun onAuthComplete(body: ByteArray) {
val status = Protobuf.readInt32Field(body, 1) val status = Protobuf.readInt32Field(body, 1)
if (state != State.WAIT_AUTH && !(state == State.TLS_HANDSHAKE && status != null && status != 0)) return
log.i("<- AuthComplete: status=$status") log.i("<- AuthComplete: status=$status")
status?.let { SessionReport.auth(it) } status?.let { SessionReport.auth(it) }
@@ -303,22 +391,23 @@ class GearslipRunner(
SessionStatus.connecting("Certificate accepted - discovering services") SessionStatus.connecting("Certificate accepted - discovering services")
sendServiceDiscoveryRequest() sendServiceDiscoveryRequest()
} }
-2 -> { -2, -23, -24 -> {
SessionReport.fail(SessionReport.Category.CERTIFICATE, "AuthComplete status -2 (certificate error)", state.name) val reason = when (status) {
log.verdict( -23 -> "The phone's certificate is not valid yet."
"NOT VIABLE (STATUS_CERTIFICATE_ERROR)", -24 -> "The phone's certificate has expired."
"The head unit explicitly rejected the phone's self-signed certificate. " + else -> "The head unit refused the phone's certificate."
"This is a definitive no - it validates the phone against a trust chain " + }
"we cannot issue from.", SessionReport.fail(SessionReport.Category.CERTIFICATE, "AuthComplete status $status", state.name, "auth $status")
) log.w("certificate rejected: $reason")
SessionStatus.failed( SessionStatus.failed(
"Head unit rejected the certificate", "Head unit rejected the certificate",
"The head unit refused the phone's certificate (certificate error).", reason,
) )
state = State.DONE state = State.DONE
authenticationFailed = true
} }
-3 -> { -3 -> {
SessionReport.fail(SessionReport.Category.AUTH, "AuthComplete status -3 (authentication failure)", state.name) SessionReport.fail(SessionReport.Category.AUTH, "AuthComplete status -3 (authentication failure)", state.name, "auth -3")
log.verdict( log.verdict(
"NOT VIABLE (STATUS_AUTHENTICATION_FAILURE)", "NOT VIABLE (STATUS_AUTHENTICATION_FAILURE)",
"Authentication rejected. Broader than a pure certificate error, but the " + "Authentication rejected. Broader than a pure certificate error, but the " +
@@ -329,9 +418,10 @@ class GearslipRunner(
"Authentication failed. The head unit does not trust the certificate in use.", "Authentication failed. The head unit does not trust the certificate in use.",
) )
state = State.DONE state = State.DONE
authenticationFailed = true
} }
else -> { else -> {
SessionReport.fail(SessionReport.Category.AUTH, "AuthComplete status $status (unexpected)", state.name) SessionReport.fail(SessionReport.Category.AUTH, "AuthComplete status $status (unexpected)", state.name, "auth $status")
log.verdict( log.verdict(
"INCONCLUSIVE (AuthComplete status=$status)", "INCONCLUSIVE (AuthComplete status=$status)",
"Unexpected status. Check MessageStatus.proto in aasdk for the meaning " + "Unexpected status. Check MessageStatus.proto in aasdk for the meaning " +
@@ -339,8 +429,10 @@ class GearslipRunner(
) )
SessionStatus.failed("Unexpected response", "The head unit answered authentication with status $status.") SessionStatus.failed("Unexpected response", "The head unit answered authentication with status $status.")
state = State.DONE state = State.DONE
authenticationFailed = true
} }
} }
if (authenticationFailed) running = false
} }
private fun sendServiceDiscoveryRequest() { private fun sendServiceDiscoveryRequest() {
@@ -351,6 +443,7 @@ class GearslipRunner(
} }
private fun onServiceDiscoveryResponse(body: ByteArray) { private fun onServiceDiscoveryResponse(body: ByteArray) {
if (state != State.WAIT_SDR) return
log.i("<- ServiceDiscoveryResponse (${body.size} bytes, decrypted successfully)") log.i("<- ServiceDiscoveryResponse (${body.size} bytes, decrypted successfully)")
// Field 5 is the car's serial number, and the Bluetooth service carries the car's // 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. // Bluetooth address. Either one identifies the car, and logs get shared, so both stay out.
@@ -399,7 +492,7 @@ class GearslipRunner(
val video = ServiceDiscovery.findVideoService(serviceDiscoveryResponse) val video = ServiceDiscovery.findVideoService(serviceDiscoveryResponse)
if (video == null) { if (video == null) {
log.w("no video service in the discovery response - cannot start Phase A") log.w("no video service in the discovery response - cannot start Phase A")
SessionReport.fail(SessionReport.Category.VIDEO, "head unit advertised no video service", state.name) SessionReport.fail(SessionReport.Category.VIDEO, "head unit advertised no video service", state.name, "no video service")
return return
} }
videoChannelId = video.serviceId videoChannelId = video.serviceId
@@ -415,8 +508,13 @@ class GearslipRunner(
openChannel(video.serviceId) openChannel(video.serviceId)
startAudioChannel(serviceDiscoveryResponse) startAudioChannel(serviceDiscoveryResponse)
startGuidanceChannel(serviceDiscoveryResponse)
startSensorChannel(serviceDiscoveryResponse) startSensorChannel(serviceDiscoveryResponse)
startMicChannel(serviceDiscoveryResponse) startMicChannel(serviceDiscoveryResponse)
startMediaStatusChannel(serviceDiscoveryResponse)
startClusterNavChannel(serviceDiscoveryResponse)
startBluetooth(serviceDiscoveryResponse)
startVendorChannel(serviceDiscoveryResponse)
val input = ServiceDiscovery.findInputService(serviceDiscoveryResponse, video.displayId) val input = ServiceDiscovery.findInputService(serviceDiscoveryResponse, video.displayId)
if (input == null) { if (input == null) {
@@ -434,7 +532,7 @@ class GearslipRunner(
} }
} }
/** Media audio rides its own sink; guidance, system and telephony sinks are left closed. */ /** Media audio rides its own sink; spoken directions take the guidance sink, and system and telephony stay closed. */
private fun startAudioChannel(serviceDiscoveryResponse: ByteArray) { private fun startAudioChannel(serviceDiscoveryResponse: ByteArray) {
val sinks = ServiceDiscovery.findAudioServices(serviceDiscoveryResponse) val sinks = ServiceDiscovery.findAudioServices(serviceDiscoveryResponse)
sinks.forEach { sink -> sinks.forEach { sink ->
@@ -451,7 +549,7 @@ class GearslipRunner(
return return
} }
if (media.codecType != ServiceDiscovery.CODEC_PCM) { if (media.codecType != ServiceDiscovery.CODEC_PCM) {
log.w("the car offers no PCM media sink, only ${media.codecName}; it may play our PCM as silence") log.w("the car offers no PCM media sink, only ${media.codecName}; music goes as AAC")
} }
val first = media.configs.firstOrNull() val first = media.configs.firstOrNull()
SessionReport.audio( SessionReport.audio(
@@ -462,8 +560,42 @@ class GearslipRunner(
media, media,
sendOnChannel = { id, body -> send(id, body, encrypted = true, channel = media.serviceId) }, sendOnChannel = { id, body -> send(id, body, encrypted = true, channel = media.serviceId) },
sendControl = { id, body -> send(id, body, encrypted = true) }, sendControl = { id, body -> send(id, body, encrypted = true) },
// The driver picked the radio or another source on the car: pause the phone's music
// as Android Auto does, instead of letting it play on to no one.
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. */
private fun startGuidanceChannel(serviceDiscoveryResponse: ByteArray) {
val guidance = ServiceDiscovery.findAudioServices(serviceDiscoveryResponse)
.filter { it.streamType == ServiceDiscovery.STREAM_GUIDANCE }
.firstOrNull { it.codecType == ServiceDiscovery.CODEC_PCM } ?: run {
log.i("no PCM directions sink - turns won't be spoken")
return
}
guidanceLink = AudioLink(
guidance,
sendOnChannel = { id, body -> send(id, body, encrypted = true, channel = guidance.serviceId) },
sendControl = { id, body -> send(id, body, encrypted = true) },
tag = "guidance",
sessionId = GUIDANCE_SESSION_ID,
asksFocus = false,
onReady = { DirectionsVoice.attach(it) { audioLink?.streaming == true } },
onClose = { DirectionsVoice.detach(it) },
)
openChannel(guidance.serviceId)
} }
/** The car's microphone, for voice: opened now, recording only while something listens. */ /** The car's microphone, for voice: opened now, recording only while something listens. */
@@ -474,10 +606,78 @@ class GearslipRunner(
} }
val config = mic.configs.first() val config = mic.configs.first()
log.i("car microphone: channel=${mic.serviceId} ${mic.codecName} ${config.sampleRate}Hz/${config.bits}bit/x${config.channels}") log.i("car microphone: channel=${mic.serviceId} ${mic.codecName} ${config.sampleRate}Hz/${config.bits}bit/x${config.channels}")
CarMic.current = CarMic(mic) { id, body -> send(id, body, encrypted = true, channel = mic.serviceId) } CarMic.current = CarMic(
mic,
sendOnChannel = { id, body -> send(id, body, encrypted = true, channel = mic.serviceId) },
// VoiceSessionNotification { status = 1 START, 2 END }, on the control channel.
voiceSession = { start ->
send(MSG_VOICE_SESSION_NOTIFICATION, Protobuf.varintField(1, if (start) 1L else 2L), encrypted = true)
},
)
openChannel(mic.serviceId) openChannel(mic.serviceId)
} }
/** What's playing, for the car's own screens: the cluster, the media source page. */
private fun startMediaStatusChannel(serviceDiscoveryResponse: ByteArray) {
val channel = ServiceDiscovery.findMediaStatusChannel(serviceDiscoveryResponse) ?: run {
log.i("no media status channel - the car's own screens won't show what's playing")
return
}
log.i("media status channel: $channel")
mediaStatus = CarMediaStatus(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
* session about 25 s in, and of the channels it offers, this is the one a Dacia that stays
* connected doesn't have. What GM sends on it is private, so Gearslip only opens it and logs
* what arrives, by message id and size.
*/
private fun startVendorChannel(serviceDiscoveryResponse: ByteArray) {
val vendors = ServiceDiscovery.findVendorServices(serviceDiscoveryResponse)
vendors.forEach { log.i("vendor channel ${it.serviceId}: ${it.name}") }
val gm = vendors.firstOrNull { it.name.startsWith("com.gm.") } ?: return
vendorChannelId = gm.serviceId
log.i("-> ChannelOpenRequest on GM's channel ${gm.serviceId}")
openChannel(gm.serviceId)
}
private fun onVendorMessage(messageId: Int, body: ByteArray) {
if (messageId == MSG_CHANNEL_OPEN_RESPONSE) {
log.i("<- GM channel open: status=${Protobuf.readInt32Field(body, 1) ?: 0}")
} else {
log.i("<- GM channel message id=$messageId (${body.size} bytes)")
}
}
private fun startBluetooth(serviceDiscoveryResponse: ByteArray) {
val bluetooth = ServiceDiscovery.findBluetoothService(serviceDiscoveryResponse) ?: run {
log.i("no Bluetooth service - the car can't be paired from here")
return
}
CarBluetooth.onCarConnected(
bluetooth,
open = { openChannel(bluetooth.serviceId) },
send = { id, body -> send(id, body, encrypted = true, channel = bluetooth.serviceId) },
)
}
/** Turn-by-turn for the instrument cluster: the next turn, its road, and how far. */
private fun startClusterNavChannel(serviceDiscoveryResponse: ByteArray) {
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, navigation.minimumIntervalMs.toLong()) { id, body ->
send(id, body, encrypted = true, channel = channel)
}
openChannel(channel)
}
/** /**
* Every sensor type the head unit advertised, subscribed to at once - there is no screen * Every sensor type the head unit advertised, subscribed to at once - there is no screen
* to pick and choose yet, this is meant to show everything reachable. [CarSensors] answers * to pick and choose yet, this is meant to show everything reachable. [CarSensors] answers
@@ -530,7 +730,7 @@ class GearslipRunner(
*/ */
private fun onNightSensor(body: ByteArray) { private fun onNightSensor(body: ByteArray) {
val night = Wire.bytes(Wire.fields(body), SENSOR_NIGHT) ?: return 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) { if (isNight != lastSensorNight) {
lastSensorNight = isNight lastSensorNight = isNight
log.i("car says it's ${if (isNight) "night" else "day"}") log.i("car says it's ${if (isNight) "night" else "day"}")
@@ -599,8 +799,8 @@ class GearslipRunner(
* right-hand third of the screen away entirely - taps on a right-edge button landed on * right-hand third of the screen away entirely - taps on a right-edge button landed on
* the middle one. The head unit maps its own panel onto our video before reporting. * the middle one. The head unit maps its own panel onto our video before reporting.
* *
* So: inject as-is, clamped to the video bounds. The advertised touchscreen size is kept * So: inject them unscaled, shifted past the cropped margin (see below) and clamped to the
* only for diagnostics. * video. The advertised touchscreen size is kept only for diagnostics.
*/ */
private fun onInputMessage(messageId: Int, body: ByteArray) { private fun onInputMessage(messageId: Int, body: ByteArray) {
when (messageId) { when (messageId) {
@@ -613,52 +813,60 @@ class GearslipRunner(
MSG_KEY_BINDING_RESPONSE -> MSG_KEY_BINDING_RESPONSE ->
log.i("<- KeyBindingResponse: status=${Protobuf.readInt32Field(body, 1)}") log.i("<- KeyBindingResponse: status=${Protobuf.readInt32Field(body, 1)}")
MSG_INPUT_REPORT -> { MSG_INPUT_REPORT -> onInputReport(body)
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 size = videoConfigs.getOrNull(selectedConfigIndex ?: 0)?.pixelSize()
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, size?.first), clamp(rawY, 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)
}
else -> log.i("<- unhandled input message id=$messageId (${body.size} bytes)") 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 * 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 * until the phone names the keys it wants. What the unit offered is asked for; a unit that
@@ -795,17 +1003,52 @@ class GearslipRunner(
/** /**
* The offered config that best fills the car's screen: the smallest whose visible width * The offered config that best fills the car's screen: the smallest whose visible width
* reaches most of the touchscreen's, else the largest there is. Always taking the smallest * reaches most of the touchscreen's, else the largest there is. Always taking the smallest
* put 800x480 on a 1540x720 Audi screen and stretched it. With no touchscreen size to go * put 800x480 on a 1540x720 Audi screen and stretched it. A car that gives no touchscreen
* by, the smallest is still the safe choice. * size gets the largest up to 1080p: a car only offers sizes it can show, and the 2024 VW
* (touchscreen 0x0) blurred 800x480 across a 1080p screen.
*/ */
private fun fitToScreen(offered: List<Int>): Int? { private fun fitToScreen(offered: List<Int>): Int? {
val known = offered.filter { videoConfigs.getOrNull(it)?.pixelSize() != null } val known = offered.filter { videoConfigs.getOrNull(it)?.pixelSize() != null }
fun pixels(idx: Int) = videoConfigs[idx].pixelCount() fun pixels(idx: Int) = videoConfigs[idx].pixelCount()
if (touchWidth <= 0) return known.minByOrNull(::pixels) if (touchWidth <= 0) {
return known.filter { pixels(it) <= 1920 * 1080 }.maxByOrNull(::pixels)
?: known.minByOrNull(::pixels)
}
val wide = known.filter { (videoConfigs[it].visibleSize()?.first ?: 0) >= touchWidth * 8 / 10 } val wide = known.filter { (videoConfigs[it].visibleSize()?.first ?: 0) >= touchWidth * 8 / 10 }
return wide.minByOrNull(::pixels) ?: known.maxByOrNull(::pixels) 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() { private fun startVideoSource() {
lastKeyframeSentAt = System.currentTimeMillis() // frame #1 is always a keyframe lastKeyframeSentAt = System.currentTimeMillis() // frame #1 is always a keyframe
val index = selectedConfigIndex ?: 0 val index = selectedConfigIndex ?: 0
@@ -826,8 +1069,21 @@ class GearslipRunner(
log.i("-> Start(session_id=$SESSION_ID, configuration_index=$index -> $config)") 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 // The car crops its margins off the frame, so keep the UI out of them unless a vehicle
// profile has its own tuned insets. // profile has its own tuned insets.
if (vehicleProfile?.insets.let { it == null || it == Insets.NONE }) { stretcher?.release()
val insets = config.marginInsets() 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) CarEnvironment.setInsets(insets)
if (insets != Insets.NONE) log.i("keeping the UI clear of the car's margins: $insets") if (insets != Insets.NONE) log.i("keeping the UI clear of the car's margins: $insets")
} }
@@ -836,8 +1092,10 @@ class GearslipRunner(
videoSource = VideoSource( videoSource = VideoSource(
width = width, width = width,
height = height, height = height,
frameRate = if (config.frameRate == 1) 60 else 30, // 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,
mode = if (useProjection) VideoSource.Mode.SURFACE else VideoSource.Mode.TEST_CARD, mode = if (useProjection) VideoSource.Mode.SURFACE else VideoSource.Mode.TEST_CARD,
wireless = wireless,
onCodecConfig = { csd -> onCodecConfig = { csd ->
lastCodecConfig = csd lastCodecConfig = csd
send(MSG_MEDIA_CODEC_CONFIG, csd, encrypted = true, channel = videoChannelId) send(MSG_MEDIA_CODEC_CONFIG, csd, encrypted = true, channel = videoChannelId)
@@ -852,8 +1110,19 @@ class GearslipRunner(
source.start() source.start()
val surface = source.inputSurface val surface = source.inputSurface
if (projection != null && surface != null) { 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() SessionStatus.projecting()
BluetoothAudioCheck.onProjecting()
LocationCheck.onProjecting()
} }
}.onFailure { e -> log.e("encoder start failed", e) } }.onFailure { e -> log.e("encoder start failed", e) }
} }
@@ -991,18 +1260,15 @@ class GearslipRunner(
log.i("touch -> frame: ${draw + encode}ms (draw ${draw}ms, encode and wait ${encode}ms)") log.i("touch -> frame: ${draw + encode}ms (draw ${draw}ms, encode and wait ${encode}ms)")
} }
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) { private fun onPingRequest(body: ByteArray) {
// The timestamp is an int64 and has to come back unchanged. Read as an int32 it was cut // The timestamp is an int64 and has to come back unchanged. Read as an int32 it was cut
// short, and a car that checks the echo (an Audi MIB2+) gave up at its second ping. // short, and a car that checks the echo (an Audi MIB2+) gave up at its second ping.
val timestamp = Protobuf.readInt64Field(body, 1) ?: System.nanoTime() val fields = Wire.fields(body)
send(MSG_PING_RESPONSE, Protobuf.varintField(1, timestamp), encrypted = tls.handshakeComplete) 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)") log.i("<- PingRequest(timestamp=$timestamp) / -> PingResponse (keeping the session alive)")
} }
@@ -1024,51 +1290,26 @@ class GearslipRunner(
try { try {
writer.execute { writer.execute {
try { try {
if (payload.size <= Frames.MAX_FRAME_PAYLOAD) { val frameCount = Frames.writeMessage(output, channel, encrypted, messageType, payload) { tls.encrypt(it) }
val framePayload = if (encrypted) tls.encrypt(payload) else payload if (frameCount > 1) splitMessages++
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++
}
output.flush() output.flush()
} catch (e: java.io.IOException) { } catch (e: IOException) {
if (running) log.w("send failed, stopping: ${e.message}") if (running) log.w("send failed, stopping: ${e.message}")
running = false running = false
videoSource?.stop() videoSource?.stop()
} }
} }
} catch (e: java.util.concurrent.RejectedExecutionException) { } catch (e: RejectedExecutionException) {
// The session is over; nothing more goes out. // The session is over; nothing more goes out.
} }
} }
private fun reportDisconnect() { private fun reportDisconnect() {
when (state) { when (state) {
State.WAIT_VERSION -> SessionReport.fail(SessionReport.Category.NO_HANDSHAKE, "no VersionRequest arrived", state.name) State.WAIT_VERSION -> SessionReport.fail(SessionReport.Category.NO_HANDSHAKE, "no VersionRequest arrived", state.name, "closed")
State.TLS_HANDSHAKE -> SessionReport.fail(SessionReport.Category.TLS, "head unit dropped the link during TLS", state.name) State.TLS_HANDSHAKE -> SessionReport.fail(SessionReport.Category.TLS, "head unit dropped the link during TLS", state.name, "closed")
State.WAIT_AUTH -> SessionReport.fail(SessionReport.Category.AUTH, "dropped after TLS, before AuthComplete", state.name) State.WAIT_AUTH -> SessionReport.fail(SessionReport.Category.AUTH, "dropped after TLS, before AuthComplete", state.name, "closed")
State.WAIT_SDR -> SessionReport.fail(SessionReport.Category.SERVICE_DISCOVERY, "accepted, then dropped before service discovery", state.name) State.WAIT_SDR -> SessionReport.fail(SessionReport.Category.SERVICE_DISCOVERY, "accepted, then dropped before service discovery", state.name, "closed")
State.DONE -> {} State.DONE -> {}
} }
when (state) { when (state) {
@@ -1154,13 +1395,6 @@ class GearslipRunner(
} }
private companion object { private companion object {
/**
* Force the protocol version Gearslip claims in VersionResponse, as (major, minor), or
* null to echo the head unit. 1.3 is what the 2018 Uconnect (which accepts the JVC cert)
* speaks. This is the 2025 identity-check downgrade experiment: see onVersionRequest.
*/
val CLAIM_VERSION: Pair<Int, Int>? = 1 to 3
// protobuf/aap_protobuf/service/control/ControlMessageType.proto:7-17 // protobuf/aap_protobuf/service/control/ControlMessageType.proto:7-17
const val MSG_VERSION_REQUEST = 1 const val MSG_VERSION_REQUEST = 1
const val MSG_VERSION_RESPONSE = 2 const val MSG_VERSION_RESPONSE = 2
@@ -1172,6 +1406,9 @@ class GearslipRunner(
const val MSG_PING_RESPONSE = 12 const val MSG_PING_RESPONSE = 12
const val MSG_BYEBYE_REQUEST = 15 const val MSG_BYEBYE_REQUEST = 15
const val MSG_BYEBYE_RESPONSE = 16 const val MSG_BYEBYE_RESPONSE = 16
const val MSG_VOICE_SESSION_NOTIFICATION = 17
const val MSG_UNEXPECTED_MESSAGE = 255
const val MSG_FRAMING_ERROR = 65535
const val MSG_CHANNEL_OPEN_REQUEST = 7 const val MSG_CHANNEL_OPEN_REQUEST = 7
const val MSG_CHANNEL_OPEN_RESPONSE = 8 const val MSG_CHANNEL_OPEN_RESPONSE = 8
@@ -1212,6 +1449,7 @@ class GearslipRunner(
const val ACK_STALL_MS = 2_000L const val ACK_STALL_MS = 2_000L
const val AUDIO_TRACE_MESSAGES = 6 const val AUDIO_TRACE_MESSAGES = 6
const val GUIDANCE_SESSION_ID = 1
const val PERIODIC_KEYFRAME_MS = 5_000L const val PERIODIC_KEYFRAME_MS = 5_000L
} }
} }
@@ -1,5 +1,6 @@
package app.seb3thehacker.gearslip package app.seb3thehacker.gearslip
import java.io.ByteArrayOutputStream
import java.net.Socket import java.net.Socket
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.security.KeyStore import java.security.KeyStore
@@ -11,23 +12,10 @@ import javax.net.ssl.SSLEngine
import javax.net.ssl.SSLEngineResult import javax.net.ssl.SSLEngineResult
import javax.net.ssl.SSLEngineResult.HandshakeStatus import javax.net.ssl.SSLEngineResult.HandshakeStatus
import javax.net.ssl.SSLPeerUnverifiedException import javax.net.ssl.SSLPeerUnverifiedException
import javax.net.ssl.SSLException
import javax.net.ssl.X509ExtendedTrustManager import javax.net.ssl.X509ExtendedTrustManager
/** /** TLS server for projection. Handshake records travel in control message 3, not a TLS socket. */
* The phone end of the Android Auto TLS session.
*
* Two things here are the opposite of what aasdk does, because aasdk implements the head
* unit and the head unit is the TLS *client*: it calls TLS_client_method() and
* SSL_set_connect_state (src/Transport/SSLWrapper.cpp:97-140, src/Messenger/Cryptor.cpp:87),
* and SSL_set_accept_state appears nowhere in that codebase. So the phone is the TLS
* **server** and its certificate goes out as the server certificate - sent unconditionally
* on every handshake, which is precisely what we are testing.
*
* TLS here is not layered over the socket: handshake bytes travel inside
* MESSAGE_ENCAPSULATED_SSL control messages (id 3). That rules out SSLSocket and makes
* SSLEngine - a buffer-to-buffer engine, the JSSE counterpart of aasdk's memory BIO pair -
* the right tool.
*/
class PhoneTls(keyStore: KeyStore, password: CharArray) { class PhoneTls(keyStore: KeyStore, password: CharArray) {
private val log = GearslipLog.tagged("TLS") private val log = GearslipLog.tagged("TLS")
@@ -36,7 +24,7 @@ class PhoneTls(keyStore: KeyStore, password: CharArray) {
private var appBuffer = ByteBuffer.allocate(32 * 1024) private var appBuffer = ByteBuffer.allocate(32 * 1024)
private var netBuffer = ByteBuffer.allocate(32 * 1024) private var netBuffer = ByteBuffer.allocate(32 * 1024)
var handshakeComplete: Boolean = false @Volatile var handshakeComplete: Boolean = false
private set private set
init { init {
@@ -52,6 +40,7 @@ class PhoneTls(keyStore: KeyStore, password: CharArray) {
useClientMode = false useClientMode = false
// want, not need: we ask the head unit for its certificate so the run also tells // want, not need: we ask the head unit for its certificate so the run also tells
// us what real units present, but we never make the session depend on getting one. // us what real units present, but we never make the session depend on getting one.
// Homemade head units may have none.
wantClientAuth = true wantClientAuth = true
enabledProtocols = arrayOf("TLSv1.2") // aasdk pins TLS 1.2 enabledProtocols = arrayOf("TLSv1.2") // aasdk pins TLS 1.2
} }
@@ -88,7 +77,7 @@ class PhoneTls(keyStore: KeyStore, password: CharArray) {
if (netBuffer.hasRemaining()) { if (netBuffer.hasRemaining()) {
outgoing += ByteArray(netBuffer.remaining()).also { netBuffer.get(it) } outgoing += ByteArray(netBuffer.remaining()).also { netBuffer.get(it) }
} }
if (result.status == SSLEngineResult.Status.CLOSED) break@loop if (result.status == SSLEngineResult.Status.CLOSED) throw SSLException("TLS closed during handshake")
} }
HandshakeStatus.NEED_UNWRAP -> { HandshakeStatus.NEED_UNWRAP -> {
@@ -100,7 +89,7 @@ class PhoneTls(keyStore: KeyStore, password: CharArray) {
appBuffer = ByteBuffer.allocate(appBuffer.capacity() * 2) appBuffer = ByteBuffer.allocate(appBuffer.capacity() * 2)
continue@loop continue@loop
} }
SSLEngineResult.Status.CLOSED -> break@loop SSLEngineResult.Status.CLOSED -> throw SSLException("TLS closed during handshake")
else -> Unit else -> Unit
} }
} }
@@ -124,7 +113,7 @@ class PhoneTls(keyStore: KeyStore, password: CharArray) {
/** Wraps a control-channel payload for an ENCRYPTED frame. */ /** Wraps a control-channel payload for an ENCRYPTED frame. */
fun encrypt(plain: ByteArray): ByteArray { fun encrypt(plain: ByteArray): ByteArray {
val source = ByteBuffer.wrap(plain) val source = ByteBuffer.wrap(plain)
val out = java.io.ByteArrayOutputStream() val out = ByteArrayOutputStream()
while (source.hasRemaining()) { while (source.hasRemaining()) {
netBuffer.clear() netBuffer.clear()
val result = engine.wrap(source, netBuffer) val result = engine.wrap(source, netBuffer)
@@ -132,6 +121,9 @@ class PhoneTls(keyStore: KeyStore, password: CharArray) {
netBuffer = ByteBuffer.allocate(netBuffer.capacity() * 2) netBuffer = ByteBuffer.allocate(netBuffer.capacity() * 2)
continue continue
} }
if (result.status != SSLEngineResult.Status.OK || result.bytesConsumed() == 0) {
throw SSLException("TLS could not encrypt application data: ${result.status}")
}
netBuffer.flip() netBuffer.flip()
val chunk = ByteArray(netBuffer.remaining()) val chunk = ByteArray(netBuffer.remaining())
netBuffer.get(chunk) netBuffer.get(chunk)
@@ -140,14 +132,10 @@ class PhoneTls(keyStore: KeyStore, password: CharArray) {
return out.toByteArray() return out.toByteArray()
} }
/** /** Each encrypted projection frame contains complete TLS records. */
* Unwraps one ENCRYPTED frame's payload. aasdk decrypts per frame
* (MessageOutStream::compoundFrame encrypts per frame), so each frame's payload is
* self-contained ciphertext.
*/
fun decrypt(cipher: ByteArray): ByteArray { fun decrypt(cipher: ByteArray): ByteArray {
val source = ByteBuffer.wrap(cipher) val source = ByteBuffer.wrap(cipher)
val out = java.io.ByteArrayOutputStream() val out = ByteArrayOutputStream()
while (source.hasRemaining()) { while (source.hasRemaining()) {
appBuffer.clear() appBuffer.clear()
val result = engine.unwrap(source, appBuffer) val result = engine.unwrap(source, appBuffer)
@@ -157,10 +145,9 @@ class PhoneTls(keyStore: KeyStore, password: CharArray) {
continue continue
} }
SSLEngineResult.Status.BUFFER_UNDERFLOW -> { SSLEngineResult.Status.BUFFER_UNDERFLOW -> {
log.w("decrypt: record split across frames (${source.remaining()} bytes left over)") throw SSLException("Incomplete TLS record in an encrypted frame")
break
} }
SSLEngineResult.Status.CLOSED -> break SSLEngineResult.Status.CLOSED -> throw SSLException("TLS peer closed the connection")
else -> Unit else -> Unit
} }
appBuffer.flip() appBuffer.flip()
@@ -2,14 +2,7 @@ package app.seb3thehacker.gearslip
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
/** /** Protobuf field encoding and diagnostic rendering for the projection protocol. */
* Just enough protobuf to run the spike, hand-rolled so there is no protoc step.
*
* Only three messages are ever written (AuthResponse is only read):
* ServiceDiscoveryRequest { optional string label_text = 4; optional string device_name = 5; }
* PingResponse { required int64 timestamp = 1; }
* AuthResponse { required int32 status = 1; }
*/
object Protobuf { object Protobuf {
fun varint(value: Long): ByteArray { fun varint(value: Long): ByteArray {
@@ -31,6 +24,9 @@ object Protobuf {
return varint(((field shl 3) or 2).toLong()) + varint(bytes.size.toLong()) + bytes return varint(((field shl 3) or 2).toLong()) + varint(bytes.size.toLong()) + bytes
} }
fun bytesField(field: Int, bytes: ByteArray): ByteArray =
varint(((field shl 3) or 2).toLong()) + varint(bytes.size.toLong()) + bytes
fun varintField(field: Int, value: Long): ByteArray = fun varintField(field: Int, value: Long): ByteArray =
varint((field shl 3).toLong()) + varint(value) varint((field shl 3).toLong()) + varint(value)
@@ -38,29 +34,7 @@ object Protobuf {
fun readInt32Field(data: ByteArray, field: Int): Int? = readInt64Field(data, field)?.toInt() fun readInt32Field(data: ByteArray, field: Int): Int? = readInt64Field(data, field)?.toInt()
/** Reads a varint field whole, for int64s such as a ping's timestamp. */ /** Reads a varint field whole, for int64s such as a ping's timestamp. */
fun readInt64Field(data: ByteArray, field: Int): Long? { fun readInt64Field(data: ByteArray, field: Int): Long? = Wire.varint(Wire.fields(data), field)
var pos = 0
while (pos < data.size) {
val (tag, afterTag) = readVarint(data, pos) ?: return null
pos = afterTag
val number = (tag ushr 3).toInt()
when ((tag and 7L).toInt()) {
0 -> {
val (value, afterValue) = readVarint(data, pos) ?: return null
pos = afterValue
if (number == field) return value
}
2 -> {
val (len, afterLen) = readVarint(data, pos) ?: return null
pos = afterLen + len.toInt()
}
5 -> pos += 4
1 -> pos += 8
else -> return null
}
}
return null
}
/** /**
* Shallow field dump. Used on ServiceDiscoveryResponse: its make/model/year strings * Shallow field dump. Used on ServiceDiscoveryResponse: its make/model/year strings
@@ -68,6 +42,7 @@ object Protobuf {
* proof that the session is live and we are decrypting correctly. * proof that the session is live and we are decrypting correctly.
*/ */
fun describe(data: ByteArray, indent: String = " "): String { fun describe(data: ByteArray, indent: String = " "): String {
if (indent.length > 32) return "$indent(nested message)\n"
val sb = StringBuilder() val sb = StringBuilder()
var pos = 0 var pos = 0
while (pos < data.size) { while (pos < data.size) {
@@ -86,6 +61,7 @@ object Protobuf {
} }
2 -> { 2 -> {
val (len, afterLen) = readVarint(data, pos) ?: break val (len, afterLen) = readVarint(data, pos) ?: break
if (len < 0 || len > data.size - afterLen) break
val end = afterLen + len.toInt() val end = afterLen + len.toInt()
if (end > data.size) break if (end > data.size) break
val body = data.copyOfRange(afterLen, end) val body = data.copyOfRange(afterLen, end)
@@ -114,6 +90,7 @@ object Protobuf {
var pos = start var pos = start
while (pos < data.size && shift <= 63) { while (pos < data.size && shift <= 63) {
val b = data[pos].toInt() and 0xFF val b = data[pos].toInt() and 0xFF
if (shift == 63 && b > 1) return null
result = result or ((b and 0x7F).toLong() shl shift) result = result or ((b and 0x7F).toLong() shl shift)
pos++ pos++
if (b and 0x80 == 0) return result to pos if (b and 0x80 == 0) return result to pos
@@ -132,45 +109,68 @@ object Wire {
val out = mutableListOf<Field>() val out = mutableListOf<Field>()
var pos = 0 var pos = 0
while (pos < data.size) { while (pos < data.size) {
val tag = readVarint(data, pos) ?: break val tag = readVarint(data, pos) ?: return emptyList()
pos = tag.second pos = tag.second
val number = (tag.first ushr 3).toInt() val number = (tag.first ushr 3).toInt()
if (tag.first ushr 3 !in 1..0x1fff_ffffL) return emptyList()
when ((tag.first and 7L).toInt()) { when ((tag.first and 7L).toInt()) {
0 -> { 0 -> {
val v = readVarint(data, pos) ?: break val v = readVarint(data, pos) ?: return emptyList()
pos = v.second pos = v.second
out += Field(number, 0, v.first, null) out += Field(number, 0, v.first, null)
} }
1 -> { if (pos + 8 > data.size) break; pos += 8 } 1 -> { if (data.size - pos < 8) return emptyList(); pos += 8 }
2 -> { 2 -> {
val len = readVarint(data, pos) ?: break val len = readVarint(data, pos) ?: return emptyList()
if (len.first < 0 || len.first > data.size - len.second) return emptyList()
val end = len.second + len.first.toInt() val end = len.second + len.first.toInt()
if (end > data.size || end < len.second) break if (end > data.size || end < len.second) return emptyList()
out += Field(number, 2, 0, data.copyOfRange(len.second, end)) out += Field(number, 2, 0, data.copyOfRange(len.second, end))
pos = end pos = end
} }
5 -> { if (pos + 4 > data.size) break; pos += 4 } 5 -> { if (data.size - pos < 4) return emptyList(); pos += 4 }
else -> break else -> return emptyList()
} }
} }
return out return out
} }
fun varint(fields: List<Field>, number: Int): Long? = fun varint(fields: List<Field>, number: Int): Long? =
fields.firstOrNull { it.number == number && it.wireType == 0 }?.varint fields.lastOrNull { it.number == number && it.wireType == 0 }?.varint
fun bytes(fields: List<Field>, number: Int): ByteArray? = fun bytes(fields: List<Field>, number: Int): ByteArray? =
fields.firstOrNull { it.number == number && it.wireType == 2 }?.bytes fields.lastOrNull { it.number == number && it.wireType == 2 }?.bytes
fun allBytes(fields: List<Field>, number: Int): List<ByteArray> = fun allBytes(fields: List<Field>, number: Int): List<ByteArray> =
fields.filter { it.number == number && it.wireType == 2 }.mapNotNull { it.bytes } fields.filter { it.number == number && it.wireType == 2 }.mapNotNull { it.bytes }
/** Protobuf readers accept both packed and unpacked repeated numeric fields. */
fun repeatedInts(fields: List<Field>, number: Int): List<Int> =
repeatedLongs(fields, number)?.map(Long::toInt).orEmpty()
/** Null means a packed field contained an incomplete or overflowing varint. */
fun repeatedLongs(fields: List<Field>, number: Int): List<Long>? = buildList {
for (field in fields.filter { it.number == number }) {
if (field.wireType == 0) add(field.varint)
else if (field.wireType == 2) {
val bytes = field.bytes ?: continue
var position = 0
while (position < bytes.size) {
val (value, next) = readVarint(bytes, position) ?: return null
add(value)
position = next
}
}
}
}
private fun readVarint(data: ByteArray, start: Int): Pair<Long, Int>? { private fun readVarint(data: ByteArray, start: Int): Pair<Long, Int>? {
var result = 0L var result = 0L
var shift = 0 var shift = 0
var pos = start var pos = start
while (pos < data.size && shift <= 63) { while (pos < data.size && shift <= 63) {
val b = data[pos].toInt() and 0xFF val b = data[pos].toInt() and 0xFF
if (shift == 63 && b > 1) return null
result = result or ((b and 0x7F).toLong() shl shift) result = result or ((b and 0x7F).toLong() shl shift)
pos++ pos++
if (b and 0x80 == 0) return result to pos if (b and 0x80 == 0) return result to pos
@@ -0,0 +1,17 @@
package app.seb3thehacker.gearslip
/**
* Advertises our supported 1.x version, as Android Auto does for older head units. Head units that
* offer a newer major version (LIVI asks for 6.1, a RAV4 and the DHU for 4.2) still accept a 1.x
* answer, so only a major version of 0 is refused.
*/
internal object ProtocolVersion {
data class Response(val major: Int, val minor: Int, val supported: Boolean) {
val bytes: ByteArray get() = byteArrayOf(
(major ushr 8).toByte(), major.toByte(), (minor ushr 8).toByte(), minor.toByte(),
if (supported) 0 else -1, if (supported) 0 else -1,
)
}
fun respond(major: Int, minor: Int): Response = Response(1, 7, major >= 1 && minor in 0..0xffff)
}
@@ -51,7 +51,7 @@ class ScreenProjector(private val context: Context) {
* preview passes "gearslip-preview". * preview passes "gearslip-preview".
*/ */
fun start( fun start(
surface: Surface, surface: Surface?,
width: Int, width: Int,
height: Int, height: Int,
densityDpi: Int, densityDpi: Int,
@@ -198,6 +198,12 @@ class ScreenProjector(private val context: Context) {
*/ */
fun dispatchKey(keyCode: Int, down: Boolean) { fun dispatchKey(keyCode: Int, down: Boolean) {
val target = root ?: return val target = root ?: return
// Keys handed straight to the view skip the step where Android leaves touch mode, and in
// touch mode Compose's buttons refuse focus, so the knob had nothing to move between.
if (target.isInTouchMode) {
target.requestFocusFromTouch()
GearslipLog.i("keys: left touch mode for the knob/D-pad (now in touch mode: ${target.isInTouchMode})")
}
// Nothing on the display takes keys until something in it holds focus. // Nothing on the display takes keys until something in it holds focus.
if (target.findFocus() == null) target.requestFocus() if (target.findFocus() == null) target.requestFocus()
val shift = keyCode == app.seb3thehacker.gearslip.car.CarKeys.KEYCODE_SHIFT_TAB val shift = keyCode == app.seb3thehacker.gearslip.car.CarKeys.KEYCODE_SHIFT_TAB
@@ -213,6 +219,14 @@ class ScreenProjector(private val context: Context) {
target.dispatchKeyEvent(event) target.dispatchKeyEvent(event)
} }
/**
* Points the display at another surface, or none, without restarting what it shows. The
* preview's SurfaceView comes and goes as the phone turns; the car UI on it carries on.
*/
fun setSurface(surface: Surface?) {
virtualDisplay?.surface = surface
}
fun stop() { fun stop() {
runCatching { presentation?.dismiss() } runCatching { presentation?.dismiss() }
runCatching { owner?.destroy() } runCatching { owner?.destroy() }
@@ -76,7 +76,7 @@ object ServiceDiscovery {
fun findAudioServices(response: ByteArray): List<AudioService> = buildList { fun findAudioServices(response: ByteArray): List<AudioService> = buildList {
for (channel in Wire.allBytes(Wire.fields(response), 1)) { for (channel in Wire.allBytes(Wire.fields(response), 1)) {
val service = Wire.fields(channel) val service = Wire.fields(channel)
val id = Wire.varint(service, 1)?.toInt() ?: continue val id = serviceId(service) ?: continue
val sink = Wire.bytes(service, 3) ?: continue val sink = Wire.bytes(service, 3) ?: continue
val sinkFields = Wire.fields(sink) val sinkFields = Wire.fields(sink)
val audioConfigs = Wire.allBytes(sinkFields, 3) val audioConfigs = Wire.allBytes(sinkFields, 3)
@@ -110,6 +110,34 @@ object ServiceDiscovery {
return media.firstOrNull { it.codecType == CODEC_PCM } ?: media.firstOrNull() return media.firstOrNull { it.codecType == CODEC_PCM } ?: media.firstOrNull()
} }
/**
* The channel the car takes "now playing" on (MediaPlaybackStatusService, Service field 9),
* which feeds its cluster and media screens. It carries no settings. Null when the car has none.
*/
fun findMediaStatusChannel(response: ByteArray): Int? = findChannelWith(response, 9)
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 (Wire.bytes(service, serviceField) != null) serviceId(service) else null
}
/** /**
* The car's microphone, from MediaSourceService (Service field 5): * The car's microphone, from MediaSourceService (Service field 5):
* MediaSourceService { MediaCodecType available_type = 1; AudioConfiguration audio_config = 2; * MediaSourceService { MediaCodecType available_type = 1; AudioConfiguration audio_config = 2;
@@ -119,7 +147,7 @@ object ServiceDiscovery {
fun findMicService(response: ByteArray): AudioService? { fun findMicService(response: ByteArray): AudioService? {
for (channel in Wire.allBytes(Wire.fields(response), 1)) { for (channel in Wire.allBytes(Wire.fields(response), 1)) {
val service = Wire.fields(channel) val service = Wire.fields(channel)
val id = Wire.varint(service, 1)?.toInt() ?: continue val id = serviceId(service) ?: continue
val source = Wire.fields(Wire.bytes(service, 5) ?: continue) val source = Wire.fields(Wire.bytes(service, 5) ?: continue)
val f = Wire.fields(Wire.bytes(source, 2) ?: continue) val f = Wire.fields(Wire.bytes(source, 2) ?: continue)
return AudioService( return AudioService(
@@ -138,8 +166,43 @@ object ServiceDiscovery {
return null return null
} }
/**
* The car's Bluetooth, from BluetoothService (Service field 6):
* BluetoothService { string car_address = 1; repeated BluetoothPairingMethod methods = 2 [packed]; }
* Null when the car offers no pairing over the link.
*/
class BluetoothService(val serviceId: Int, val carAddress: String, val pairingMethods: List<Int>)
/** A carmaker's own channel (service field 12), for its apps on the phone: GM's is "com.gm.vip". */
class VendorService(val serviceId: Int, val name: String)
fun findVendorServices(response: ByteArray): List<VendorService> =
Wire.allBytes(Wire.fields(response), 1).mapNotNull { channel ->
val service = Wire.fields(channel)
val id = Wire.varint(service, 1)?.toInt() ?: return@mapNotNull null
val fields = Wire.fields(Wire.bytes(service, 12) ?: return@mapNotNull null)
val name = Wire.bytes(fields, 1)?.let { String(it, Charsets.UTF_8) } ?: return@mapNotNull null
VendorService(id, name)
}
fun findBluetoothService(response: ByteArray): BluetoothService? {
for (channel in Wire.allBytes(Wire.fields(response), 1)) {
val service = Wire.fields(channel)
val id = Wire.varint(service, 1)?.toInt() ?: continue
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 = Wire.repeatedInts(fields, 2)
return BluetoothService(id, address, methods)
}
return null
}
const val STREAM_GUIDANCE = 1
const val STREAM_MEDIA = 3 const val STREAM_MEDIA = 3
const val CODEC_PCM = 1 const val CODEC_PCM = 1
const val CODEC_AAC_LC = 2
const val CODEC_AAC_LC_ADTS = 4
private val STREAMS = mapOf(0 to "NONE", 1 to "GUIDANCE", 2 to "SYSTEM_AUDIO", 3 to "MEDIA", 4 to "TELEPHONY") private val STREAMS = mapOf(0 to "NONE", 1 to "GUIDANCE", 2 to "SYSTEM_AUDIO", 3 to "MEDIA", 4 to "TELEPHONY")
@@ -163,8 +226,8 @@ object ServiceDiscovery {
* repeated TouchScreen touchscreen = 2; ... } * repeated TouchScreen touchscreen = 2; ... }
* TouchScreen { required int32 width = 1; required int32 height = 2; type = 3; } * TouchScreen { required int32 width = 1; required int32 height = 2; type = 3; }
* *
* Touch coordinates arrive in this space (1258x708 on the 2018 Uconnect) and must be * Touch reports use projected video coordinates; these dimensions describe the input
* scaled to the projected video size before injection. [keycodesSupported] is what the * hardware and help select a video configuration. [keycodesSupported] is what the
* head unit says it will actually send over the Input channel as key events (steering-wheel * head unit says it will actually send over the Input channel as key events (steering-wheel
* buttons, etc.) - not every unit reports the same set, so it's kept for diagnostics and to * buttons, etc.) - not every unit reports the same set, so it's kept for diagnostics and to
* know ahead of time whether a button like voice/PTT is even wired up on this hardware. * know ahead of time whether a button like voice/PTT is even wired up on this hardware.
@@ -193,38 +256,18 @@ object ServiceDiscovery {
private fun inputServices(response: ByteArray): List<InputService> = buildList { private fun inputServices(response: ByteArray): List<InputService> = buildList {
for (channel in Wire.allBytes(Wire.fields(response), 1)) { for (channel in Wire.allBytes(Wire.fields(response), 1)) {
val service = Wire.fields(channel) val service = Wire.fields(channel)
val id = Wire.varint(service, 1)?.toInt() ?: continue val id = serviceId(service) ?: continue
val input = Wire.bytes(service, 4) ?: continue val input = Wire.bytes(service, 4) ?: continue
val inputFields = Wire.fields(input) val inputFields = Wire.fields(input)
// A unit with only a knob and buttons has no touchscreen entry; it still has keys. // A unit with only a knob and buttons has no touchscreen entry; it still has keys.
val touchFields = Wire.bytes(inputFields, 2)?.let { Wire.fields(it) }.orEmpty() val touchFields = Wire.bytes(inputFields, 2)?.let { Wire.fields(it) }.orEmpty()
val width = Wire.varint(touchFields, 1)?.toInt() ?: 0 val width = Wire.varint(touchFields, 1)?.toInt() ?: 0
val height = Wire.varint(touchFields, 2)?.toInt() ?: 0 val height = Wire.varint(touchFields, 2)?.toInt() ?: 0
// Declared packed, but some units send each keycode as its own field. val keycodes = Wire.repeatedInts(inputFields, 1)
val keycodes = inputFields.filter { it.number == 1 }.flatMap { f ->
f.bytes?.let(::packedVarints) ?: listOf(f.varint.toInt())
}
add(InputService(id, width, height, keycodes, Wire.varint(inputFields, 5)?.toInt() ?: 0)) add(InputService(id, width, height, keycodes, Wire.varint(inputFields, 5)?.toInt() ?: 0))
} }
} }
/** Decodes a `[packed=true]` repeated varint field's raw bytes into individual values. */
private fun packedVarints(data: ByteArray): List<Int> = buildList {
var pos = 0
while (pos < data.size) {
var result = 0L
var shift = 0
while (pos < data.size) {
val b = data[pos].toInt() and 0xFF
result = result or ((b and 0x7F).toLong() shl shift)
pos++
if (b and 0x80 == 0) break
shift += 7
}
add(result.toInt())
}
}
/** /**
* The main screen's video sink. A head unit with a second screen, such as LIVI with a * The main screen's video sink. A head unit with a second screen, such as LIVI with a
* cluster display, offers a sink for each; the one without a display id (0) is the main one. * cluster display, offers a sink for each; the one without a display id (0) is the main one.
@@ -237,7 +280,7 @@ object ServiceDiscovery {
private fun videoServices(response: ByteArray): List<VideoService> = buildList { private fun videoServices(response: ByteArray): List<VideoService> = buildList {
for (channel in Wire.allBytes(Wire.fields(response), 1)) { for (channel in Wire.allBytes(Wire.fields(response), 1)) {
val service = Wire.fields(channel) val service = Wire.fields(channel)
val id = Wire.varint(service, 1)?.toInt() ?: continue val id = serviceId(service) ?: continue
val sink = Wire.bytes(service, 3) ?: continue val sink = Wire.bytes(service, 3) ?: continue
val sinkFields = Wire.fields(sink) val sinkFields = Wire.fields(sink)
@@ -264,16 +307,15 @@ object ServiceDiscovery {
* SensorSourceService { repeated Sensor sensors = 1; } * SensorSourceService { repeated Sensor sensors = 1; }
* Sensor { required SensorType sensor_type = 1; } * Sensor { required SensorType sensor_type = 1; }
* *
* Field numbers for what actually rides inside each SensorEvent once subscribed are not * SensorBatch contains repeated messages in fields 1 through 22 matching these type ids.
* pinned down here - unlike video/audio/input, there's no independent source to check them * The vehicle data screen keeps their raw contents for diagnostics.
* against, so [CarSensors] shows the raw decode instead of trusting a guessed field map.
*/ */
class SensorService(val serviceId: Int, val types: List<Int>) class SensorService(val serviceId: Int, val types: List<Int>)
fun findSensorService(response: ByteArray): SensorService? { fun findSensorService(response: ByteArray): SensorService? {
for (channel in Wire.allBytes(Wire.fields(response), 1)) { for (channel in Wire.allBytes(Wire.fields(response), 1)) {
val service = Wire.fields(channel) val service = Wire.fields(channel)
val id = Wire.varint(service, 1)?.toInt() ?: continue val id = serviceId(service) ?: continue
val sensorSource = Wire.bytes(service, 2) ?: continue val sensorSource = Wire.bytes(service, 2) ?: continue
val types = Wire.allBytes(Wire.fields(sensorSource), 1).mapNotNull { val types = Wire.allBytes(Wire.fields(sensorSource), 1).mapNotNull {
Wire.varint(Wire.fields(it), 1)?.toInt() Wire.varint(Wire.fields(it), 1)?.toInt()
@@ -29,12 +29,30 @@ object SessionReport {
UNKNOWN("unclassified"), UNKNOWN("unclassified"),
} }
/**
* How far a session got, in the order a connection is built. The usage note's "Where
* connections fail" share sends the furthest one reached, so a failure says where it broke.
*/
enum class Stage(val key: String) {
USB("usb"), // the link opened; the car may have said nothing
VERSIONS("versions"), // the car said which protocol it speaks
TLS("tls"), // the secure link is up
ACCEPTED("accepted"), // the car accepted the certificate
CHANNELS("channels"), // the car listed its channels and said who it is
VIDEO("video"), // the car's screen is set up for Gearslip
STREAMING("streaming"), // video frames are reaching the car
}
private var appVersion = "?" private var appVersion = "?"
private var appCode = 0L private var appCode = 0L
@Volatile private var startedAt = 0L @Volatile private var startedAt = 0L
@Volatile var category = Category.NONE; private set @Volatile var category = Category.NONE; private set
@Volatile private var failure = "" @Volatile private var failure = ""
@Volatile var stage = Stage.USB; private set
/** What the car or the phone said when it broke: a TLS alert, an auth status, a ByeBye reason, an errno. */
@Volatile private var failCode = ""
@Volatile private var failedAfter = 0L
@Volatile private var protocolVersion = "" @Volatile private var protocolVersion = ""
@Volatile private var certSource = "" @Volatile private var certSource = ""
@Volatile private var certIssuer = "" @Volatile private var certIssuer = ""
@@ -58,10 +76,17 @@ object SessionReport {
appCode = info?.longVersionCode ?: 0 appCode = info?.longVersionCode ?: 0
} }
fun begin() { /** How this drive reached the car, and how its music did: "usb" or "wireless", "usb" or "bluetooth". */
@Volatile var link = ""; private set
@Volatile var audioMode = ""; private set
fun begin(wireless: Boolean) {
startedAt = System.currentTimeMillis() startedAt = System.currentTimeMillis()
link = if (wireless) "wireless" else "usb"
audioMode = if (app.seb3thehacker.gearslip.car.CarSettings.pipeAudio.value) "usb" else "bluetooth"
category = Category.NONE category = Category.NONE
failure = ""; protocolVersion = ""; certSource = ""; certIssuer = "" failure = ""; protocolVersion = ""; certSource = ""; certIssuer = ""
stage = Stage.USB; failCode = ""; failedAfter = 0
tlsProtocol = ""; tlsCipher = ""; authStatus = null tlsProtocol = ""; tlsCipher = ""; authStatus = null
headUnitInfo = null headUnitInfo = null
carScreen = ""; carDpi = 0 carScreen = ""; carDpi = 0
@@ -69,34 +94,87 @@ object SessionReport {
printed = false printed = false
} }
fun protocolVersion(major: Int, minor: Int) { protocolVersion = "$major.$minor" } /** Moves the session's furthest stage on; never back. */
private fun reached(next: Stage) { if (next > stage) stage = next }
fun protocolVersion(major: Int, minor: Int) { protocolVersion = "$major.$minor"; reached(Stage.VERSIONS) }
fun certificate(source: String, issuer: String) { certSource = source; certIssuer = issuer } fun certificate(source: String, issuer: String) { certSource = source; certIssuer = issuer }
fun tls(protocol: String, cipher: String) { tlsProtocol = protocol; tlsCipher = cipher } fun tls(protocol: String, cipher: String) { tlsProtocol = protocol; tlsCipher = cipher; reached(Stage.TLS) }
fun auth(status: Int) { authStatus = status } fun auth(status: Int) { authStatus = status; if (status == 0) reached(Stage.ACCEPTED) }
fun headUnit(info: String) { headUnit = info } fun headUnit(info: String) { headUnit = info }
/** Who the car said it is, for the opt-in usage note; null until service discovery. */ /** Who the car said it is, for the opt-in usage note; null until service discovery. */
@Volatile var headUnitInfo: ServiceDiscovery.HeadUnitInfo? = null @Volatile var headUnitInfo: ServiceDiscovery.HeadUnitInfo? = null
set(value) { field = value; if (value != null) reached(Stage.CHANNELS) }
/** The car screen's usable size once its margins are cropped, and its density; blank before video starts. */ /** The car screen's usable size once its margins are cropped, and its density; blank before video starts. */
@Volatile var carScreen = ""; private set @Volatile var carScreen = ""; private set
@Volatile var carDpi = 0; private set @Volatile var carDpi = 0; private set
fun carScreen(size: String, dpi: Int) { carScreen = size; carDpi = dpi } fun carScreen(size: String, dpi: Int) { carScreen = size; carDpi = dpi; reached(Stage.VIDEO) }
fun profile(name: String?) { profile = name ?: "none matched" } fun profile(name: String?) { profile = name ?: "none matched" }
fun video(summary: String) { video = summary } fun video(summary: String) { video = summary }
fun audio(summary: String) { audio = summary } fun audio(summary: String) { audio = summary }
fun stream(summary: String) { lastStream = summary } fun stream(summary: String) { lastStream = summary; reached(Stage.STREAMING) }
/** Records the first failure only: later ones are usually fallout from it. */ /**
fun fail(category: Category, detail: String, state: String = "") { * Records the first failure only: later ones are usually fallout from it. [code] is the short
* thing the car or the phone said, like "auth -3" or "bye 1", for the usage note.
*/
fun fail(category: Category, detail: String, state: String = "", code: String = "") {
if (this.category != Category.NONE) return if (this.category != Category.NONE) return
this.category = category this.category = category
failure = detail failure = detail
lastState = state lastState = state
failCode = code
failedAfter = if (startedAt == 0L) 0 else (System.currentTimeMillis() - startedAt) / 1000
} }
fun fail(category: Category, detail: String, t: Throwable, state: String = "") = fun fail(category: Category, detail: String, t: Throwable, state: String = "") =
fail(category, "$detail (${GearslipLog.rootCause(t)})", state) fail(category, "$detail (${GearslipLog.rootCause(t)})", state, codeOf(t))
/**
* A short code from an exception, never its whole message: the TLS alert's name, the errno
* of a USB failure, or else the exception's class.
*/
private fun codeOf(t: Throwable): String {
var root = t
while (root.cause != null && root.cause !== root) root = root.cause!!
val text = "${t.message} ${root.message}"
Regex("(?i)alert[ _:]*\\(?([a-z_ ]{3,40}?)\\)?(?:\\s|$|[,;.])").find(text)?.let {
return "alert ${it.groupValues[1].trim().lowercase().replace(' ', '_')}"
}
Regex("\\b(E[A-Z]{2,12})\\b").find(text)?.let { return it.groupValues[1] }
return root.javaClass.simpleName.take(40)
}
/**
* One session for the usage note's "Where connections fail" share: how far it got, how and
* when it ended, and with which kind of certificate. Counts and codes only, nothing personal.
*/
fun sessionNote(): org.json.JSONObject {
val seconds = if (startedAt == 0L) 0 else (System.currentTimeMillis() - startedAt) / 1000
val source = certSource.lowercase()
return org.json.JSONObject()
.put("stage", stage.key)
.put("ended", category.name.lowercase())
.put("code", failCode)
.put("failed_at", if (category == Category.NONE) 0 else rounded(failedAfter))
.put("seconds", rounded(seconds))
.put("cert", when {
"imported" in source -> "imported"
"downloaded" in source -> "downloaded"
"self" in source -> "self-signed"
source.isEmpty() -> ""
else -> "other"
})
}
/** Seconds rounded so a drive's length can't be matched to anything: 5 s, then minutes, then 5 minutes. */
private fun rounded(s: Long): Long = when {
s < 60 -> (s + 2) / 5 * 5
s < 3600 -> (s + 30) / 60 * 60
else -> (s + 150) / 300 * 300
}
/** The report as plain text, for the log file and for anything that gets sent. */ /** The report as plain text, for the log file and for anything that gets sent. */
fun render(): String { fun render(): String {
@@ -145,7 +223,14 @@ object SessionReport {
category == Category.NONE -> "ended before video" category == Category.NONE -> "ended before video"
else -> category.label else -> category.label
} }
runCatching { UsageStats.recordSession(context, headUnitInfo, protocolVersion, outcome, carScreen, carDpi) } runCatching {
UsageStats.recordSession(context, headUnitInfo, protocolVersion, outcome, carScreen, carDpi, sessionNote(), link, audioMode)
}
if (outcome == "connected") runCatching {
app.seb3thehacker.gearslip.stats.WorkedPrompt.onDriveWorked(context, headUnitInfo, protocolVersion, carScreen, carDpi)
app.seb3thehacker.gearslip.ui.DonatePrompt.onDriveWorked(context)
}
runCatching { app.seb3thehacker.gearslip.update.UpdateChecker.onDriveEnded(context) }
} }
GearslipLog.flush() GearslipLog.flush()
} }
@@ -20,7 +20,7 @@ object SessionStatus {
Snapshot( Snapshot(
Phase.IDLE, Phase.IDLE,
"Not connected", "Not connected",
"Plug the phone into the car's USB port. Gearslip opens by itself when the head unit connects.", "Plug into the car's USB port, or connect wirelessly. Gearslip opens by itself when the car answers.",
), ),
) )
@@ -4,33 +4,25 @@ import java.net.Socket
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.security.SecureRandom import java.security.SecureRandom
import java.security.cert.X509Certificate import java.security.cert.X509Certificate
import javax.net.ssl.KeyManagerFactory
import javax.net.ssl.SSLContext import javax.net.ssl.SSLContext
import javax.net.ssl.SSLEngine import javax.net.ssl.SSLEngine
import javax.net.ssl.SSLEngineResult import javax.net.ssl.SSLEngineResult
import javax.net.ssl.SSLEngineResult.HandshakeStatus import javax.net.ssl.SSLEngineResult.HandshakeStatus
import javax.net.ssl.X509ExtendedTrustManager import javax.net.ssl.X509ExtendedTrustManager
/** /** Checks mutual TLS and an encrypted data round trip with an in-process head-unit peer. */
* 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.
*/
object TlsSelfTest { object TlsSelfTest {
private val log = GearslipLog.tagged("TLS") 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("=== TLS self-test starting ===")
log.i("cert source: ${identity.source}") log.i("cert source: ${identity.source}")
log.i(" subject = ${identity.certificate.subjectX500Principal}") log.i(" subject = ${identity.certificate.subjectX500Principal}")
log.i(" issuer = ${identity.certificate.issuerX500Principal}") log.i(" issuer = ${identity.certificate.issuerX500Principal}")
return try { return try {
val server = PhoneTls(identity.keyStore, identity.password) val server = PhoneTls(identity.keyStore, identity.password)
val client = Driver(clientEngine()) val client = Driver(clientEngine(withClientCertificate))
var outbound = client.pump(null) // ClientHello var outbound = client.pump(null) // ClientHello
var rounds = 0 var rounds = 0
@@ -49,8 +41,7 @@ object TlsSelfTest {
return false return false
} }
// Data round trip, exercising the same encrypt path the spike uses for // Exercise the same encrypt path used by ServiceDiscoveryRequest.
// ServiceDiscoveryRequest.
val plaintext = "gearslip round trip".toByteArray() val plaintext = "gearslip round trip".toByteArray()
val decrypted = client.decrypt(server.encrypt(plaintext)) val decrypted = client.decrypt(server.encrypt(plaintext))
if (!decrypted.contentEquals(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") 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 { return context.createSSLEngine().apply {
useClientMode = true useClientMode = true
enabledProtocols = arrayOf("TLSv1.2") enabledProtocols = arrayOf("TLSv1.2")
@@ -23,6 +23,8 @@ class VideoSource(
private val height: Int, private val height: Int,
private val frameRate: Int, private val frameRate: Int,
private val mode: Mode = Mode.TEST_CARD, private val mode: Mode = Mode.TEST_CARD,
/** Over Wi-Fi: a fraction of the cable bitrate, so the link keeps up and sound isn't starved. */
private val wireless: Boolean = false,
private val onCodecConfig: (ByteArray) -> Unit, private val onCodecConfig: (ByteArray) -> Unit,
private val onFrame: (data: ByteArray, presentationTimeUs: Long, keyFrame: Boolean) -> Unit, private val onFrame: (data: ByteArray, presentationTimeUs: Long, keyFrame: Boolean) -> Unit,
) { ) {
@@ -45,7 +47,10 @@ class VideoSource(
private var frameIndex = 0L private var frameIndex = 0L
fun start() { fun start() {
val bitRate = (width.toLong() * height * frameRate * BITS_PER_PIXEL).toInt().coerceIn(MIN_BIT_RATE, MAX_BIT_RATE) val level = levelFor(width, height, frameRate)
val bitRate = defaultBitRate(width, height, frameRate, level).let {
if (wireless) (it * WIRELESS_SHARE).toInt().coerceAtMost(WIRELESS_MAX_BIT_RATE) else it
}
fun format(tuned: Boolean) = MediaFormat.createVideoFormat(MIME, width, height).apply { fun format(tuned: Boolean) = MediaFormat.createVideoFormat(MIME, width, height).apply {
setInteger( setInteger(
MediaFormat.KEY_COLOR_FORMAT, MediaFormat.KEY_COLOR_FORMAT,
@@ -74,7 +79,7 @@ class VideoSource(
setFloat(MediaFormat.KEY_MAX_FPS_TO_ENCODER, frameRate.toFloat()) setFloat(MediaFormat.KEY_MAX_FPS_TO_ENCODER, frameRate.toFloat())
} }
setInteger(MediaFormat.KEY_PROFILE, MediaCodecInfo.CodecProfileLevel.AVCProfileBaseline) setInteger(MediaFormat.KEY_PROFILE, MediaCodecInfo.CodecProfileLevel.AVCProfileBaseline)
setInteger(MediaFormat.KEY_LEVEL, MediaCodecInfo.CodecProfileLevel.AVCLevel31) setInteger(MediaFormat.KEY_LEVEL, level.codecLevel)
if (tuned) { if (tuned) {
// Hints only: a car screen wants each frame now, not the best frame a moment later. // Hints only: a car screen wants each frame now, not the best frame a moment later.
setInteger(MediaFormat.KEY_BITRATE_MODE, MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_VBR) setInteger(MediaFormat.KEY_BITRATE_MODE, MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_VBR)
@@ -98,7 +103,7 @@ class VideoSource(
codec = encoder codec = encoder
running = true running = true
log.i("encoder started: ${width}x$height @ ${frameRate}fps, ${bitRate / 1000} kbps, H.264 Baseline, mode=$mode") log.i("encoder started: ${width}x$height @ ${frameRate}fps, ${bitRate / 1000} kbps${if (wireless) " (wireless)" else ""}, H.264 Baseline level ${level.label}, mode=$mode")
worker = Thread { worker = Thread {
runCatching { pump(encoder) } runCatching { pump(encoder) }
@@ -223,10 +228,48 @@ class VideoSource(
const val TIMEOUT_US = 10_000L const val TIMEOUT_US = 10_000L
const val SURFACE_TIMEOUT_US = 100_000L const val SURFACE_TIMEOUT_US = 100_000L
/** Enough for sharp text and map lines; 800x480 at 30fps comes out at 4 Mbps. */ /**
const val BITS_PER_PIXEL = 0.3 * 800x480 at 30fps gets 8 Mbps: about the most that size can show, enough that a moving
const val MIN_BIT_RATE = 4_000_000 * video in the browser stays sharp. Bigger screens get more, but less than in proportion,
const val MAX_BIT_RATE = 12_000_000 * since large frames compress better: bitrate grows with pixels^0.75. 60fps gets 1.5x,
* because frames that close together are mostly alike.
*/
const val BASE_BIT_RATE = 8_000_000.0
const val BASE_PIXELS = 800.0 * 480
/** No car's decoder is asked for more than this, whatever its level allows. */
const val MAX_BIT_RATE = 25_000_000
/**
* The H.264 levels a car screen can need, smallest first: macroblocks a second each
* decodes, and the Baseline bitrate it must handle. The encoder declares the smallest
* that fits, and the bitrate stays within it, so a car decoder isn't asked for more than
* the level it was told about.
*/
enum class Level(val label: String, val codecLevel: Int, val macroblocksPerSecond: Int, val maxBitRate: Int) {
L31("3.1", MediaCodecInfo.CodecProfileLevel.AVCLevel31, 108_000, 14_000_000),
L32("3.2", MediaCodecInfo.CodecProfileLevel.AVCLevel32, 216_000, 20_000_000),
L4("4", MediaCodecInfo.CodecProfileLevel.AVCLevel4, 245_760, 20_000_000),
L42("4.2", MediaCodecInfo.CodecProfileLevel.AVCLevel42, 522_240, 50_000_000),
}
fun levelFor(width: Int, height: Int, frameRate: Int): Level {
val perSecond = ((width + 15) / 16) * ((height + 15) / 16) * frameRate
return Level.entries.firstOrNull { perSecond <= it.macroblocksPerSecond } ?: Level.L42
}
fun defaultBitRate(width: Int, height: Int, frameRate: Int, level: Level): Int {
val size = Math.pow(width.toDouble() * height / BASE_PIXELS, 0.75)
val rate = if (frameRate > 30) 1.5 else 1.0
return (BASE_BIT_RATE * size * rate).toInt().coerceAtMost(minOf(level.maxBitRate, MAX_BIT_RATE))
}
/**
* A car's Wi-Fi is shared, noisy and far slower than its nominal rate, and the sound rides
* the same link behind the video. Real Android Auto sends a few Mbps wirelessly; at the
* cable rate (15-20 Mbps for 720p) a laptop hotspot fell seconds behind and the music
* cut in and out. 720p30 comes out near 5.4 Mbps.
*/
const val WIRELESS_SHARE = 0.35
const val WIRELESS_MAX_BIT_RATE = 8_000_000
const val I_FRAME_INTERVAL_S = 5 const val I_FRAME_INTERVAL_S = 5
private const val REPEAT_AFTER_US = 100_000L private const val REPEAT_AFTER_US = 100_000L
@@ -0,0 +1,94 @@
package app.seb3thehacker.gearslip.audio
import android.media.MediaCodec
import android.media.MediaCodecInfo
import android.media.MediaFormat
/**
* Turns captured PCM into AAC-LC for a car whose music channel takes nothing else. Cars that
* offer PCM never get here: AAC costs a little delay and quality, and every car that works today
* is a PCM car.
*
* Android's encoder hands back raw access units, which is what AUDIO_AAC_LC expects. For
* AUDIO_AAC_LC_ADTS each unit gets the 7-byte ADTS header in front, so the car can find the
* frame edges itself. The encoder's first output is its configuration, not audio, and is skipped.
*/
internal class AacEncoder(private val sampleRate: Int, private val channels: Int, private val adts: Boolean) {
private val codec: MediaCodec = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_AUDIO_AAC).apply {
val format = MediaFormat.createAudioFormat(MediaFormat.MIMETYPE_AUDIO_AAC, sampleRate, channels).apply {
setInteger(MediaFormat.KEY_AAC_PROFILE, MediaCodecInfo.CodecProfileLevel.AACObjectLC)
setInteger(MediaFormat.KEY_BIT_RATE, if (channels == 1) 96_000 else 192_000)
setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, 16 * 1024)
}
configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
start()
}
private val info = MediaCodec.BufferInfo()
private var samplesIn = 0L
/** Encodes [size] bytes of 16-bit PCM and returns the AAC frames that came out, oldest first. */
fun encode(pcm: ByteArray, size: Int): List<ByteArray> {
var offset = 0
val out = ArrayList<ByteArray>()
while (offset < size) {
val index = codec.dequeueInputBuffer(INPUT_WAIT_US)
if (index < 0) {
drain(out)
continue
}
val buffer = codec.getInputBuffer(index) ?: continue
val n = minOf(buffer.remaining(), size - offset)
buffer.put(pcm, offset, n)
val presentationUs = samplesIn * 1_000_000L / sampleRate
codec.queueInputBuffer(index, 0, n, presentationUs, 0)
samplesIn += n / (2 * channels)
offset += n
drain(out)
}
return out
}
private fun drain(out: MutableList<ByteArray>) {
while (true) {
val index = codec.dequeueOutputBuffer(info, 0)
if (index < 0) return
if (info.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG == 0 && info.size > 0) {
val buffer = codec.getOutputBuffer(index)
if (buffer != null) {
val frame = ByteArray(info.size)
buffer.position(info.offset)
buffer.get(frame)
out.add(if (adts) adtsHeader(frame.size) + frame else frame)
}
}
codec.releaseOutputBuffer(index, false)
}
}
/** ADTS for one AAC-LC frame: sync word, no CRC, and the frame's length including this header. */
private fun adtsHeader(payload: Int): ByteArray {
val length = payload + 7
val freq = FREQUENCIES.indexOf(sampleRate).let { if (it < 0) 3 else it }
val profile = 1 // AAC-LC is object type 2, written as type minus one
return byteArrayOf(
0xFF.toByte(),
0xF1.toByte(),
((profile shl 6) or (freq shl 2) or (channels shr 2)).toByte(),
(((channels and 3) shl 6) or (length shr 11)).toByte(),
((length shr 3) and 0xFF).toByte(),
(((length and 7) shl 5) or 0x1F).toByte(),
0xFC.toByte(),
)
}
fun release() {
runCatching { codec.stop() }
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)
}
}
@@ -16,6 +16,7 @@ import android.media.AudioRecord
import android.media.projection.MediaProjection import android.media.projection.MediaProjection
import android.media.projection.MediaProjectionManager import android.media.projection.MediaProjectionManager
import android.os.IBinder import android.os.IBinder
import android.os.Process
import app.seb3thehacker.gearslip.GearslipLog import app.seb3thehacker.gearslip.GearslipLog
import kotlin.math.abs import kotlin.math.abs
@@ -147,6 +148,9 @@ class AudioCaptureService : Service() {
reading = true reading = true
thread = Thread({ thread = Thread({
// A busy phone (a map app drawing, the video encoder) would otherwise starve this
// loop, and the car hears every late read as a skip.
Process.setThreadPriority(Process.THREAD_PRIORITY_URGENT_AUDIO)
val buffer = ByteArray(CHUNK_BYTES) val buffer = ByteArray(CHUNK_BYTES)
var peak = 0 var peak = 0
var heardAt = System.currentTimeMillis() var heardAt = System.currentTimeMillis()
@@ -4,6 +4,8 @@ import app.seb3thehacker.gearslip.GearslipLog
import app.seb3thehacker.gearslip.Protobuf import app.seb3thehacker.gearslip.Protobuf
import app.seb3thehacker.gearslip.ServiceDiscovery import app.seb3thehacker.gearslip.ServiceDiscovery
import app.seb3thehacker.gearslip.Wire import app.seb3thehacker.gearslip.Wire
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
/** /**
* The phone end of the head unit's media audio sink, the audio twin of the video channel: * The phone end of the head unit's media audio sink, the audio twin of the video channel:
@@ -22,6 +24,18 @@ class AudioLink(
private val service: ServiceDiscovery.AudioService, private val service: ServiceDiscovery.AudioService,
private val sendOnChannel: (messageId: Int, body: ByteArray) -> Unit, private val sendOnChannel: (messageId: Int, body: ByteArray) -> Unit,
private val sendControl: (messageId: Int, body: ByteArray) -> Unit, private val sendControl: (messageId: Int, body: ByteArray) -> Unit,
/** 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 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) },
/** The car took the sound back after giving it, as when the driver switches to the radio. */
private val onTakenBack: () -> Unit = {},
) : CarAudio.Link { ) : CarAudio.Link {
private var configIndex = 0 private var configIndex = 0
@@ -33,11 +47,15 @@ class AudioLink(
// The Uconnect asks for 4 on its video channel; assume the same until its Config says otherwise. // The Uconnect asks for 4 on its video channel; assume the same until its Config says otherwise.
@Volatile private var maxUnacked = 4 @Volatile private var maxUnacked = 4
@Volatile private var lastAckAt = 0L @Volatile private var lastAckAt = 0L
private var stalls = 0
private var chunks = 0 private var chunks = 0
private var bytes = 0L private var bytes = 0L
private var dropped = 0 private var dropped = 0
@Volatile private var configTimer: Thread? = null @Volatile private var configTimer: Thread? = null
private val ackLock = Object() private val ackLock = Object()
/** Only for a sink that takes AAC and nothing else; PCM sinks get the capture as it is. */
@Volatile private var aac: AacEncoder? = null
private val aacSink get() = service.codecType == ServiceDiscovery.CODEC_AAC_LC || service.codecType == ServiceDiscovery.CODEC_AAC_LC_ADTS
private val config get() = service.configs.getOrNull(configIndex) ?: service.configs.firstOrNull() private val config get() = service.configs.getOrNull(configIndex) ?: service.configs.firstOrNull()
override val sampleRate get() = config?.sampleRate ?: 48_000 override val sampleRate get() = config?.sampleRate ?: 48_000
@@ -45,29 +63,71 @@ class AudioLink(
val channelId get() = service.serviceId val channelId get() = service.serviceId
/** Sending audio now, which for media means the car has handed us the speakers. */
val streaming get() = started
@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) { fun opened(status: Int) {
GearslipLog.i("audio: <- ChannelOpenResponse (media audio): status=$status") GearslipLog.i("$tag: <- ChannelOpenResponse: status=$status")
if (status != 0) { if (status != 0) {
GearslipLog.w("audio: the head unit refused the media audio channel (status $status)") GearslipLog.w("$tag: the head unit refused the $tag channel (status $status)")
return return
} }
// AVChannelSetupRequest { required uint32 config_index = 1 }. This field is an index into // Setup { required MediaCodecType type = 1 }: the codec the car advertised for this sink
// the sink's own audio_configs, not a codec id - sending the codec type asks for a config // (aasdk's current protos, and LIVI reads it as the codec). Gearslip used to send 0 here,
// the sink does not have, and it answers with a status that never reaches READY. // read from an older proto as a config index; 0 is no codec at all, and the 2024 VW
sendOnChannel(MSG_SETUP, Protobuf.varintField(1, 0L)) // answered UNEXPECTED_MESSAGE (id 255), never acked a chunk and played nothing.
GearslipLog.i("audio: -> Setup(config_index=0) on channel ${service.serviceId}, codec ${service.codecName}") sendOnChannel(MSG_SETUP, Protobuf.varintField(1, service.codecType.toLong()))
GearslipLog.i("$tag: -> Setup(codec=${service.codecName}) on channel ${service.serviceId}")
armConfigFallback() armConfigFallback()
} }
/** /**
* The 2018 Uconnect answers our Setup with a two-byte message we cannot identify (id 255, no * The 2018 Uconnect, a Dacia ULC 4.5 and the 2024 VW answered the old Setup (codec 0) with
* body) rather than the AVChannelSetupResponse the video channel gets, so the sink never * UNEXPECTED_MESSAGE (id 255, no body) rather than a Config, so the sink never reached READY.
* reaches READY and no audio is ever sent. * [MSG_SETUP_ANSWER] still takes that as the go-ahead, in case a car rejects the real codec too.
* *
* Service discovery already told us everything that message would have: the sink is PCM at * Service discovery already told us everything a Config would have: the sink's rate, channels
* 48000Hz stereo and it has exactly one configuration. So rather than wait forever for a * and its one configuration. So for a car that answers nothing at all, wait a moment and then
* message that is not coming, assume the advertised configuration and carry on. If that guess * assume the advertised configuration. If that guess is wrong the head unit simply will not
* is wrong the head unit simply will not ack, which the write path already reports. * ack, which the write path already reports.
*/ */
private fun armConfigFallback() { private fun armConfigFallback() {
val timer = Thread({ val timer = Thread({
@@ -76,19 +136,25 @@ class AudioLink(
} catch (e: InterruptedException) { } catch (e: InterruptedException) {
return@Thread return@Thread
} }
if (ready) return@Thread useAdvertisedSink("no usable Config after ${CONFIG_WAIT_MS}ms")
GearslipLog.w(
"audio: no usable Config after ${CONFIG_WAIT_MS}ms - falling back to the sink the " +
"head unit advertised (${sampleRate}Hz x$channels, config_index=0, max_unacked=$maxUnacked)",
)
configIndex = 0
ready = true
CarAudio.attach(this)
}, "gearslip-audio-config").apply { isDaemon = true } }, "gearslip-audio-config").apply { isDaemon = true }
configTimer = timer configTimer = timer
timer.start() timer.start()
} }
@Synchronized
private fun useAdvertisedSink(why: String) {
if (ready) return
configTimer?.interrupt()
GearslipLog.i(
"$tag: $why - using the sink the head unit advertised " +
"(${sampleRate}Hz x$channels, config_index=0, max_unacked=$maxUnacked)",
)
configIndex = 0
ready = true
onReady(this)
}
fun onMessage(messageId: Int, body: ByteArray) { fun onMessage(messageId: Int, body: ByteArray) {
when (messageId) { when (messageId) {
MSG_CHANNEL_OPEN_RESPONSE -> opened(Protobuf.readInt32Field(body, 1) ?: 0) MSG_CHANNEL_OPEN_RESPONSE -> opened(Protobuf.readInt32Field(body, 1) ?: 0)
@@ -98,17 +164,17 @@ class AudioLink(
val status = Wire.varint(fields, 1)?.toInt() val status = Wire.varint(fields, 1)?.toInt()
val window = Wire.varint(fields, 2)?.toInt() val window = Wire.varint(fields, 2)?.toInt()
val indices = fields.filter { it.number == 3 && it.wireType == 0 }.map { it.varint.toInt() } val indices = fields.filter { it.number == 3 && it.wireType == 0 }.map { it.varint.toInt() }
GearslipLog.i("audio: <- Config status=$status max_unacked=$window indices=$indices") GearslipLog.i("$tag: <- Config status=$status max_unacked=$window indices=$indices")
if (window != null) maxUnacked = maxOf(1, window) if (window != null) maxUnacked = maxOf(1, window)
configIndex = indices.firstOrNull() ?: 0 configIndex = indices.firstOrNull() ?: 0
if (status == STATUS_READY) { if (status == STATUS_READY) {
configTimer?.interrupt() configTimer?.interrupt()
ready = true ready = true
GearslipLog.i("audio: sink ready - ${sampleRate}Hz x$channels ${config?.bits}bit") GearslipLog.i("$tag: sink ready - ${sampleRate}Hz x$channels ${config?.bits}bit")
CarAudio.attach(this) onReady(this)
} else { } else {
GearslipLog.w( GearslipLog.w(
"audio: the sink is not ready (status=$status, 1=WAIT 2=READY) - " + "$tag: the sink is not ready (status=$status, 1=WAIT 2=READY) - " +
"nothing will be sent until it is", "nothing will be sent until it is",
) )
} }
@@ -123,9 +189,11 @@ class AudioLink(
} }
} }
// Whatever the head unit answers Setup with, we need its bytes to identify it. // Whatever else the head unit answers Setup with, we need its bytes to identify it.
else -> { else -> if (messageId == MSG_SETUP_ANSWER && body.isEmpty()) {
GearslipLog.i("audio: <- unhandled message id=$messageId (${body.size} bytes)") useAdvertisedSink("<- empty Setup answer (id $messageId)")
} else {
GearslipLog.i("$tag: <- unhandled message id=$messageId (${body.size} bytes)")
GearslipLog.hex(" audio msg", body, limit = 64) GearslipLog.hex(" audio msg", body, limit = 64)
} }
} }
@@ -138,52 +206,105 @@ class AudioLink(
*/ */
fun onFocus(state: Int) { fun onFocus(state: Int) {
val name = FOCUS_STATES[state] ?: "unknown($state)" val name = FOCUS_STATES[state] ?: "unknown($state)"
GearslipLog.i("audio: <- AudioFocusResponse state=$name") GearslipLog.i("$tag: <- AudioFocusResponse state=$name")
focusGranted = state == FOCUS_STATE_GAIN || state == FOCUS_STATE_GAIN_TRANSIENT 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
if (focusGranted) startStream() else stop() if (focusGranted) startStream() else stop()
// A ducking request leaves the music playing; a loss means another source has the speakers.
if (had && (state == FOCUS_STATE_LOSS || state == FOCUS_STATE_LOSS_TRANSIENT)) onTakenBack()
} }
override fun start() { override fun start() {
if (!ready || started) return if (!ready || started) return
if (!focusRequested) { if (asksFocus && !focusRequested) {
focusRequested = true focusRequested = true
sendControl(MSG_FOCUS_REQUEST, Protobuf.varintField(1, FOCUS_GAIN.toLong())) sendControl(MSG_FOCUS_REQUEST, Protobuf.varintField(1, FOCUS_GAIN.toLong()))
GearslipLog.i("audio: -> AudioFocusRequest(GAIN); waiting for the head unit to hand the sink over") GearslipLog.i("$tag: -> AudioFocusRequest(GAIN); waiting for the head unit to hand the sink over")
} }
// Some head units grant the sink silently and never answer. Do not let that be the end of // Some head units grant the sink silently and never answer. Do not let that be the end of
// it: start anyway, and let the acks say whether anything is being taken. // it: start anyway, and let the acks say whether anything is being taken.
startStream() startStream()
} }
/**
* Guards starting and stopping. [start] starts the stream at once for head units that never
* answer the focus request, and the car's answer arrives on another thread and starts it
* too: without this both got through, so the car got Start twice and two AAC encoders, and a
* 2014 head unit dropped the connection on the first audio.
*/
private val startLock = Any()
private fun startStream() { private fun startStream() {
synchronized(startLock) { startStreamLocked() }
}
private fun startStreamLocked() {
if (!ready || started) return if (!ready || started) return
sendOnChannel( sendOnChannel(
MSG_START, MSG_START,
Protobuf.varintField(1, SESSION_ID.toLong()) + Protobuf.varintField(2, configIndex.toLong()), Protobuf.varintField(1, sessionId.toLong()) + Protobuf.varintField(2, configIndex.toLong()),
) )
inFlight = 0 inFlight = 0
lastAckAt = System.currentTimeMillis() lastAckAt = System.currentTimeMillis()
if (aacSink && aac == null) {
aac = runCatching { AacEncoder(sampleRate, channels, adts = service.codecType == ServiceDiscovery.CODEC_AAC_LC_ADTS) }
.onSuccess { GearslipLog.i("$tag: the car takes ${service.codecName} only - encoding to AAC") }
.onFailure { GearslipLog.e("$tag: couldn't start the AAC encoder", it) }
.getOrNull()
}
started = true started = true
GearslipLog.i("audio: -> Start(session=$SESSION_ID, config=$configIndex) - streaming ${sampleRate}Hz x$channels") GearslipLog.i("$tag: -> Start(session=$sessionId, config=$configIndex) - streaming ${sampleRate}Hz x$channels")
} }
override fun stop() { override fun stop() {
synchronized(startLock) { stopLocked() }
}
private fun stopLocked() {
if (!started) return if (!started) return
started = false started = false
synchronized(ackLock) { ackLock.notifyAll() } synchronized(ackLock) { ackLock.notifyAll() }
runCatching { runCatching {
sendOnChannel(MSG_STOP, Protobuf.varintField(1, SESSION_ID.toLong())) sendOnChannel(MSG_STOP, Protobuf.varintField(1, sessionId.toLong()))
if (focusRequested) sendControl(MSG_FOCUS_REQUEST, Protobuf.varintField(1, FOCUS_RELEASE.toLong())) if (focusRequested) sendControl(MSG_FOCUS_REQUEST, Protobuf.varintField(1, FOCUS_RELEASE.toLong()))
} }
focusRequested = false focusRequested = false
focusGranted = false focusGranted = false
GearslipLog.i("audio: -> Stop, AudioFocusRequest(RELEASE) after $chunks chunks / $bytes bytes") aac?.release()
aac = null
GearslipLog.i("$tag: -> Stop, AudioFocusRequest(RELEASE) after $chunks chunks / $bytes bytes")
chunks = 0 chunks = 0
bytes = 0 bytes = 0
} }
override fun write(pcm: ByteArray, size: Int) { override fun write(pcm: ByteArray, size: Int) {
if (!started) return if (!started) return
val encoder = aac
if (encoder == null) {
send(pcm.copyOf(size))
return
}
val frames = try {
encoder.encode(pcm, size)
} catch (e: Exception) {
// A codec that dies mid-drive leaves the car silent, as it was before AAC; log it once.
GearslipLog.e("$tag: AAC encoding failed - no more audio this drive", e)
aac = null
started = false
return
}
frames.forEach(::send)
}
/** One Data message, once the car has room for it. */
private fun send(payload: ByteArray) {
// Wait for the car to catch up rather than throw the chunk away: a dropped chunk is a gap // Wait for the car to catch up rather than throw the chunk away: a dropped chunk is a gap
// the driver hears, while a short wait is soaked up by the capture buffer behind us. // the driver hears, while a short wait is soaked up by the capture buffer behind us.
synchronized(ackLock) { synchronized(ackLock) {
@@ -191,7 +312,11 @@ class AudioLink(
val waited = System.currentTimeMillis() - lastAckAt val waited = System.currentTimeMillis() - lastAckAt
if (waited > STALL_MS) { if (waited > STALL_MS) {
// A lost ack must not silence the stream for good; after a stall assume the window cleared. // A lost ack must not silence the stream for good; after a stall assume the window cleared.
GearslipLog.w("audio: no ack for ${STALL_MS}ms with $inFlight in flight - reopening the window") // A car that never acks (the 2024 VW, the Bolt) hits this every chunk: say so
// once, then now and then, so the rest of the log stays readable.
if (stalls++ % 500 == 0) {
GearslipLog.w("$tag: no ack for ${STALL_MS}ms with $inFlight in flight - reopening the window ($stalls times)")
}
inFlight = 0 inFlight = 0
break break
} }
@@ -206,20 +331,20 @@ class AudioLink(
val micros = System.nanoTime() / 1_000 val micros = System.nanoTime() / 1_000
for (i in 0 until 8) timestamp[i] = ((micros shr ((7 - i) * 8)) and 0xFF).toByte() for (i in 0 until 8) timestamp[i] = ((micros shr ((7 - i) * 8)) and 0xFF).toByte()
try { try {
sendOnChannel(MSG_DATA, timestamp + pcm.copyOf(size)) sendOnChannel(MSG_DATA, timestamp + payload)
} catch (e: java.io.IOException) { } catch (e: java.io.IOException) {
GearslipLog.e("audio: the link died mid-stream", e) GearslipLog.e("$tag: the link died mid-stream", e)
started = false started = false
return return
} }
synchronized(ackLock) { inFlight++ } synchronized(ackLock) { inFlight++ }
chunks++ chunks++
bytes += size bytes += payload.size
// The first chunk proves the whole path; after that, an occasional line is enough to show // The first chunk proves the whole path; after that, an occasional line is enough to show
// the car is still taking audio and to catch a window that has quietly closed. // the car is still taking audio and to catch a window that has quietly closed.
if (chunks == 1 || chunks % CHUNK_LOG_EVERY == 0) { if (chunks == 1 || chunks % CHUNK_LOG_EVERY == 0) {
GearslipLog.i( GearslipLog.i(
"audio: -> $chunks chunks / $bytes bytes, inFlight=$inFlight/$maxUnacked, " + "$tag: -> $chunks chunks / $bytes bytes, inFlight=$inFlight/$maxUnacked, " +
"dropped=$dropped, focus=${if (focusGranted) "granted" else "not granted"}", "dropped=$dropped, focus=${if (focusGranted) "granted" else "not granted"}",
) )
} }
@@ -229,8 +354,9 @@ class AudioLink(
configTimer?.interrupt() configTimer?.interrupt()
configTimer = null configTimer = null
stop() stop()
handBackSpeakers()
ready = false ready = false
CarAudio.detach(this) onClose(this)
} }
companion object { companion object {
@@ -243,18 +369,26 @@ class AudioLink(
private const val MSG_STOP = 32770 private const val MSG_STOP = 32770
private const val MSG_CONFIG = 32771 private const val MSG_CONFIG = 32771
private const val MSG_ACK = 32772 private const val MSG_ACK = 32772
private const val MSG_SETUP_ANSWER = 255
private const val STATUS_READY = 2 private const val STATUS_READY = 2
private const val FOCUS_GAIN = 1 private const val FOCUS_GAIN = 1
private const val FOCUS_GAIN_TRANSIENT_MAY_DUCK = 3
private const val FOCUS_RELEASE = 4 private const val FOCUS_RELEASE = 4
private const val FOCUS_WAIT_MS = 500L
private const val FOCUS_STATE_GAIN = 1 private const val FOCUS_STATE_GAIN = 1
private const val FOCUS_STATE_GAIN_TRANSIENT = 2 private const val FOCUS_STATE_GAIN_TRANSIENT = 2
private const val FOCUS_STATE_GAIN_MEDIA_ONLY = 6
private const val FOCUS_STATE_LOSS = 3
private const val FOCUS_STATE_LOSS_TRANSIENT = 5
private const val SESSION_ID = 2 private const val SESSION_ID = 2
private const val STALL_MS = 2_000L private const val STALL_MS = 2_000L
private const val CHUNK_LOG_EVERY = 200 // about every four seconds private const val CHUNK_LOG_EVERY = 200 // about every four seconds
private const val CONFIG_WAIT_MS = 2_500L private const val CONFIG_WAIT_MS = 1_000L
// AudioFocusStateNotification.AudioFocusState in aasdk.
private val FOCUS_STATES = mapOf( private val FOCUS_STATES = mapOf(
1 to "GAIN", 2 to "GAIN_TRANSIENT", 3 to "LOSS", 4 to "LOSS_TRANSIENT", 1 to "GAIN", 2 to "GAIN_TRANSIENT", 3 to "LOSS", 4 to "LOSS_TRANSIENT_CAN_DUCK",
5 to "LOSS_TRANSIENT", 6 to "GAIN_MEDIA_ONLY", 7 to "GAIN_TRANSIENT_GUIDANCE_ONLY",
) )
} }
} }
@@ -44,6 +44,9 @@ object CarAudio {
@Volatile private var link: Link? = null @Volatile private var link: Link? = null
/** Opens the car's media channel, left closed while music went over Bluetooth; null once open. */
@Volatile var openSink: (() -> Unit)? = null
/** Rate and channel count the capture should use: the car's, or a sensible default. */ /** Rate and channel count the capture should use: the car's, or a sensible default. */
val sampleRate get() = link?.sampleRate ?: 48_000 val sampleRate get() = link?.sampleRate ?: 48_000
val channels get() = link?.channels ?: 2 val channels get() = link?.channels ?: 2
@@ -71,6 +74,7 @@ object CarAudio {
when (_capture.value) { when (_capture.value) {
Capture.ON, Capture.ASKING, Capture.DENIED -> return Capture.ON, Capture.ASKING, Capture.DENIED -> return
Capture.OFF -> if (link == null) { Capture.OFF -> if (link == null) {
openSink?.invoke()
GearslipLog.i("audio: waiting for the car to offer an audio sink") GearslipLog.i("audio: waiting for the car to offer an audio sink")
} else { } else {
ask() ask()
@@ -100,6 +104,26 @@ object CarAudio {
_capture.value = Capture.DENIED _capture.value = Capture.DENIED
} }
private val _micBlocked = MutableStateFlow(false)
/** The microphone permission is off for good: Android refuses without asking, so say why. */
val micBlocked: StateFlow<Boolean> = _micBlocked.asStateFlow()
fun micBlocked() {
_micBlocked.value = true
denied()
}
/** The driver turned the permission back on mid-drive: ask for capture again. */
fun micAllowed() {
if (!_micBlocked.value) return
_micBlocked.value = false
if (_capture.value == Capture.DENIED) {
_capture.value = Capture.OFF
if (wanted) request()
}
}
internal fun captureStarted() { internal fun captureStarted() {
_capture.value = Capture.ON _capture.value = Capture.ON
link?.start() link?.start()
@@ -12,17 +12,21 @@ import java.util.concurrent.TimeUnit
* *
* -> CHANNEL_OPEN_REQUEST <- CHANNEL_OPEN_RESPONSE * -> CHANNEL_OPEN_REQUEST <- CHANNEL_OPEN_RESPONSE
* -> Setup { config_index } <- Config * -> Setup { config_index } <- Config
* -> VoiceSessionNotification { START } (control channel)
* -> MicrophoneRequest { open } <- MicrophoneResponse { status, session_id } * -> MicrophoneRequest { open } <- MicrophoneResponse { status, session_id }
* <- Data { 8-byte timestamp, PCM }... -> Ack { session_id, ack } * <- Data { 8-byte timestamp, PCM }... -> Ack { session_id, ack }
* -> MicrophoneRequest { open = false } * -> MicrophoneRequest { open = false }
* -> VoiceSessionNotification { END }
* *
* The channel opens with the session, but the car only records between [start] and [stop]. * The channel opens with the session, but the car only records between [start] and [stop], and
* only inside a voice session: the 2024 VW answered a bare MicrophoneRequest with status -7.
* Speech wants 16 kHz mono; a car that records at a multiple of that is averaged down to it, and * Speech wants 16 kHz mono; a car that records at a multiple of that is averaged down to it, and
* one that doesn't isn't used, so voice falls back to the phone's microphone. * one that doesn't isn't used, so voice falls back to the phone's microphone.
*/ */
class CarMic( class CarMic(
private val service: ServiceDiscovery.AudioService, private val service: ServiceDiscovery.AudioService,
private val sendOnChannel: (messageId: Int, body: ByteArray) -> Unit, private val sendOnChannel: (messageId: Int, body: ByteArray) -> Unit,
private val voiceSession: (start: Boolean) -> Unit,
) { ) {
val channelId get() = service.serviceId val channelId get() = service.serviceId
@@ -43,7 +47,8 @@ class CarMic(
val status = Protobuf.readInt32Field(body, 1) ?: 0 val status = Protobuf.readInt32Field(body, 1) ?: 0
GearslipLog.i("mic: <- ChannelOpenResponse: status=$status") GearslipLog.i("mic: <- ChannelOpenResponse: status=$status")
if (status != 0) return if (status != 0) return
sendOnChannel(MSG_SETUP, Protobuf.varintField(1, 0L)) // Setup { type = the codec }, as for audio: 0 got UNEXPECTED_MESSAGE from the VW.
sendOnChannel(MSG_SETUP, Protobuf.varintField(1, service.codecType.toLong()))
ready = usable ready = usable
if (!usable) { if (!usable) {
GearslipLog.w( GearslipLog.w(
@@ -97,6 +102,7 @@ class CarMic(
chunks.clear() chunks.clear()
packets = 0 packets = 0
listening = true listening = true
voiceSession(true)
sendOnChannel(MSG_MIC_REQUEST, Protobuf.varintField(1, 1L) + Protobuf.varintField(4, 1L)) sendOnChannel(MSG_MIC_REQUEST, Protobuf.varintField(1, 1L) + Protobuf.varintField(4, 1L))
GearslipLog.i("mic: -> MicrophoneRequest(open)") GearslipLog.i("mic: -> MicrophoneRequest(open)")
return true return true
@@ -109,7 +115,8 @@ class CarMic(
if (!listening) return if (!listening) return
listening = false listening = false
sendOnChannel(MSG_MIC_REQUEST, Protobuf.varintField(1, 0L)) sendOnChannel(MSG_MIC_REQUEST, Protobuf.varintField(1, 0L))
GearslipLog.i("mic: -> MicrophoneRequest(close) after $packets packets") voiceSession(false)
GearslipLog.i("mic: -> MicrophoneRequest(close) after $packets packets, voice session ended")
} }
companion object { companion object {
@@ -23,19 +23,19 @@ data class Caller(val number: String, val name: String?) {
sealed interface CallUiState { sealed interface CallUiState {
data object None : CallUiState data object None : CallUiState
data class Ringing(val caller: Caller, val canDecline: Boolean) : CallUiState data class Ringing(val caller: Caller, val canDecline: Boolean) : CallUiState
data class Active(val caller: Caller?) : CallUiState /** [since] is the uptime the call went off-hook, for the timer. */
data class Active(val caller: Caller?, val since: Long) : CallUiState
} }
/** /**
* Tracks the phone's call state for the car screen, and answers or declines through the two * Tracks the phone's call state for the car screen, and answers or declines through the
* doors a sideloaded app can use: [android.telecom.TelecomManager.acceptRingingCall] to answer, * doors a sideloaded app can use: [android.telecom.TelecomManager.acceptRingingCall] to answer,
* and [GearslipCallScreening]'s response to decline one that is still ringing. * [GearslipCallScreening]'s response to decline one that is still ringing, and
* [android.telecom.TelecomManager.endCall] to hang up, which needs only ANSWER_PHONE_CALLS for an
* ordinary carrier call. Mute is the phone's microphone mute, put back when the call ends.
* *
* Ending a call already answered needs MODIFY_PHONE_STATE, a privileged permission Gearslip * Call audio stays on the phone's normal voice path; nothing here sends it over the USB link,
* cannot hold, so a call taken here is finished the way it would be without Gearslip at all - * which only ever carries media playback.
* from the phone itself, a paired headset, or the car's own Bluetooth if it has any. Call audio
* stays on the phone's normal voice path too; nothing here sends it over the USB link, which
* only ever carries media playback.
*/ */
object CarCalls { object CarCalls {
@@ -47,9 +47,14 @@ object CarCalls {
private var pendingRespond: ((allow: Boolean) -> Unit)? = null private var pendingRespond: ((allow: Boolean) -> Unit)? = null
private var ringingCaller: Caller? = null private var ringingCaller: Caller? = null
private val _muted = MutableStateFlow(false)
val muted: StateFlow<Boolean> = _muted.asStateFlow()
private var audio: android.media.AudioManager? = null
fun start(context: Context) { fun start(context: Context) {
if (watcher != null) return if (watcher != null) return
if (context.checkSelfPermission(Manifest.permission.READ_PHONE_STATE) != PackageManager.PERMISSION_GRANTED) return if (context.checkSelfPermission(Manifest.permission.READ_PHONE_STATE) != PackageManager.PERMISSION_GRANTED) return
audio = context.getSystemService(android.media.AudioManager::class.java)
val manager = context.getSystemService(TelephonyManager::class.java) ?: return val manager = context.getSystemService(TelephonyManager::class.java) ?: return
val callback = object : TelephonyCallback(), TelephonyCallback.CallStateListener { val callback = object : TelephonyCallback(), TelephonyCallback.CallStateListener {
override fun onCallStateChanged(state: Int) = onPhoneState(state) override fun onCallStateChanged(state: Int) = onPhoneState(state)
@@ -66,6 +71,7 @@ object CarCalls {
watcher = null watcher = null
pendingRespond = null pendingRespond = null
ringingCaller = null ringingCaller = null
unmute()
_state.value = CallUiState.None _state.value = CallUiState.None
} }
@@ -74,11 +80,15 @@ object CarCalls {
TelephonyManager.CALL_STATE_IDLE -> { TelephonyManager.CALL_STATE_IDLE -> {
pendingRespond = null pendingRespond = null
ringingCaller = null ringingCaller = null
// A mute left on would silence the microphone for everything after the call.
unmute()
_state.value = CallUiState.None _state.value = CallUiState.None
} }
TelephonyManager.CALL_STATE_OFFHOOK -> { TelephonyManager.CALL_STATE_OFFHOOK -> {
pendingRespond = null pendingRespond = null
_state.value = CallUiState.Active(ringingCaller) if (_state.value !is CallUiState.Active) {
_state.value = CallUiState.Active(ringingCaller, android.os.SystemClock.uptimeMillis())
}
} }
TelephonyManager.CALL_STATE_RINGING -> { TelephonyManager.CALL_STATE_RINGING -> {
// The number itself only ever arrives through the screening service; if this // The number itself only ever arrives through the screening service; if this
@@ -109,6 +119,39 @@ object CarCalls {
runCatching { respond(false) }.onFailure { GearslipLog.w("calls: decline failed: ${it.message}") } runCatching { respond(false) }.onFailure { GearslipLog.w("calls: decline failed: ${it.message}") }
} }
/** A call Gearslip itself is placing, so the call pill can say who it's to. */
fun outgoingCall(context: Context, number: String) {
ringingCaller = Caller(number, lookUpName(context, number))
}
fun end(context: Context) {
if (context.checkSelfPermission(Manifest.permission.ANSWER_PHONE_CALLS) != PackageManager.PERMISSION_GRANTED) {
GearslipLog.w("calls: ending a call needs ANSWER_PHONE_CALLS, which was never granted")
return
}
val telecom = context.getSystemService(TelecomManager::class.java) ?: return
@Suppress("DEPRECATION")
runCatching { telecom.endCall() }
.onSuccess { if (!it) GearslipLog.w("calls: the phone wouldn't end the call") }
.onFailure { GearslipLog.w("calls: end failed: ${it.message}") }
}
fun toggleMute() {
val manager = audio ?: return
val want = !_muted.value
runCatching { manager.isMicrophoneMute = want }
.onFailure { GearslipLog.w("calls: mute failed: ${it.message}") }
// Read back rather than trusted: some phones let the call app overrule it.
_muted.value = manager.isMicrophoneMute
if (_muted.value != want) GearslipLog.w("calls: the phone didn't take the mute change")
}
private fun unmute() {
if (!_muted.value) return
runCatching { audio?.isMicrophoneMute = false }
_muted.value = false
}
fun answer(context: Context) { fun answer(context: Context) {
if (context.checkSelfPermission(Manifest.permission.ANSWER_PHONE_CALLS) != PackageManager.PERMISSION_GRANTED) { if (context.checkSelfPermission(Manifest.permission.ANSWER_PHONE_CALLS) != PackageManager.PERMISSION_GRANTED) {
GearslipLog.w("calls: answer needs ANSWER_PHONE_CALLS, which was never granted") GearslipLog.w("calls: answer needs ANSWER_PHONE_CALLS, which was never granted")
@@ -1,10 +1,14 @@
package app.seb3thehacker.gearslip.car package app.seb3thehacker.gearslip.car
import android.content.Context import android.content.Context
import app.seb3thehacker.gearslip.media.CarMedia
import app.seb3thehacker.gearslip.speech.VoiceFocus
import java.text.DateFormat import java.text.DateFormat
import java.util.Date import java.util.Date
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
/** /**
* Turns what [CarAssistant] heard into an action plus something to say back. * Turns what [CarAssistant] heard into an action plus something to say back.
@@ -17,9 +21,16 @@ import kotlinx.coroutines.withContext
object AssistantCommands { object AssistantCommands {
suspend fun handle(context: Context, navigator: CarNavigator, frame: CarEnvironment.Frame, heardRaw: String): String { suspend fun handle(context: Context, navigator: CarNavigator, frame: CarEnvironment.Frame, heardRaw: String): String {
val heard = heardRaw.lowercase().trim() val heard = natural(heardRaw)
VoiceHelp.hide()
if (heard in HELP_WORDS || heard.startsWith("what can i say") || heard.startsWith("what can you do")) {
VoiceHelp.show()
return "Here's what you can say."
}
if (heard.matches(Regex("(fuck|f\\*+) (you|off)( too)?"))) return "Fuck you too."
navigateCommand(context, navigator, frame, heard)?.let { return it } navigateCommand(context, navigator, frame, heard)?.let { return it }
playSearchCommand(context, navigator, heard)?.let { return it }
mediaCommand(heard)?.let { return it } mediaCommand(heard)?.let { return it }
navCommand(navigator, heard)?.let { return it } navCommand(navigator, heard)?.let { return it }
infoCommand(heard)?.let { return it } infoCommand(heard)?.let { return it }
@@ -29,6 +40,47 @@ object AssistantCommands {
return "I don't know how to do that yet." return "I don't know how to do that yet."
} }
/**
* What was said, minus the padding people wrap a command in: "hey", "can you", "I want to",
* "please", "thanks". Each command below then only has to know its own words.
*/
private fun natural(raw: String): String {
var t = raw.lowercase().replace(Regex("[.,!?]"), " ").replace(Regex("\\s+"), " ").trim()
var changed = true
while (changed) {
changed = false
for (lead in LEAD_FILLERS) if (t.startsWith("$lead ")) { t = t.removePrefix("$lead ").trim(); changed = true }
for (tail in TAIL_FILLERS) if (t.endsWith(" $tail")) { t = t.removeSuffix(" $tail").trim(); changed = true }
}
return t
}
/** The part after the first of [triggers] that [text] starts with, or null. */
private fun after(text: String, triggers: List<String>): String? =
triggers.firstOrNull { text.startsWith(it) }?.let { text.removePrefix(it).trim() }
private suspend fun allEntries(context: Context): List<Entry> {
val vehicleData = CarSettings.experimentalFeaturesEnabled.value && CarSettings.carSensorsEnabled.value
val builtIn = BuiltInApps.entries.filter { it.builtIn !== BuiltInApps.VehicleData || vehicleData }
return builtIn + (LauncherCache.entries ?: withContext(Dispatchers.IO) { LauncherCache.load(context) })
}
/**
* Best label match: exact, then a label starting with [name], then one containing it. Spaces
* and punctuation don't count, since the recognizer may hear "MapQuest" as "map quest".
*/
private fun <T> bestMatch(items: List<T>, name: String, label: (T) -> String): T? {
val n = squash(name.removePrefix("the ").removePrefix("my ").removeSuffix(" app"))
if (n.isEmpty()) return null
items.firstOrNull { squash(label(it)) == n }?.let { return it }
// Loose matches need a real word: "pizza in a" mustn't pick "Organic Maps" for its "a".
if (n.length < 3) return null
return items.firstOrNull { squash(label(it)).startsWith(n) }
?: items.firstOrNull { squash(label(it)).contains(n) }
}
private fun squash(s: String) = s.lowercase().filter { it.isLetterOrDigit() }
/** /**
* "navigate to X [with|using|on|via Y]" - Y picks the nav app by (partial) label; without it, * "navigate to X [with|using|on|via Y]" - Y picks the nav app by (partial) label; without it,
* whichever nav app was used last, or the only one installed, or the first one found. Starts * whichever nav app was used last, or the only one installed, or the first one found. Starts
@@ -42,29 +94,49 @@ object AssistantCommands {
frame: CarEnvironment.Frame, frame: CarEnvironment.Frame,
heard: String, heard: String,
): String? { ): String? {
val trigger = NAVIGATE_TRIGGERS.firstOrNull { heard.startsWith(it) } ?: return null // "use Organic Maps to get to X", "open OsmAnd and navigate to X": the app comes first.
var destination = heard.removePrefix(trigger).trim() var text = 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) {
leadingApp = m.groupValues[1]
text = m.groupValues[2]
}
}
var destination = after(text, NAVIGATE_TRIGGERS) ?: return null
if (destination.isBlank()) return "Where to?" if (destination.isBlank()) return "Where to?"
var appName: String? = null val entries = allEntries(context)
for (sep in listOf(" with ", " using ", " on ", " via ")) { // "go to Spotify" means the app, and "go to settings" the screen; "go to Target" is a place.
val at = destination.lastIndexOf(sep) val asApp = destination.removePrefix("the ").removeSuffix(" app")
if (at > 0) { if (text.startsWith("go to ") &&
appName = destination.substring(at + sep.length).trim() (asApp in SCREEN_WORDS || asApp in APP_NICKNAMES || entries.any { it.label.lowercase() == asApp })
) {
return null
}
val navApps = entries.filter { it.template?.isNavigation == true }
if (navApps.isEmpty()) return "You don't have any navigation apps installed."
// "X with Organic Maps", "X on OsmAnd": split only when the end names a nav app, so a
// place like "Starbucks on Main Street" stays whole.
var chosenApp = leadingApp?.let { name -> bestMatch(navApps, name) { it.label } ?: return "I couldn't find a navigation app called $name." }
if (chosenApp == null) {
for (sep in listOf(" with ", " using ", " on ", " via ", " in ", " through ")) {
val at = destination.lastIndexOf(sep)
if (at <= 0) continue
val app = bestMatch(navApps, destination.substring(at + sep.length)) { it.label } ?: continue
chosenApp = app
destination = destination.substring(0, at).trim() destination = destination.substring(0, at).trim()
break break
} }
} }
// "the nearest gas station" -> "gas station": the map app already searches around the car
// and AutoRoute picks the nearest result, and Organic Maps took "nearest" as part of the name.
val (place, nearby) = plainPlace(destination)
destination = place
if (destination.isBlank()) return "Where to?" if (destination.isBlank()) return "Where to?"
val entries = LauncherCache.entries ?: withContext(Dispatchers.IO) { LauncherCache.load(context) } val chosen = chosenApp ?: when {
val navApps = entries.filter { it.template?.isNavigation == true }
if (navApps.isEmpty()) return "You don't have any navigation apps installed."
val requestedApp = appName
val chosen = when {
requestedApp != null -> navApps.firstOrNull { it.label.lowercase().contains(requestedApp) }
?: return "I couldn't find a navigation app called $requestedApp."
navApps.size == 1 -> navApps.first() navApps.size == 1 -> navApps.first()
else -> navApps.firstOrNull { it.componentId == CarSettings.lastNav.value } ?: navApps.first() else -> navApps.firstOrNull { it.componentId == CarSettings.lastNav.value } ?: navApps.first()
} }
@@ -72,29 +144,104 @@ object AssistantCommands {
CarServices.connectNav(template, frame, destination) CarServices.connectNav(template, frame, destination)
navigator.home() navigator.home()
return "Navigating to $destination with ${chosen.label}." return if (nearby) "Finding the nearest $destination with ${chosen.label}." else "Navigating to $destination with ${chosen.label}."
}
/** [destination] without "a", "the nearest", "near me" and the like, and whether any of that said "close by". */
private fun plainPlace(destination: String): Pair<String, Boolean> {
var t = destination
var nearby = false
var changed = true
while (changed) {
changed = false
for (lead in PLACE_LEADS) if (t.startsWith(lead)) {
t = t.removePrefix(lead).trim(); changed = true
if (lead.trim() !in ARTICLES) nearby = true
}
for (tail in PLACE_TAILS) if (t.endsWith(tail)) { t = t.removeSuffix(tail).trim(); changed = true; nearby = true }
}
return t to nearby
}
/**
* "play X" with something after it: a song, artist, album or playlist, asked of the music app
* that's open through its own search ([CarMedia.search]). A bare "play", or "play music",
* falls through to resume.
*/
private suspend fun playSearchCommand(context: Context, navigator: CarNavigator, heard: String): String? {
var query = after(heard, PLAY_TRIGGERS) ?: return null
query = query.removePrefix("some ").trim()
if (query.isEmpty() || query in RESUME_WORDS) return null
val media = CarServices.media
// "play Daft Punk on Metrolist": switch to that app first, if it's one we know.
for (sep in listOf(" on ", " in ", " from ", " with ", " using ")) {
val at = query.lastIndexOf(sep)
if (at <= 0) continue
val apps = withContext(Dispatchers.IO) { app.seb3thehacker.gearslip.media.MediaCatalog.installed(context) }
val wanted = bestMatch(apps, query.substring(at + sep.length)) { it.label } ?: continue
query = query.substring(0, at).trim()
if (query.isEmpty() || query in RESUME_WORDS) {
CarServices.openMedia(wanted, autoplay = true)
navigator.media()
return "Opening ${wanted.label}."
}
if (CarServices.mediaApp.value?.component != wanted.component) {
CarServices.openMedia(wanted)
navigator.media()
// Give it a moment to connect before asking it to search.
withTimeoutOrNull(APP_CONNECT_MS) {
while (media.appLabel == null || (!media.canSearchLibrary && !media.now.value.canSearch)) delay(200)
}
}
break
}
// No music app open, or its connection is gone (the app was closed): use the one played
// last, the way "play X" works on Android Auto.
val phase = media.phase.value
if (media.appLabel == null || phase == CarMedia.Phase.IDLE || phase == CarMedia.Phase.REJECTED) {
if (!CarServices.reopenLastMedia()) return "Open a music app first, then ask again."
navigator.media()
}
withTimeoutOrNull(APP_CONNECT_MS) {
while (media.phase.value != CarMedia.Phase.READY || media.appLabel == null ||
(!media.canSearchLibrary && !media.now.value.canSearch)
) delay(200)
}
val app = media.appLabel ?: return "Your music app didn't open in time. Try again."
if (!media.canSearchLibrary && !media.now.value.canSearch) {
return "$app ignores voice searches. Pick something on its screen instead."
}
media.playBestMatch(query)
return "Looking for $query on $app."
} }
private fun mediaCommand(heard: String): String? { private fun mediaCommand(heard: String): String? {
val media = CarServices.media val media = CarServices.media
return when { return when {
"pause" in heard || heard.startsWith("stop") -> { "pause" in heard || heard.startsWith("stop") || heard == "quiet" || heard == "shut up" -> {
if (media.now.value.playing) media.togglePlay() media.pause()
VoiceFocus.afterRelease { media.pause() }
"Paused." "Paused."
} }
"resume" in heard || (heard.startsWith("play") && "playing" !in heard) -> { "resume" in heard || "unpause" in heard || heard in RESUME_PHRASES ||
(heard.startsWith("play") && "playing" !in heard) -> {
if (!media.now.value.playing) media.togglePlay() if (!media.now.value.playing) media.togglePlay()
"Playing." "Playing."
} }
"next" in heard || "skip" in heard -> { "next" in heard || "skip" in heard || heard.startsWith("change the song") -> {
media.next() media.next()
"Skipping." "Skipping."
} }
"previous" in heard || "last song" in heard || "back song" in heard -> { "previous" in heard || "last song" in heard || "back song" in heard || "go back a song" in heard ||
"play that again" in heard || "replay" in heard -> {
media.previous() media.previous()
"Playing the previous track." "Playing the previous track."
} }
"what" in heard && "playing" in heard -> { ("what" in heard && "playing" in heard) || "what song" in heard || "who sings" in heard ||
"who's singing" in heard || "who is singing" in heard || "what's this song" in heard ||
"name of this song" in heard -> {
val now = media.now.value val now = media.now.value
if (now.isActive) listOf(now.title, now.artist).filter { it.isNotBlank() }.joinToString(" by ") if (now.isActive) listOf(now.title, now.artist).filter { it.isNotBlank() }.joinToString(" by ")
else "Nothing's playing." else "Nothing's playing."
@@ -104,11 +251,19 @@ object AssistantCommands {
} }
private fun navCommand(navigator: CarNavigator, heard: String): String? = when { private fun navCommand(navigator: CarNavigator, heard: String): String? = when {
"go home" in heard || heard == "home" -> { heard == "go back" || heard == "back" -> {
navigator.back()
"Going back."
}
heard in EXIT_WORDS -> {
BuiltInApps.Exit.open(navigator)
"Back to the car's screen."
}
"go home" in heard || heard == "home" || heard == "home screen" || "go to the home screen" in heard -> {
navigator.home() navigator.home()
"Heading home." "Heading home."
} }
"open apps" in heard || heard == "apps" || "app list" in heard -> { "open apps" in heard || heard == "apps" || "app list" in heard || "all apps" in heard || "my apps" in heard || heard == "go to apps" -> {
navigator.apps() navigator.apps()
"Here are your apps." "Here are your apps."
} }
@@ -128,9 +283,10 @@ object AssistantCommands {
} }
private fun infoCommand(heard: String): String? = when { private fun infoCommand(heard: String): String? = when {
"what time" in heard || "what's the time" in heard -> "what time" in heard || "what's the time" in heard || heard == "time" || "the time" in heard ->
"It's ${DateFormat.getTimeInstance(DateFormat.SHORT).format(Date())}." "It's ${DateFormat.getTimeInstance(DateFormat.SHORT).format(Date())}."
"weather" in heard -> { "weather" in heard || "going to rain" in heard || "gonna rain" in heard || "how hot" in heard ||
"how cold" in heard || "temperature outside" in heard -> {
val data = (Weather.state.value as? WeatherState.Ready)?.data val data = (Weather.state.value as? WeatherState.Ready)?.data
if (data != null) "${data.temp}${data.tempUnit} and ${Weather.describe(data.code)} in ${data.place}." if (data != null) "${data.temp}${data.tempUnit} and ${Weather.describe(data.code)} in ${data.place}."
else "I don't have the weather yet." else "I don't have the weather yet."
@@ -139,12 +295,11 @@ object AssistantCommands {
} }
private suspend fun callCommand(context: Context, heard: String): String? { private suspend fun callCommand(context: Context, heard: String): String? {
val name = when { val name = (
heard.startsWith("call ") -> heard.removePrefix("call ") after(heard, CALL_TRIGGERS)
heard.startsWith("phone ") -> heard.removePrefix("phone ") ?: Regex("^give (.+) a (?:call|ring)$").find(heard)?.groupValues?.get(1)
heard.startsWith("dial ") -> heard.removePrefix("dial ") ?: return null
else -> return null ).replace(Regex(" (?:on|at) (?:their |his |her )?(?:mobile|cell|phone|home|work)$"), "").trim()
}.trim()
if (name.isBlank()) return "Who should I call?" if (name.isBlank()) return "Who should I call?"
if (!hasCallPermissions(context)) return "Gearslip needs the phone and contacts permissions first, from the phone screen." if (!hasCallPermissions(context)) return "Gearslip needs the phone and contacts permissions first, from the phone screen."
@@ -162,21 +317,69 @@ object AssistantCommands {
frame: CarEnvironment.Frame, frame: CarEnvironment.Frame,
heard: String, heard: String,
): String? { ): String? {
val name = when { val name = (after(heard, OPEN_TRIGGERS) ?: return null)
heard.startsWith("open ") -> heard.removePrefix("open ") .removePrefix("the ").removePrefix("my ").removeSuffix(" app").trim()
heard.startsWith("launch ") -> heard.removePrefix("launch ")
heard.startsWith("start ") -> heard.removePrefix("start ")
else -> return null
}.trim()
if (name.isBlank()) return null if (name.isBlank()) return null
val wanted = APP_NICKNAMES[name] ?: name
val entries = LauncherCache.entries ?: withContext(Dispatchers.IO) { LauncherCache.load(context) } // Gearslip's own apps come first in the list, so "phone" opens its dialer, not the phone's.
val match = entries.firstOrNull { it.label.lowercase().contains(name) } val match = bestMatch(allEntries(context), wanted) { it.label }
?: return "I couldn't find an app called $name." ?: return "I couldn't find an app called $name."
launchEntry(match, navigator, frame) launchEntry(match, navigator, frame)
return "Opening ${match.label}." return "Opening ${match.label}."
} }
private val NAVIGATE_TRIGGERS = listOf("navigate to ", "directions to ", "take me to ", "drive to ") private val EXIT_WORDS = setOf("exit", "exit gearslip", "close gearslip", "quit", "quit gearslip", "open exit")
/** Other names people use for Gearslip's own apps. */
private val APP_NICKNAMES = mapOf(
"web" to "browser", "internet" to "browser", "the web" to "browser", "web browser" to "browser",
"dialer" to "phone", "the dialer" to "phone", "keypad" to "phone",
"screen share" to "screen sharing", "screen mirroring" to "screen sharing", "mirroring" to "screen sharing",
)
private val NAVIGATE_TRIGGERS = listOf(
"navigate to ", "navigate me to ", "directions to ", "get directions to ", "give me directions to ",
"take me to ", "drive to ", "drive me to ", "get me to ", "go to ", "head to ", "route to ",
"how do i get to ", "show me the way to ", "find a route to ", "navigation to ", "get to ",
// Last, so "navigate to" wins: the recognizer sometimes drops the "to".
"navigate me ", "navigate ", "find me the nearest ", "find the nearest ", "find the closest ",
)
private val ARTICLES = setOf("a", "an", "the", "some")
// Longest first, so "the nearest " goes before "the ".
private val PLACE_LEADS = listOf(
"the nearest ", "the closest ", "a nearby ", "the nearby ", "nearest ", "closest ", "nearby ",
"a ", "an ", "some ",
)
private val PLACE_TAILS = listOf(" near me", " nearby", " near here", " around here", " close by", " closest to me")
private val PLAY_TRIGGERS = listOf("play me ", "play ", "put on ", "listen to ", "i want to hear ", "shuffle ")
private val CALL_TRIGGERS = listOf("call ", "phone ", "dial ", "ring ", "make a call to ", "place a call to ", "start a call with ")
private val OPEN_TRIGGERS = listOf("open ", "launch ", "start ", "go to ", "switch to ", "show me ", "bring up ", "pull up ")
/** Screens "go to X" means rather than a place. */
private val SCREEN_WORDS = setOf("settings", "home", "the home screen", "home screen", "apps", "my apps", "all apps")
/** Padding stripped from the front of a command, longest first where they overlap. */
private val LEAD_FILLERS = listOf(
"hey gearslip", "ok gearslip", "okay gearslip", "gearslip", "hey", "ok", "okay", "um", "uh", "so",
"please", "can you please", "can you", "could you", "would you", "will you", "can i", "could i",
"i want you to", "i want to", "i'd like to", "i would like to", "i wanna", "i need to", "i need you to",
"let's", "lets", "go ahead and", "just", "now", "quickly",
)
/** Longest first, so "right now" goes whole rather than leaving "right". */
private val TAIL_FILLERS = listOf("thank you very much", "thank you", "right now", "for me", "please", "thanks", "now")
private const val APP_CONNECT_MS = 5_000L
private val HELP_WORDS = setOf("help", "commands", "voice commands", "list commands", "show commands")
private val RESUME_WORDS = setOf("music", "the music", "some music", "something", "it", "again", "that", "my music")
private val RESUME_PHRASES = setOf("keep playing", "continue", "keep going", "start the music", "music on", "turn the music back on")
} }
@@ -4,7 +4,21 @@ import app.seb3thehacker.gearslip.car.theme.*
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.provider.Settings import android.provider.Settings
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.scale
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
@@ -30,47 +44,66 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
/** /**
* The band that appears while [CarAssistant] is listening, thinking or speaking - a thin strip * What the assistant hears and says, laid over the bottom of whatever screen is up: a dark shade
* across the top, same idea as [CallOverlay]'s active-call banner, so it never blocks the screen * so white words read over a bright map, and a glow along the bottom edge in the phone's own
* the driver was already looking at. * colours that breathes while listening. The shade passes touches through; only the buttons take them.
*/ */
@Composable @Composable
fun AssistantOverlay(modifier: Modifier = Modifier) { fun AssistantOverlay(modifier: Modifier = Modifier) {
val state by CarAssistant.state.collectAsState() val state by CarAssistant.state.collectAsState()
if (state.phase == CarAssistant.Phase.IDLE) return if (state.phase == CarAssistant.Phase.IDLE) return
val context = LocalContext.current val context = LocalContext.current
val listening = state.phase == CarAssistant.Phase.LISTENING && state.ready
val glow = MaterialTheme.colorScheme.primary
val glow2 = MaterialTheme.colorScheme.tertiary
val breathe by rememberInfiniteTransition(label = "glow").animateFloat(
0.55f, 1f, infiniteRepeatable(tween(900, easing = FastOutSlowInEasing), RepeatMode.Reverse), label = "glow",
)
val strength = when (state.phase) {
CarAssistant.Phase.LISTENING -> if (state.ready) breathe else 0.3f
CarAssistant.Phase.ERROR -> 0.3f
else -> 0.6f
}
Surface( Box(
color = MaterialTheme.colorScheme.primaryContainer, modifier
contentColor = MaterialTheme.colorScheme.onPrimaryContainer, .fillMaxWidth()
modifier = modifier.fillMaxWidth(), .drawBehind {
) { drawRect(Brush.verticalGradient(0f to Color.Transparent, 0.45f to Color.Black.copy(alpha = 0.6f), 1f to Color.Black.copy(alpha = 0.85f)))
Row( // A wide, flat oval of light rising from the bottom edge.
Modifier.padding(horizontal = 16.dp, vertical = 10.dp), scale(1f, 0.22f, Offset(size.width / 2, size.height)) {
verticalAlignment = Alignment.CenterVertically, drawCircle(
horizontalArrangement = Arrangement.spacedBy(12.dp), Brush.radialGradient(
) { 0f to glow.copy(alpha = 0.9f * strength), 0.5f to glow2.copy(alpha = 0.35f * strength), 1f to Color.Transparent,
PulsingMic(state.phase == CarAssistant.Phase.LISTENING) center = Offset(size.width / 2, size.height), radius = size.width * 0.55f,
Column(Modifier.weight(1f)) { ),
Text( radius = size.width * 0.55f, center = Offset(size.width / 2, size.height),
when (state.phase) {
CarAssistant.Phase.LISTENING -> "Listening…"
CarAssistant.Phase.THINKING -> "…"
CarAssistant.Phase.SPEAKING -> state.reply
CarAssistant.Phase.ERROR -> state.reply
CarAssistant.Phase.IDLE -> ""
},
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
)
if (state.heard.isNotBlank() && state.phase != CarAssistant.Phase.SPEAKING) {
Text(
"\"${state.heard}\"",
style = MaterialTheme.typography.bodySmall,
) )
} }
} }
.padding(start = 16.dp, end = 16.dp, top = 56.dp, bottom = 20.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp)) {
PulsingMic(listening)
Column(Modifier.weight(1f), horizontalAlignment = Alignment.CenterHorizontally) {
val (small, big) = when (state.phase) {
CarAssistant.Phase.LISTENING -> "" to state.heard.ifBlank { if (state.ready) "Listening…" else "One moment…" }
CarAssistant.Phase.THINKING -> "" to state.heard.ifBlank { "…" }
else -> state.heard to state.reply
}
if (small.isNotBlank()) {
Text(small, color = Color.White.copy(alpha = 0.7f), style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
Text(
big,
color = if (big == "Listening…" || big == "One moment…" || big == "…") Color.White.copy(alpha = 0.7f) else Color.White,
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Center,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
if (state.missingPermission) { if (state.missingPermission) {
GsButton(tone = GsTone.Tonal, onClick = { GsButton(tone = GsTone.Tonal, onClick = {
context.startActivity( context.startActivity(
@@ -0,0 +1,146 @@
package app.seb3thehacker.gearslip.car
import android.Manifest
import android.annotation.SuppressLint
import android.bluetooth.BluetoothA2dp
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothProfile
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.os.Handler
import android.os.Looper
import app.seb3thehacker.gearslip.GearslipLog
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* With Bluetooth audio chosen, music only reaches the car if the phone is connected to it over
* Bluetooth. Twenty seconds after the car shows Gearslip, long enough for a car to connect on its
* own, this checks. If the phone isn't connected, the car and the phone's Home screen both say so,
* until it connects or the drive ends.
*
* When the car named its Bluetooth address, only that device counts. Otherwise any audio
* connection does, since there's no telling which device is the car.
*/
object BluetoothAudioCheck {
private val _missing = MutableStateFlow(false)
/** True while Bluetooth audio is chosen and the phone isn't connected to the car for it. */
val missing: StateFlow<Boolean> = _missing
private val _carDismissed = MutableStateFlow(false)
/** The driver tapped OK on the car's card; it stays away for the rest of this drive. */
val carDismissed: StateFlow<Boolean> = _carDismissed
private val log = GearslipLog.tagged("bluetooth")
private val main = Handler(Looper.getMainLooper())
private var appContext: Context? = null
private var armed = false
private var receiver: BroadcastReceiver? = null
fun init(context: Context) {
appContext = context.applicationContext
}
/** The car shows Gearslip. Called again whenever the video restarts, so it arms once per drive. */
fun onProjecting() = main.post {
if (armed) return@post
armed = true
main.postDelayed(::start, WAIT_MS)
}
fun onCarDisconnected() = main.post {
armed = false
main.removeCallbacksAndMessages(null)
stopWatching()
_missing.value = false
_carDismissed.value = false
}
fun dismissOnCar() {
_carDismissed.value = true
}
/** "Don't show again" on the car: turns the warning off in Settings, for every car. */
fun neverAgain() {
CarSettings.setBluetoothWarning(false)
log.i("Bluetooth audio warning turned off")
_carDismissed.value = true
_missing.value = false
}
private fun start() {
if (!armed) return
check()
// Connecting later clears the warning, and losing the connection brings it back.
val context = appContext ?: return
val r = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
main.postDelayed(::check, SETTLE_MS)
}
}
context.registerReceiver(
r,
IntentFilter(BluetoothA2dp.ACTION_CONNECTION_STATE_CHANGED).apply {
addAction(BluetoothAdapter.ACTION_STATE_CHANGED)
},
Context.RECEIVER_EXPORTED,
)
receiver = r
}
@SuppressLint("MissingPermission")
private fun check() {
if (!armed) return
val context = appContext ?: return
if (CarSettings.pipeAudio.value) {
set(false, "USB audio is on")
return
}
if (!CarSettings.bluetoothWarning.value) {
set(false, "the warning is turned off")
return
}
if (context.checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED) {
log.i("can't check the audio connection: the Bluetooth permission isn't granted")
return
}
val adapter = context.getSystemService(BluetoothManager::class.java)?.adapter ?: return
if (!adapter.isEnabled) {
set(true, "Bluetooth is off")
return
}
val car = CarBluetooth.carAddress?.uppercase()
adapter.getProfileProxy(context, object : BluetoothProfile.ServiceListener {
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
val connected = runCatching { proxy.connectedDevices }.getOrDefault(emptyList())
adapter.closeProfileProxy(profile, proxy)
val ok = if (car != null) connected.any { it.address.uppercase() == car } else connected.isNotEmpty()
main.post {
set(!ok, if (ok) "connected to the car" else "not connected to the car")
}
}
override fun onServiceDisconnected(profile: Int) {}
}, BluetoothProfile.A2DP)
}
private fun set(missing: Boolean, why: String) {
if (!armed || _missing.value == missing) return
_missing.value = missing
log.i("Bluetooth audio: $why")
}
private fun stopWatching() {
val r = receiver ?: return
receiver = null
runCatching { appContext?.unregisterReceiver(r) }
}
private const val WAIT_MS = 20_000L
private const val SETTLE_MS = 1_000L
}
@@ -0,0 +1,118 @@
package app.seb3thehacker.gearslip.car
import app.seb3thehacker.gearslip.car.theme.*
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
/**
* Asks once per car whether to pair it over Bluetooth, then waits while the phone shows its
* pairing dialog. See [CarBluetooth]. With no pairing to do, says when Bluetooth audio isn't
* connected (see [BluetoothAudioCheck]), or else when the phone's location is off (see
* [LocationCheck]). Unlike the call prompt, a tap outside the card does nothing:
* the driver picks Pair or Not now.
*/
@Composable
internal fun BluetoothPairOverlay() {
val prompt by CarBluetooth.prompt.collectAsState()
val bluetoothAudio = !CarSettings.pipeAudio.collectAsState().value
when (prompt) {
CarBluetooth.Prompt.NONE -> {
val missing by BluetoothAudioCheck.missing.collectAsState()
val dismissed by BluetoothAudioCheck.carDismissed.collectAsState()
val warn by CarSettings.bluetoothWarning.collectAsState()
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,
) 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,
)
}
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,
)
CarBluetooth.Prompt.PAIRING -> PairCard(
title = "Confirm on your phone",
body = "Check the code on your phone and tap Pair. The car may ask too.",
secondary = null,
primary = "Hide" to CarBluetooth::hide,
)
}
}
@Composable
private fun PairCard(title: String, body: String, secondary: Pair<String, () -> Unit>?, primary: Pair<String, () -> Unit>) {
Box(
Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.6f)).clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() },
onClick = {},
),
contentAlignment = Alignment.Center,
) {
Surface(
shape = MaterialTheme.shapes.extraLarge,
color = MaterialTheme.colorScheme.surfaceContainerHigh,
modifier = Modifier.fillMaxWidth(0.85f).padding(16.dp),
) {
Column(
Modifier.padding(32.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
Text(
body,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(16.dp)) {
secondary?.let { (label, onClick) ->
GsButton(onClick = onClick, tone = GsTone.Outline, modifier = Modifier.weight(1f).height(56.dp)) {
Text(label, style = MaterialTheme.typography.titleMedium)
}
}
GsButton(onClick = primary.second, modifier = Modifier.weight(1f).height(56.dp)) {
Text(primary.first, style = MaterialTheme.typography.titleMedium)
}
}
}
}
}
}
@@ -33,21 +33,17 @@ import app.seb3thehacker.gearslip.call.CallUiState
import app.seb3thehacker.gearslip.call.CarCalls import app.seb3thehacker.gearslip.call.CarCalls
/** /**
* Sits above whatever the car is showing while a call is ringing or under way. Ringing takes the * Takes the whole car screen while a call is ringing, because nothing else matters more right
* whole screen, because nothing else matters more right now; an active call is a thin band along * now. A call under way moves into the nav bar instead (see NavCallPill), so it covers nothing.
* the top, since from here the driver can only see who it is, not end it - see [CarCalls].
*/ */
@Composable @Composable
fun CallOverlay(modifier: Modifier = Modifier) { fun CallOverlay() {
val state by CarCalls.state.collectAsState() val state by CarCalls.state.collectAsState()
val context = LocalContext.current.applicationContext val context = LocalContext.current.applicationContext
when (val s = state) { when (val s = state) {
// Covers the box regardless of where the caller aligned it, so the alignment below is
// only ever felt by the active-call banner, which is the one that isn't full-screen.
is CallUiState.Ringing -> IncomingCall(s, onAnswer = { CarCalls.answer(context) }, onDecline = CarCalls::decline) is CallUiState.Ringing -> IncomingCall(s, onAnswer = { CarCalls.answer(context) }, onDecline = CarCalls::decline)
is CallUiState.Active -> ActiveCallBanner(s, modifier) else -> {}
CallUiState.None -> {}
} }
} }
@@ -110,25 +106,3 @@ private fun CallButton(icon: ImageVector, label: String, color: Color, enabled:
Text(label, style = MaterialTheme.typography.labelLarge) Text(label, style = MaterialTheme.typography.labelLarge)
} }
} }
@Composable
private fun ActiveCallBanner(s: CallUiState.Active, modifier: Modifier = Modifier) {
Surface(
color = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = modifier.fillMaxWidth(),
) {
Row(
Modifier.padding(horizontal = 20.dp, vertical = 10.dp),
horizontalArrangement = Arrangement.Center,
) {
Icon(Icons.Filled.Call, contentDescription = null, modifier = Modifier.size(20.dp))
Spacer(Modifier.height(0.dp))
Text(
" On a call" + (s.caller?.let { " with ${it.label}" } ?: ""),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold,
)
}
}
}
@@ -34,6 +34,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
@@ -59,13 +61,22 @@ fun CarAppStage(
) { ) {
val template by connection.template.collectAsStateWithLifecycle() val template by connection.template.collectAsStateWithLifecycle()
val cover = remember { ChromeCover() }
val navBar = LocalNavBarInset.current
Box(Modifier.fillMaxSize()) { Box(Modifier.fillMaxSize()) {
// The app's own drawing goes underneath; everything else is the host's rendering of // The app's own drawing goes underneath; everything else is the host's rendering of
// the template it described, laid over the top. // the template it described, laid over the top.
if (status.surfaceRequested) { androidx.compose.runtime.CompositionLocalProvider(LocalChromeCover provides cover) {
AppSurface(connection, frame, template, Modifier.fillMaxSize()) if (status.surfaceRequested) {
AppSurface(connection, frame, template, Modifier.fillMaxSize())
}
TemplateChrome(
template,
Modifier.fillMaxSize().padding(top = if (embedded) 0.dp else CHROME_BAR, bottom = navBar),
)
BottomCover(navBar, Modifier.align(Alignment.BottomCenter))
} }
TemplateChrome(template, Modifier.fillMaxSize().padding(top = if (embedded) 0.dp else CHROME_BAR))
if (!embedded) ChromeBar(status, onDisconnect, Modifier.align(Alignment.TopCenter)) if (!embedded) ChromeBar(status, onDisconnect, Modifier.align(Alignment.TopCenter))
} }
} }
@@ -84,13 +95,16 @@ private fun AppSurface(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
var size by remember { mutableStateOf(0 to 0) } var size by remember { mutableStateOf(0 to 0) }
var bounds by remember { mutableStateOf<androidx.compose.ui.geometry.Rect?>(null) }
val cover = LocalChromeCover.current
// The app lays its map out around whatever the host covers, so a template change moves the // The app lays its map out around whatever the host covers, so a template change or a card
// visible area even though the surface itself hasn't changed. // changing size moves the visible area even though the surface itself hasn't changed.
LaunchedEffect(template, size) { val (width, height) = size
val (width, height) = size val measured = bounds?.takeIf { width > 0 && height > 0 }?.let { cover?.visibleWithin(it, width, height) }
LaunchedEffect(template, size, measured) {
if (width > 0 && height > 0) { if (width > 0 && height > 0) {
val visible = visibleAreaFor(template, width, height) val visible = visibleAreaFor(template, width, height, measured)
connection.reportAreas(visible = visible, stable = visible) connection.reportAreas(visible = visible, stable = visible)
} }
} }
@@ -101,7 +115,9 @@ private fun AppSurface(
// new one on it before releasing the old: the old display's disconnect then takes the new one's // new one on it before releasing the old: the old display's disconnect then takes the new one's
// connection with it and the map freezes for good. The nav bar hiding for the search keyboard // connection with it and the map freezes for good. The nav bar hiding for the search keyboard
// and the lyrics pane opening both resize this area. // and the lyrics pane opening both resize this area.
androidx.compose.foundation.layout.BoxWithConstraints(modifier) { androidx.compose.foundation.layout.BoxWithConstraints(
modifier.onGloballyPositioned { bounds = it.boundsInWindow() },
) {
val epoch by connection.surfaceEpoch.collectAsState() val epoch by connection.surfaceEpoch.collectAsState()
androidx.compose.runtime.key(constraints.maxWidth, constraints.maxHeight, epoch) { androidx.compose.runtime.key(constraints.maxWidth, constraints.maxHeight, epoch) {
SurfaceHost(connection, frame, onSize = { size = it }, Modifier.fillMaxSize()) SurfaceHost(connection, frame, onSize = { size = it }, Modifier.fillMaxSize())
@@ -22,7 +22,7 @@ class CarApp(
object CarApps { object CarApps {
val all: List<CarApp> = listOf( val all: List<CarApp> = listOf(
CarApp("web", "Web", Icons.Filled.Search) { CarApp("web", "Browser", Icons.Filled.Search) {
WebApp(content = ProjectedContent.resolve(LocalContext.current)) WebApp(content = ProjectedContent.resolve(LocalContext.current))
}, },
CarApp("phone", "Screen sharing", Icons.Filled.Share) { CarApp("phone", "Screen sharing", Icons.Filled.Share) {
@@ -38,6 +38,8 @@ object CarAssistant {
val heard: String = "", val heard: String = "",
val reply: String = "", val reply: String = "",
val missingPermission: Boolean = false, val missingPermission: Boolean = false,
/** False from the press until the microphone is actually open, so the overlay doesn't claim to listen early. */
val ready: Boolean = false,
) )
private val _state = MutableStateFlow(State()) private val _state = MutableStateFlow(State())
@@ -89,6 +91,7 @@ object CarAssistant {
tts?.stop() tts?.stop()
_state.value = State(Phase.LISTENING) _state.value = State(Phase.LISTENING)
listenOnce( listenOnce(
onListening = { _state.value = _state.value.copy(ready = true) },
onPartial = { text -> _state.value = _state.value.copy(heard = text) }, onPartial = { text -> _state.value = _state.value.copy(heard = text) },
onHeard = { _state.value = _state.value.copy(phase = Phase.THINKING) }, onHeard = { _state.value = _state.value.copy(phase = Phase.THINKING) },
onResult = { text -> onResult = { text ->
@@ -174,15 +177,16 @@ object CarAssistant {
onSpeaking: () -> Unit = {}, onSpeaking: () -> Unit = {},
) { ) {
cancel() cancel()
listenOnce(onPartial, onHeard = {}, onResult, onError, cue, onSpeaking) listenOnce(onPartial, onHeard = {}, onResult = onResult, onError = onError, cue = cue, onSpeaking = onSpeaking)
} }
/** /**
* One phrase, through Gearslip's own [SpeechEngine]. The phone's recognizer is only a fallback * One phrase, through Gearslip's own [SpeechEngine], or the phone's recognizer when the driver
* for when the engine itself can't run, since plenty of phones (GrapheneOS among them) have none. * chose it in settings. Never the phone's on its own: on most phones that's Google's.
*/ */
private fun listenOnce( private fun listenOnce(
onPartial: (String) -> Unit, onPartial: (String) -> Unit,
onListening: () -> Unit = {},
onHeard: () -> Unit, onHeard: () -> Unit,
onResult: (String) -> Unit, onResult: (String) -> Unit,
onError: (String) -> Unit, onError: (String) -> Unit,
@@ -190,24 +194,24 @@ object CarAssistant {
onSpeaking: () -> Unit = {}, onSpeaking: () -> Unit = {},
) { ) {
val context = appContext ?: return onError("Speech isn't ready yet.") val context = appContext ?: return onError("Speech isn't ready yet.")
if (CarSettings.systemSpeech.value && SpeechRecognizer.isRecognitionAvailable(context)) {
return systemListen(context, onListening, onPartial, onHeard, onResult, onError)
}
SpeechEngine.listen( SpeechEngine.listen(
context, context,
cue = cue, cue = cue,
onSpeaking = onSpeaking, onSpeaking = onSpeaking,
onPartial = onPartial,
onListening = onListening,
onHeard = onHeard, onHeard = onHeard,
onResult = onResult, onResult = onResult,
onError = { message -> onError = onError,
if (message == SpeechEngine.NOT_LOADED && SpeechRecognizer.isRecognitionAvailable(context)) {
systemListen(context, onPartial, onHeard, onResult, onError)
} else {
onError(message)
}
},
) )
} }
private fun systemListen( private fun systemListen(
context: Context, context: Context,
onListening: () -> Unit,
onPartial: (String) -> Unit, onPartial: (String) -> Unit,
onHeard: () -> Unit, onHeard: () -> Unit,
onResult: (String) -> Unit, onResult: (String) -> Unit,
@@ -216,7 +220,7 @@ object CarAssistant {
val r = SpeechRecognizer.createSpeechRecognizer(context) val r = SpeechRecognizer.createSpeechRecognizer(context)
recognizer = r recognizer = r
r.setRecognitionListener(object : RecognitionListener { r.setRecognitionListener(object : RecognitionListener {
override fun onReadyForSpeech(params: Bundle?) {} override fun onReadyForSpeech(params: Bundle?) = onListening()
override fun onBeginningOfSpeech() {} override fun onBeginningOfSpeech() {}
override fun onRmsChanged(rmsdB: Float) {} override fun onRmsChanged(rmsdB: Float) {}
override fun onBufferReceived(buffer: ByteArray?) {} override fun onBufferReceived(buffer: ByteArray?) {}
@@ -0,0 +1,255 @@
package app.seb3thehacker.gearslip.car
import android.Manifest
import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.os.Handler
import android.os.Looper
import androidx.car.app.CarToast
import app.seb3thehacker.gearslip.AppSettings
import app.seb3thehacker.gearslip.GearslipLog
import app.seb3thehacker.gearslip.Protobuf
import app.seb3thehacker.gearslip.ServiceDiscovery
import app.seb3thehacker.gearslip.Wire
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import java.security.MessageDigest
/**
* Pairs the phone with the car over Bluetooth the first time they meet, so calls (and music, when
* the driver chose Bluetooth audio) reach the car's speakers. Android Auto does this on its own;
* Gearslip asks first, on the car screen.
*
* The car names its Bluetooth address in its service list. When the driver taps Pair, Gearslip
* opens the car's Bluetooth channel and sends what Android Auto sends:
*
* -> CHANNEL_OPEN_REQUEST <- CHANNEL_OPEN_RESPONSE
* -> PairingRequest { phone_address, method }
* <- PairingResponse { status, already_paired }
*
* then starts pairing from the phone. Two things Android Auto has that Gearslip can't: the
* phone's own Bluetooth address (apps get a placeholder), and confirming the pairing code without
* asking. So a car may turn the request down, and the phone always shows its pairing dialog. The
* channel stays closed unless the driver taps Pair: every new channel is a risk to cars that work.
*/
object CarBluetooth {
enum class Prompt { NONE, ASK, PAIRING }
private val _prompt = MutableStateFlow(Prompt.NONE)
val prompt: StateFlow<Prompt> = _prompt
private val log = GearslipLog.tagged("bluetooth")
private val main = Handler(Looper.getMainLooper())
private var appContext: Context? = null
/** This session's car: its channel, and how to open and write to it. */
private class Car(
val service: ServiceDiscovery.BluetoothService,
val open: () -> Unit,
val send: (messageId: Int, body: ByteArray) -> Unit,
)
@Volatile private var car: Car? = null
@Volatile private var bondStarted = false
/** From the Pair tap until it succeeds or fails, whether or not the card still shows. */
@Volatile private var pairing = false
private var receiver: BroadcastReceiver? = null
/** The car's Bluetooth address, when it named one in its service list. */
val carAddress: String? get() = car?.service?.carAddress
/** The car's Bluetooth channel once the driver has asked to pair, -1 otherwise. */
val channelId: Int get() = car?.takeIf { pairing }?.service?.serviceId ?: -1
fun init(context: Context) {
appContext = context.applicationContext
}
/** Called once the car lists its services. Asks on the car screen only if there's pairing to do. */
fun onCarConnected(
service: ServiceDiscovery.BluetoothService,
open: () -> Unit,
send: (messageId: Int, body: ByteArray) -> Unit,
) {
car = Car(service, open, send)
bondStarted = false
log.i("the car offers Bluetooth pairing, methods=${service.pairingMethods}")
val device = device() ?: return
when {
device.bondState == BluetoothDevice.BOND_BONDED -> log.i("already paired with this car")
declined(service.carAddress) -> log.i("not paired, and the driver said not now for this car")
else -> _prompt.value = Prompt.ASK
}
}
fun onCarDisconnected() {
car = null
pairing = false
main.post {
stopWatching()
_prompt.value = Prompt.NONE
}
}
/** The driver tapped Pair. */
fun pair() {
val c = car ?: return
val device = device() ?: return
pairing = true
_prompt.value = Prompt.PAIRING
watchBonding(device)
c.open()
log.i("-> ChannelOpenRequest on channel ${c.service.serviceId}")
// 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.")
}, GIVE_UP_MS)
}
/** The driver tapped Not now: this car isn't asked about again. */
fun notNow() {
car?.service?.carAddress?.let { address ->
val context = appContext ?: return@let
val list = AppSettings.getString(context, KEY_DECLINED, "").split(",").filter { it.isNotEmpty() }
AppSettings.putString(context, KEY_DECLINED, (list + hash(address)).distinct().joinToString(","))
}
_prompt.value = Prompt.NONE
}
/** Hides the pairing card. The phone's own dialog carries on. */
fun hide() {
_prompt.value = Prompt.NONE
}
fun onMessage(messageId: Int, body: ByteArray) {
val c = car ?: return
when (messageId) {
MSG_CHANNEL_OPEN_RESPONSE -> {
val status = Protobuf.readInt32Field(body, 1) ?: 0
log.i("<- ChannelOpenResponse: status=$status")
if (status != 0) {
main.post { startBond("the car wouldn't open its Bluetooth channel") }
return
}
val method = c.service.pairingMethods.firstOrNull { it == METHOD_NUMERIC_COMPARISON }
?: c.service.pairingMethods.firstOrNull() ?: METHOD_NUMERIC_COMPARISON
c.send(
MSG_PAIRING_REQUEST,
Protobuf.stringField(1, phoneAddress()) + Protobuf.varintField(2, method.toLong()),
)
log.i("-> PairingRequest(method=$method)")
}
MSG_PAIRING_RESPONSE -> {
val fields = Wire.fields(body)
log.i("<- PairingResponse status=${Wire.varint(fields, 1)} already_paired=${Wire.varint(fields, 2) == 1L}")
// Some cars start pairing themselves once they answer; give them a moment first.
main.postDelayed({ startBond("the car answered") }, CAR_BOND_WAIT_MS)
}
// Carries the pairing code. Android shows it to the driver, so it stays out of the log.
MSG_AUTHENTICATION_DATA -> log.i("<- AuthenticationData (${body.size} bytes)")
else -> log.i("<- unhandled message id=$messageId (${body.size} bytes)")
}
}
@SuppressLint("MissingPermission")
private fun startBond(why: String) {
if (bondStarted || !pairing) return
val device = device() ?: return
if (device.bondState != BluetoothDevice.BOND_NONE) return
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.")
}
private fun watchBonding(device: BluetoothDevice) {
val context = appContext ?: return
stopWatching()
val r = object : BroadcastReceiver() {
@Suppress("DEPRECATION")
override fun onReceive(context: Context, intent: Intent) {
val changed = intent.getParcelableExtra<BluetoothDevice>(BluetoothDevice.EXTRA_DEVICE) ?: return
if (changed.address != device.address) return
val state = intent.getIntExtra(BluetoothDevice.EXTRA_BOND_STATE, BluetoothDevice.BOND_NONE)
val previous = intent.getIntExtra(BluetoothDevice.EXTRA_PREVIOUS_BOND_STATE, BluetoothDevice.BOND_NONE)
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.")
}
}
}
context.registerReceiver(r, IntentFilter(BluetoothDevice.ACTION_BOND_STATE_CHANGED), Context.RECEIVER_EXPORTED)
receiver = r
}
private fun finish(message: String) {
pairing = false
stopWatching()
main.removeCallbacksAndMessages(null)
_prompt.value = Prompt.NONE
CarToasts.show(this, message, CarToast.LENGTH_LONG)
}
private fun stopWatching() {
val r = receiver ?: return
receiver = null
runCatching { appContext?.unregisterReceiver(r) }
}
/** The car as a Bluetooth device, or null (and logged) when Gearslip can't reach Bluetooth. */
private fun device(): BluetoothDevice? {
val context = appContext ?: return null
val address = car?.service?.carAddress ?: return null
if (context.checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED) {
log.i("can't check pairing: the Bluetooth permission isn't granted")
return null
}
val adapter = context.getSystemService(BluetoothManager::class.java)?.adapter
if (adapter == null || !adapter.isEnabled) {
log.i("can't check pairing: Bluetooth is off")
return null
}
if (!BluetoothAdapter.checkBluetoothAddress(address)) {
log.w("the car sent an address that isn't one")
return null
}
return adapter.getRemoteDevice(address)
}
/** Apps get 02:00:00:00:00:00 here, not the real address. Sent anyway, as the field is required. */
@SuppressLint("MissingPermission", "HardwareIds")
private fun phoneAddress(): String =
runCatching { appContext?.getSystemService(BluetoothManager::class.java)?.adapter?.address }.getOrNull()
?: "02:00:00:00:00:00"
private fun declined(address: String): Boolean {
val context = appContext ?: return false
return hash(address) in AppSettings.getString(context, KEY_DECLINED, "").split(",")
}
/** Settings keep a short hash, not the car's address. */
private fun hash(address: String): String =
MessageDigest.getInstance("SHA-256").digest(address.uppercase().toByteArray())
.take(8).joinToString("") { "%02x".format(it) }
private const val KEY_DECLINED = "bluetooth_pair_declined"
private const val CAR_REPLY_WAIT_MS = 5_000L
private const val CAR_BOND_WAIT_MS = 3_000L
private const val GIVE_UP_MS = 90_000L
private const val METHOD_NUMERIC_COMPARISON = 2
private const val MSG_CHANNEL_OPEN_RESPONSE = 8
private const val MSG_PAIRING_REQUEST = 32769
private const val MSG_PAIRING_RESPONSE = 32770
private const val MSG_AUTHENTICATION_DATA = 32771
}
@@ -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
}
@@ -94,12 +94,8 @@ fun CarHome() {
} }
val splitScreen = phase == CarMedia.Phase.READY && now.isActive && panel != SidePanel.NONE val splitScreen = phase == CarMedia.Phase.READY && now.isActive && panel != SidePanel.NONE
// No outer padding on any side, split screen or not - only the gap between the two panes // No padding anywhere, split screen or not: the player sits flush against the map.
// when the player is sharing the screen with the map. Row(Modifier.fillMaxSize()) {
Row(
Modifier.fillMaxSize(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Box( Box(
Modifier Modifier
.weight(if (splitScreen) 2.1f else 1f) .weight(if (splitScreen) 2.1f else 1f)
@@ -108,7 +104,7 @@ fun CarHome() {
MapPane(navStatus, frame) MapPane(navStatus, frame)
} }
if (splitScreen) { if (splitScreen) {
val side = Modifier.weight(1f).fillMaxHeight() val side = Modifier.weight(1f).fillMaxHeight().padding(bottom = LocalNavBarInset.current)
if (panel == SidePanel.LYRICS) SideLyrics(now, side) else DockedPlayer(now, side) if (panel == SidePanel.LYRICS) SideLyrics(now, side) else DockedPlayer(now, side)
} }
} }
@@ -44,6 +44,12 @@ object CarKeyboardVisibility {
val state = mutableStateOf(false) val state = mutableStateOf(false)
} }
/**
* True for the car UI the phone's own car preview draws. The phone is in hand there, so a
* [CarKeyboard] draws nothing and the phone's keyboard does the typing instead (see [PhoneTyping]).
*/
val LocalCarPreview = compositionLocalOf { false }
/** /**
* A keyboard the host draws itself. * A keyboard the host draws itself.
* *
@@ -73,10 +79,11 @@ fun CarKeyboard(
) { ) {
var symbols by remember { mutableStateOf(false) } var symbols by remember { mutableStateOf(false) }
var shifted by remember { mutableStateOf(false) } var shifted by remember { mutableStateOf(false) }
val preview = LocalCarPreview.current
DisposableEffect(Unit) { DisposableEffect(preview) {
CarKeyboardVisibility.state.value = true if (!preview) CarKeyboardVisibility.state.value = true
onDispose { CarKeyboardVisibility.state.value = false } onDispose { if (!preview) CarKeyboardVisibility.state.value = false }
} }
// The same field on the phone, for typing on its own keyboard: see [PhoneTyping]. // The same field on the phone, for typing on its own keyboard: see [PhoneTyping].
@@ -88,6 +95,7 @@ fun CarKeyboard(
} }
DisposableEffect(phoneField) { onDispose { PhoneTyping.close(phoneField) } } DisposableEffect(phoneField) { onDispose { PhoneTyping.close(phoneField) } }
LaunchedEffect(text) { PhoneTyping.carText(phoneField, text) } LaunchedEffect(text) { PhoneTyping.carText(phoneField, text) }
if (preview) return
val rows = if (symbols) SYMBOL_ROWS else LETTER_ROWS val rows = if (symbols) SYMBOL_ROWS else LETTER_ROWS
val keyHeight = keyHeight(LocalConfiguration.current.screenHeightDp) val keyHeight = keyHeight(LocalConfiguration.current.screenHeightDp)
@@ -138,18 +146,18 @@ fun CarKeyboard(
} }
// Control row: no backspace here - it stays with the letters above, not next to the // Control row: no backspace here - it stays with the letters above, not next to the
// key most likely to be hit right after it by mistake. Dismiss (when offered) is the // key most likely to be hit right after it by mistake. Search/enter takes the
// last key, bottom-right corner of the keyboard. // bottom-right corner, where a thumb expects it; dismiss (when offered) sits left of it.
Row( Row(
Modifier.fillMaxWidth(), Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp),
) { ) {
Key(if (symbols) "abc" else "?123", Modifier.weight(1.4f)) { symbols = !symbols } Key(if (symbols) "abc" else "?123", Modifier.weight(1.4f)) { symbols = !symbols }
Key("space", Modifier.weight(if (onDismiss != null) 3.2f else 4f)) { onTextChange("$text ") } Key("space", Modifier.weight(if (onDismiss != null) 3.2f else 4f)) { onTextChange("$text ") }
SubmitKey(Modifier.weight(1.6f), submitIcon, submitImage, onSubmit)
if (onDismiss != null) { if (onDismiss != null) {
DismissKey(onDismiss, Modifier.weight(1.4f)) DismissKey(onDismiss, Modifier.weight(1.4f))
} }
SubmitKey(Modifier.weight(1.6f), submitIcon, submitImage, onSubmit)
} }
} }
} }
@@ -222,9 +230,9 @@ private fun SubmitKey(modifier: Modifier, search: Boolean, image: ImageVector?,
* Big enough to hit on a bumpy road, small enough that four rows plus a field plus a few results * Big enough to hit on a bumpy road, small enough that four rows plus a field plus a few results
* still fit. A 480-tall screen sets the floor; a taller one (LIVI's 1280x720) gets bigger keys. * still fit. A 480-tall screen sets the floor; a taller one (LIVI's 1280x720) gets bigger keys.
*/ */
private fun keyHeight(screenHeightDp: Int): Dp = (screenHeightDp * 0.075f).dp.coerceIn(36.dp, 52.dp) private fun keyHeight(screenHeightDp: Int): Dp = (screenHeightDp * 0.09f).dp.coerceIn(44.dp, 64.dp)
private val LocalKeyHeight = compositionLocalOf { 36.dp } private val LocalKeyHeight = compositionLocalOf { 44.dp }
private val LETTER_ROWS = listOf( private val LETTER_ROWS = listOf(
"qwertyuiop".map { it.toString() }, "qwertyuiop".map { it.toString() },
@@ -27,6 +27,11 @@ object CarKeys {
private val _screenKeys = MutableSharedFlow<ScreenKey>(extraBufferCapacity = 8) private val _screenKeys = MutableSharedFlow<ScreenKey>(extraBufferCapacity = 8)
val screenKeys: SharedFlow<ScreenKey> = _screenKeys val screenKeys: SharedFlow<ScreenKey> = _screenKeys
private val _focusMoves = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
/** A knob turn or D-pad press, so controls that hide when left alone can come back. */
val focusMoves: SharedFlow<Unit> = _focusMoves
/** Delivers an Android key event to the car display; set while the car UI is showing. */ /** Delivers an Android key event to the car display; set while the car UI is showing. */
@Volatile var focusSink: ((keyCode: Int, down: Boolean) -> Unit)? = null @Volatile var focusSink: ((keyCode: Int, down: Boolean) -> Unit)? = null
@@ -58,6 +63,7 @@ object CarKeys {
*/ */
fun onKey(keyCode: Int, down: Boolean, longPress: Boolean): Boolean { fun onKey(keyCode: Int, down: Boolean, longPress: Boolean): Boolean {
if (keyCode in FOCUS_KEYS) { if (keyCode in FOCUS_KEYS) {
_focusMoves.tryEmit(Unit)
focusSink?.invoke(keyCode, down) focusSink?.invoke(keyCode, down)
return true return true
} }
@@ -86,6 +92,7 @@ object CarKeys {
/** A turn of the rotary knob: each click moves focus one step, like Tab or Shift+Tab. */ /** A turn of the rotary knob: each click moves focus one step, like Tab or Shift+Tab. */
fun onRotate(delta: Int) { fun onRotate(delta: Int) {
val sink = focusSink ?: return val sink = focusSink ?: return
_focusMoves.tryEmit(Unit)
val code = if (delta > 0) KeyEvent.KEYCODE_TAB else KEYCODE_SHIFT_TAB val code = if (delta > 0) KeyEvent.KEYCODE_TAB else KEYCODE_SHIFT_TAB
repeat(kotlin.math.abs(delta).coerceAtMost(MAX_ROTARY_STEPS)) { repeat(kotlin.math.abs(delta).coerceAtMost(MAX_ROTARY_STEPS)) {
sink(code, true) sink(code, true)
@@ -95,15 +102,10 @@ object CarKeys {
/** /**
* The voice key: answers the message on screen if there is one, otherwise starts the * The voice key: answers the message on screen if there is one, otherwise starts the
* assistant when it's turned on. * assistant.
*/ */
private fun voice() = main.post { private fun voice() = main.post {
if (VoiceReply.onVoiceKey()) return@post if (!VoiceReply.onVoiceKey()) CarAssistant.start()
if (CarSettings.experimentalFeaturesEnabled.value && CarSettings.voiceAssistantEnabled.value) {
CarAssistant.start()
} else {
GearslipLog.i("keys: voice pressed, but the assistant is off in settings")
}
} }
/** Answers a ringing call; with nothing ringing, opens the phone screen. */ /** Answers a ringing call; with nothing ringing, opens the phone screen. */
@@ -70,12 +70,8 @@ private class Tile(
val label: String, val label: String,
val icon: ImageBitmap? = null, val icon: ImageBitmap? = null,
val glyph: ImageVector? = null, val glyph: ImageVector? = null,
/** Tested from start to finish with Gearslip; drawn with a check mark. */
val verified: Boolean = false,
/** Known not to work; drawn with a red X instead of being left off the launcher. */ /** Known not to work; drawn with a red X instead of being left off the launcher. */
val broken: Boolean = false, val broken: Boolean = false,
/** Starts and can be used, but rough enough not to call working; drawn with a yellow mark. */
val partial: Boolean = false,
val onLongClick: (() -> Unit)? = null, val onLongClick: (() -> Unit)? = null,
val onClick: () -> Unit, val onClick: () -> Unit,
) )
@@ -187,6 +183,8 @@ private fun groupOf(entry: Entry, mediaPackages: Set<String>): AppGroup {
return when (app.kind) { return when (app.kind) {
"calling" -> AppGroup.PHONE "calling" -> AppGroup.PHONE
"navigation", "poi", "parking", "charging" -> AppGroup.MAPS "navigation", "poi", "parking", "charging" -> AppGroup.MAPS
// Template-only media apps may have no legacy browser/player tile to identify them.
"media" -> AppGroup.MUSIC
"messaging" -> AppGroup.MESSAGING "messaging" -> AppGroup.MESSAGING
"settings" -> AppGroup.SETTINGS "settings" -> AppGroup.SETTINGS
else -> AppGroup.OTHER else -> AppGroup.OTHER
@@ -241,6 +239,7 @@ internal fun launchEntry(entry: Entry, navigator: CarNavigator, frame: CarEnviro
/** The app launcher: every app as an icon grid, grouped Phone, Maps, Music, Messaging, Web, Screen sharing, Settings. */ /** The app launcher: every app as an icon grid, grouped Phone, Maps, Music, Messaging, Web, Screen sharing, Settings. */
@Composable @Composable
fun CarLauncher() { fun CarLauncher() {
val context = LocalContext.current
val navigator = LocalCarNavigator.current val navigator = LocalCarNavigator.current
val frame by CarEnvironment.frame.collectAsState() val frame by CarEnvironment.frame.collectAsState()
@@ -248,18 +247,15 @@ fun CarLauncher() {
val all = rememberAllEntries() val all = rememberAllEntries()
val running = rememberRunningApps() val running = rememberRunningApps()
val showVehicleData = rememberVehicleDataShown() val showVehicleData = rememberVehicleDataShown()
var showBadgeKey by remember { mutableStateOf(false) }
fun tileOf(entry: Entry): Tile { fun tileOf(entry: Entry): Tile {
// The badges rate an app's own car screens; a messenger shown on Gearslip's own Messages // Only apps that don't work get a mark. The X rates an app's own car screens; a messenger
// screen has none, so it gets no badge. // shown on Gearslip's own Messages screen has none, so it never gets one.
val pkg = entry.packageName?.takeIf { entry.messaging == null } val pkg = entry.packageName?.takeIf { entry.messaging == null }
val id = entry.componentId val id = entry.componentId
return Tile( return Tile(
entry.label, entry.icon, entry.builtIn?.glyph, entry.label, entry.icon, entry.builtIn?.glyph,
verified = pkg != null && !KnownApps.isPartial(pkg) && (KnownApps.works(pkg) || (entry.media != null && KnownApps.playerWorks(pkg))), broken = pkg != null && (KnownApps.isBroken(context, pkg) || (entry.template != null && KnownApps.screenBroken(pkg))),
broken = pkg != null && (KnownApps.isBroken(pkg) || (entry.template != null && KnownApps.screenBroken(pkg))),
partial = pkg != null && KnownApps.isPartial(pkg),
onLongClick = id?.let { { navigator.showAppMenu(it) } }, onLongClick = id?.let { { navigator.showAppMenu(it) } },
) { openApp(entry, id?.let { running[it] }, navigator, frame) } ) { openApp(entry, id?.let { running[it] }, navigator, frame) }
} }
@@ -275,60 +271,6 @@ fun CarLauncher() {
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
items(tiles) { AppTile(it) } items(tiles) { AppTile(it) }
if (tiles.any { it.verified || it.broken || it.partial }) {
// Folded away behind one button: it's reference, read once, not something every
// visit to the launcher needs spelled out under the grid.
item(span = { GridItemSpan(maxLineSpan) }) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
GsButton(onClick = { showBadgeKey = !showBadgeKey }, tone = GsTone.Neutral) {
Icon(Icons.Filled.Info, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text(if (showBadgeKey) "Hide badge key" else "What the badges mean")
}
if (showBadgeKey) {
BadgeLegend(
verified = tiles.any { it.verified },
partial = tiles.any { it.partial },
broken = tiles.any { it.broken },
)
}
}
}
}
}
}
/** Says what each badge on a tile means, once, under the grid - only the ones actually in use. */
@Composable
private fun BadgeLegend(verified: Boolean, partial: Boolean, broken: Boolean) {
Column(
Modifier.fillMaxWidth().padding(top = 4.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
if (verified) LegendRow(size = 16.dp, badge = { VerifiedBadge(it) }, text = "Tested and working with Gearslip")
if (partial) LegendRow(size = 16.dp, badge = { PartialBadge(it) }, text = "Runs, but not cleanly, with Gearslip")
if (broken) LegendRow(size = 16.dp, badge = { BrokenBadge(it) }, text = "Doesn't work with Gearslip yet")
}
}
@Composable
private fun LegendRow(size: androidx.compose.ui.unit.Dp, badge: @Composable (androidx.compose.ui.unit.Dp) -> Unit, text: String) {
Row(horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) {
badge(size)
Spacer(Modifier.width(8.dp))
Text(text, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
/** A green disc with a white check: the same mark on every tile and in the legend. */
@Composable
private fun VerifiedBadge(size: androidx.compose.ui.unit.Dp, modifier: Modifier = Modifier) {
Box(
modifier.size(size).background(Color(0xFF43A047), CircleShape),
contentAlignment = Alignment.Center,
) {
Icon(Icons.Filled.Check, contentDescription = "Tested and working", tint = Color.White, modifier = Modifier.size(size * 0.66f))
} }
} }
@@ -343,39 +285,16 @@ private fun BrokenBadge(size: androidx.compose.ui.unit.Dp, modifier: Modifier =
} }
} }
/** A yellow disc with a warning mark: neither the check nor the X, since this app runs but isn't clean about it. */
@Composable
private fun PartialBadge(size: androidx.compose.ui.unit.Dp, modifier: Modifier = Modifier) {
Box(
modifier.size(size).background(Color(0xFFF9A825), CircleShape),
contentAlignment = Alignment.Center,
) {
Icon(Icons.Filled.Warning, contentDescription = "Works, but not cleanly, with Gearslip", tint = Color.White, modifier = Modifier.size(size * 0.6f))
}
}
/** Trial: where a tile says whether its app works. See [AppTileBody]. */
private enum class BadgeStyle { ON_ICON, OUTLINE }
private val BADGE_STYLE = BadgeStyle.ON_ICON
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
private fun AppTile(tile: Tile) { private fun AppTile(tile: Tile) {
val status = when {
tile.verified -> Color(0xFF43A047)
tile.partial -> Color(0xFFF9A825)
tile.broken -> Color(0xFFD32F2F)
else -> null
}
val shape = MaterialTheme.shapes.extraLarge val shape = MaterialTheme.shapes.extraLarge
// A long press opens the app's menu (pin, close) - the same gesture everywhere apps are // A long press opens the app's menu (pin, close) - the same gesture everywhere apps are
// shown, here and on the nav bar. GsIconBox marks it in the theme's style. // shown, here and on the nav bar. GsIconBox marks it in the theme's style.
GsIconBox( GsIconBox(
onClick = tile.onClick, onClick = tile.onClick,
onLongClick = tile.onLongClick, onLongClick = tile.onLongClick,
modifier = Modifier.height(120.dp).then( modifier = Modifier.height(120.dp),
if (BADGE_STYLE == BadgeStyle.OUTLINE && status != null) Modifier.border(2.5.dp, status, shape) else Modifier,
),
colors = GsColors(MaterialTheme.colorScheme.surfaceVariant, MaterialTheme.colorScheme.onSurfaceVariant), colors = GsColors(MaterialTheme.colorScheme.surfaceVariant, MaterialTheme.colorScheme.onSurfaceVariant),
shape = shape, shape = shape,
) { ) {
@@ -387,16 +306,14 @@ private fun AppTile(tile: Tile) {
) { ) {
Box { Box {
TileIcon(tile) TileIcon(tile)
if (BADGE_STYLE == BadgeStyle.ON_ICON) { if (tile.broken) {
// Pinned to the icon's lower corner, ringed in the tile's colour so it // Pinned to the icon's lower corner, ringed in the tile's colour so it
// reads as sitting on top of the icon. // reads as sitting on top of the icon.
val ring = Modifier.align(Alignment.BottomEnd).offset(x = 6.dp, y = 4.dp) BrokenBadge(
.border(2.dp, MaterialTheme.colorScheme.surfaceVariant, CircleShape) 22.dp,
when { Modifier.align(Alignment.BottomEnd).offset(x = 6.dp, y = 4.dp)
tile.verified -> VerifiedBadge(22.dp, ring) .border(2.dp, MaterialTheme.colorScheme.surfaceVariant, CircleShape),
tile.broken -> BrokenBadge(22.dp, ring) )
tile.partial -> PartialBadge(22.dp, ring)
}
} }
} }
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
@@ -5,16 +5,7 @@ import app.seb3thehacker.gearslip.Wire
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
/** /** Raw sensor readings, keyed by SensorBatch field number, which matches the sensor type id. */
* Everything the head unit's sensor channel has offered and sent, for the "Vehicle data" screen.
*
* [ServiceDiscovery.findSensorService] tells us which numeric sensor types this head unit is
* willing to stream; [SensorType] names the ones publicly documented by other AA reverse-engineering
* projects (aasdk, openauto). That mapping is carried over from other projects, not verified
* against this exact unit, so it's a label, not a guarantee - the raw field dump next to it is
* the ground truth. Once [onEvent] starts decoding real traffic from the 2018 Uconnect, correct
* any type that turns out to be mislabeled.
*/
object CarSensors { object CarSensors {
data class Reading(val typeId: Int, val typeName: String, val raw: String, val updatedAtMs: Long) data class Reading(val typeId: Int, val typeName: String, val raw: String, val updatedAtMs: Long)
@@ -30,16 +21,10 @@ object CarSensors {
} }
} }
/** /** Each SensorBatch field contains repeated readings of one sensor type. */
* One SensorEventIndication. Every reverse-engineered version of this message multiplexes
* every sensor type into one shared set of message-level field numbers (field N = "the data
* for sensor type N", roughly) rather than tagging events with the type id from
* SensorType.type - so field number is read here as a best-effort type id, same as the
* other services in this file.
*/
fun onEvent(body: ByteArray) { fun onEvent(body: ByteArray) {
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
val fields = Wire.fields(body) val fields = Wire.fields(body).filter { it.wireType == 2 }
val byType = fields.groupBy { it.number } val byType = fields.groupBy { it.number }
val updated = byType.mapValues { (number, group) -> val updated = byType.mapValues { (number, group) ->
Reading( Reading(
@@ -61,10 +46,7 @@ object CarSensors {
} }
} }
/** /** Sensor type values shared by the discovery service and SensorBatch. */
* Sensor type ids as named across public Android Auto reverse-engineering references. Treated as
* a label, not a fact - see [CarSensors] doc.
*/
object SensorType { object SensorType {
private val NAMES = mapOf( private val NAMES = mapOf(
1 to "Location (GPS)", 1 to "Location (GPS)",
@@ -89,6 +71,10 @@ object SensorType {
20 to "Gyroscope", 20 to "Gyroscope",
21 to "GPS satellites", 21 to "GPS satellites",
22 to "Toll card", 22 to "Toll card",
23 to "Vehicle energy model",
24 to "Trailer",
25 to "Raw vehicle energy model",
26 to "Raw EV trip settings",
) )
fun name(id: Int): String = NAMES[id]?.let { "$it (#$id)" } ?: "Sensor #$id" fun name(id: Int): String = NAMES[id]?.let { "$it (#$id)" } ?: "Sensor #$id"
@@ -33,6 +33,12 @@ object CarServices {
val nav: CarAppConnection by lazy { CarAppConnection(requireNotNull(context)) } val nav: CarAppConnection by lazy { CarAppConnection(requireNotNull(context)) }
val media: CarMedia by lazy { CarMedia(requireNotNull(context)) } val media: CarMedia by lazy { CarMedia(requireNotNull(context)) }
/** [nav], or null before the car screen has set things up. */
fun navOrNull(): CarAppConnection? = if (context != null) nav else null
/** [media], or null before the car screen has set things up: for code outside the car UI. */
fun mediaOrNull(): CarMedia? = if (context != null) media else null
/** /**
* A second, independent connection for templated apps that aren't navigation apps - Spotify's * A second, independent connection for templated apps that aren't navigation apps - Spotify's
* own Car App Library service is the first of these. Kept apart from [nav] so opening one * own Car App Library service is the first of these. Kept apart from [nav] so opening one
@@ -59,6 +65,10 @@ object CarServices {
app.seb3thehacker.gearslip.notify.CarMotion.start(appContext) app.seb3thehacker.gearslip.notify.CarMotion.start(appContext)
app.seb3thehacker.gearslip.call.CarCalls.start(appContext) app.seb3thehacker.gearslip.call.CarCalls.start(appContext)
CarAssistant.init(appContext) CarAssistant.init(appContext)
DirectionsVoice.init(appContext)
CarBluetooth.init(appContext)
BluetoothAudioCheck.init(appContext)
LocationCheck.init(appContext)
CarKeys.init(appContext) CarKeys.init(appContext)
VoiceReply.init(appContext) VoiceReply.init(appContext)
// Loaded now, while nothing's waiting on it, so the first reply isn't slower than the rest. // Loaded now, while nothing's waiting on it, so the first reply isn't slower than the rest.
@@ -79,15 +89,22 @@ object CarServices {
if (destination != null && nav.connectedComponent == app.component) { if (destination != null && nav.connectedComponent == app.component) {
nav.navigateTo(destination) nav.navigateTo(destination)
} else { } else {
// Another map app taking over ends the old one's route, or it keeps guiding unseen.
if (nav.connectedComponent != app.component) endRoute()
nav.connect(app, frame.width, frame.height, frame.densityDpi, destination) nav.connect(app, frame.width, frame.height, frame.densityDpi, destination)
} }
} }
fun stopNav() { fun stopNav() {
navStopped = true navStopped = true
endRoute()
nav.disconnect() nav.disconnect()
} }
private fun endRoute() {
if (nav.navigating) nav.stopNavigating()
}
/** Undoes [stopNav]'s hold so the last map app comes back, same as it would after a restart. */ /** Undoes [stopNav]'s hold so the last map app comes back, same as it would after a restart. */
suspend fun reconnectNav(frame: CarEnvironment.Frame) { suspend fun reconnectNav(frame: CarEnvironment.Frame) {
navStopped = false navStopped = false
@@ -121,6 +138,19 @@ object CarServices {
if (CarSettings.pipeAudio.value) CarAudio.request() if (CarSettings.pipeAudio.value) CarAudio.request()
} }
/**
* Reopens the media app used last, for the car's media button when nothing is open. False
* when there's none on record or it's been uninstalled since.
*/
suspend fun reopenLastMedia(): Boolean {
val ctx = context ?: return false
val last = CarSettings.lastMedia.value?.let(ComponentName::unflattenFromString) ?: return false
val app = withContext(Dispatchers.IO) { MediaCatalog.installed(ctx) }
.firstOrNull { it.component == last } ?: return false
openMedia(app)
return true
}
/** Closes the media app from the car: stops what it's playing and drops the connection. */ /** Closes the media app from the car: stops what it's playing and drops the connection. */
fun closeMedia() { fun closeMedia() {
if (media.now.value.playing) media.togglePlay() if (media.now.value.playing) media.togglePlay()
@@ -148,7 +178,7 @@ object CarServices {
_hasNavApps.value = navApps.isNotEmpty() _hasNavApps.value = navApps.isNotEmpty()
// Never auto-pick one already known to fail outright - a driver who plugs in should // Never auto-pick one already known to fail outright - a driver who plugs in should
// land on a map, not on the one tile marked with a red X. // land on a map, not on the one tile marked with a red X.
val candidates = navApps.filterNot { KnownApps.isBroken(it.component.packageName) } val candidates = navApps.filterNot { KnownApps.isBroken(ctx, it.component.packageName) }
val last = CarSettings.lastNav.value val last = CarSettings.lastNav.value
// The one used last, or - first run, nothing on record yet - whichever comes first: // The one used last, or - first run, nothing on record yet - whichever comes first:
// a driver who plugs in for the first time shouldn't have to go pick one by hand // a driver who plugs in for the first time shouldn't have to go pick one by hand
@@ -194,6 +224,32 @@ object CarServices {
return playingPackages.firstNotNullOfOrNull { pkg -> catalog.firstOrNull { it.component.packageName == pkg } } return playingPackages.firstNotNullOfOrNull { pkg -> catalog.firstOrNull { it.component.packageName == pkg } }
} }
/**
* The car went away mid-song: pause it, as Android Auto does, so it doesn't carry on out of
* the phone's speaker. Unplugging a USB accessory isn't "audio becoming noisy" the way
* headphones are, so the apps won't pause themselves. Every playing session is paused, not
* just the one Gearslip opened: anything the car was playing was going through the car.
*/
fun pausePhoneMedia(why: String = "the car left") {
val ctx = context ?: return
val manager = ctx.getSystemService(MediaSessionManager::class.java)
val listener = ComponentName(ctx, GearslipNotificationListener::class.java)
val playing = try {
manager.getActiveSessions(listener).filter { it.playbackState?.state == PlaybackState.STATE_PLAYING }
} catch (e: SecurityException) {
null
}
if (playing == null) {
// No notification access: the app Gearslip itself is driving is all it can reach.
mediaOrNull()?.let { if (it.now.value.playing) it.togglePlay() }
return
}
playing.forEach { runCatching { it.transportControls.pause() } }
if (playing.isNotEmpty()) {
app.seb3thehacker.gearslip.GearslipLog.i("media: paused ${playing.joinToString { it.packageName }}: $why")
}
}
/** The car went away: nothing should keep running for a screen that no longer exists. */ /** The car went away: nothing should keep running for a screen that no longer exists. */
fun shutdown() { fun shutdown() {
context?.let { app.seb3thehacker.gearslip.notify.CarMotion.stop(it) } context?.let { app.seb3thehacker.gearslip.notify.CarMotion.stop(it) }
@@ -202,6 +258,8 @@ object CarServices {
runCatching { media.disconnect() } runCatching { media.disconnect() }
runCatching { browse.disconnect() } runCatching { browse.disconnect() }
CarAssistant.cancel() CarAssistant.cancel()
// About 900 MB that nothing needs once the car's gone.
app.seb3thehacker.gearslip.speech.SpeechEngine.unload()
CarAudio.release() CarAudio.release()
_mediaApp.value = null _mediaApp.value = null
navStopped = false navStopped = false
@@ -2,6 +2,7 @@ package app.seb3thehacker.gearslip.car
import android.content.Context import android.content.Context
import app.seb3thehacker.gearslip.AppSettings import app.seb3thehacker.gearslip.AppSettings
import app.seb3thehacker.gearslip.audio.CarAudio
import app.seb3thehacker.gearslip.car.theme.GsTheme import app.seb3thehacker.gearslip.car.theme.GsTheme
import app.seb3thehacker.gearslip.car.theme.GsThemes import app.seb3thehacker.gearslip.car.theme.GsThemes
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -17,6 +18,33 @@ enum class Units { AUTO, IMPERIAL, METRIC }
/** The car UI's own colours. Separate from [NightMode], which is about the light outside (the map). */ /** The car UI's own colours. Separate from [NightMode], which is about the light outside (the map). */
enum class AppTheme { PHONE, LIGHT, DARK } enum class AppTheme { PHONE, LIGHT, DARK }
/** A screen's width and height in pixels, as someone typed it: "1280x480". */
data class ScreenSize(val width: Int, val height: Int) {
override fun toString() = "${width}x$height"
companion object {
fun parse(text: String?): ScreenSize? {
val parts = text?.lowercase()?.split('x', '×', ' ')?.filter { it.isNotBlank() } ?: return null
if (parts.size != 2) return null
val w = parts[0].trim().toIntOrNull() ?: return null
val h = parts[1].trim().toIntOrNull() ?: return null
return if (w in 100..8000 && h in 100..8000) ScreenSize(w, h) else null
}
}
}
/**
* How a head unit puts our frame on a screen of another shape. [CROP] cuts the edges off, as
* a car's own margins do; [SQUASH] shrinks the whole frame to fit, stretching everything.
*/
enum class ScreenFit { CROP, SQUASH }
/** What size the phone's car preview draws: the last car's, this device's whole screen, or one typed in. */
enum class PreviewSize { LAST_CAR, FILL, CUSTOM }
/** When the nav bar gets out of the way: never, during a route, or whenever the map is up. */
enum class NavBarMode { SHOW, HIDE_NAVIGATING, HIDE }
/** /**
* Settings changed from the car screen itself. Held as flows so the car UI reacts the moment a * Settings changed from the car screen itself. Held as flows so the car UI reacts the moment a
* button is tapped, and persisted through [AppSettings] so they survive the next connection. * button is tapped, and persisted through [AppSettings] so they survive the next connection.
@@ -27,6 +55,10 @@ object CarSettings {
private const val KEY_OPEN = "car_open_on_connect" private const val KEY_OPEN = "car_open_on_connect"
private const val KEY_NIGHT = "car_night_mode" private const val KEY_NIGHT = "car_night_mode"
private const val KEY_PIPE_AUDIO = "car_pipe_audio" private const val KEY_PIPE_AUDIO = "car_pipe_audio"
private const val KEY_SPEAK_DIRECTIONS = "car_speak_directions"
private const val KEY_BLUETOOTH_WARNING = "car_bluetooth_warning"
private const val KEY_LOCATION_WARNING = "car_location_warning"
private const val KEY_AUDIO_CHOICE_ASKED = "car_audio_choice_asked"
private const val KEY_LAST_NAV = "car_last_nav" private const val KEY_LAST_NAV = "car_last_nav"
private const val KEY_LAST_MEDIA = "car_last_media" private const val KEY_LAST_MEDIA = "car_last_media"
private const val KEY_AUTOPLAY = "car_autoplay" private const val KEY_AUTOPLAY = "car_autoplay"
@@ -36,11 +68,15 @@ object CarSettings {
private const val KEY_PINNED = "car_pinned_apps" private const val KEY_PINNED = "car_pinned_apps"
private const val KEY_APP_ORDER = "car_app_order" private const val KEY_APP_ORDER = "car_app_order"
private const val KEY_EXPERIMENTAL_FEATURES = "car_experimental_features" private const val KEY_EXPERIMENTAL_FEATURES = "car_experimental_features"
private const val KEY_VOICE_ASSISTANT = "car_voice_assistant" private const val KEY_SYSTEM_SPEECH = "car_system_speech"
private const val KEY_CAR_SENSORS = "car_sensors_enabled" private const val KEY_CAR_SENSORS = "car_sensors_enabled"
private const val KEY_MEDIA_SEARCH = "car_media_search_enabled"
private const val KEY_SEEN_SAFETY_WARNING = "car_seen_safety_warning" private const val KEY_SEEN_SAFETY_WARNING = "car_seen_safety_warning"
private const val KEY_UNITS = "car_units" private const val KEY_UNITS = "car_units"
private const val KEY_NAV_BAR = "car_nav_bar"
private const val KEY_SCREEN_SIZE = "car_screen_size"
private const val KEY_SCREEN_FIT = "car_screen_fit"
private const val KEY_PREVIEW_SIZE = "preview_size"
private const val KEY_PREVIEW_CUSTOM = "preview_custom_size"
private const val KEY_WHATS_NEW = "car_whats_new_after_updates" private const val KEY_WHATS_NEW = "car_whats_new_after_updates"
private const val KEY_WHATS_NEW_SEEN = "car_whats_new_seen_code" private const val KEY_WHATS_NEW_SEEN = "car_whats_new_seen_code"
@@ -57,6 +93,9 @@ object CarSettings {
private val openFlow = MutableStateFlow(HOME) private val openFlow = MutableStateFlow(HOME)
private val nightFlow = MutableStateFlow(NightMode.AUTO) private val nightFlow = MutableStateFlow(NightMode.AUTO)
private val pipeAudioFlow = MutableStateFlow(true) private val pipeAudioFlow = MutableStateFlow(true)
private val speakDirectionsFlow = MutableStateFlow(true)
private val bluetoothWarningFlow = MutableStateFlow(true)
private val locationWarningFlow = MutableStateFlow(true)
private val lastNavFlow = MutableStateFlow<String?>(null) private val lastNavFlow = MutableStateFlow<String?>(null)
private val lastMediaFlow = MutableStateFlow<String?>(null) private val lastMediaFlow = MutableStateFlow<String?>(null)
private val autoplayFlow = MutableStateFlow(false) private val autoplayFlow = MutableStateFlow(false)
@@ -70,14 +109,50 @@ object CarSettings {
private val pinnedFlow = MutableStateFlow<Set<String>>(emptySet()) private val pinnedFlow = MutableStateFlow<Set<String>>(emptySet())
private val appOrderFlow = MutableStateFlow<List<String>>(emptyList()) private val appOrderFlow = MutableStateFlow<List<String>>(emptyList())
private val experimentalFeaturesFlow = MutableStateFlow(false) private val experimentalFeaturesFlow = MutableStateFlow(false)
private val voiceAssistantFlow = MutableStateFlow(false) private val systemSpeechFlow = MutableStateFlow(false)
private val carSensorsFlow = MutableStateFlow(false) private val carSensorsFlow = MutableStateFlow(false)
private val mediaSearchFlow = MutableStateFlow(false)
private val seenSafetyWarningFlow = MutableStateFlow(false) private val seenSafetyWarningFlow = MutableStateFlow(false)
private val unitsFlow = MutableStateFlow(Units.AUTO) private val unitsFlow = MutableStateFlow(Units.AUTO)
val units: StateFlow<Units> = unitsFlow val units: StateFlow<Units> = unitsFlow
private val screenSizeFlow = MutableStateFlow<ScreenSize?>(null)
private val screenFitFlow = MutableStateFlow(ScreenFit.CROP)
private val previewSizeFlow = MutableStateFlow(PreviewSize.LAST_CAR)
private val previewCustomFlow = MutableStateFlow<ScreenSize?>(null)
/** The car screen's real size, when the head unit gets it wrong; null trusts the head unit. */
val screenSize: StateFlow<ScreenSize?> = screenSizeFlow
val screenFit: StateFlow<ScreenFit> = screenFitFlow
val previewSize: StateFlow<PreviewSize> = previewSizeFlow
val previewCustom: StateFlow<ScreenSize?> = previewCustomFlow
/** Takes effect the next time the car connects: the video size is agreed at the start. */
fun setScreenSize(size: ScreenSize?) {
screenSizeFlow.value = size
AppSettings.putString(app, KEY_SCREEN_SIZE, size?.toString().orEmpty())
}
fun setScreenFit(fit: ScreenFit) {
screenFitFlow.value = fit
AppSettings.putString(app, KEY_SCREEN_FIT, fit.name)
}
fun setPreviewSize(size: PreviewSize, custom: ScreenSize? = previewCustomFlow.value) {
previewSizeFlow.value = size
previewCustomFlow.value = custom
AppSettings.putString(app, KEY_PREVIEW_SIZE, size.name)
AppSettings.putString(app, KEY_PREVIEW_CUSTOM, custom?.toString().orEmpty())
}
private val navBarFlow = MutableStateFlow(NavBarMode.SHOW)
val navBarMode: StateFlow<NavBarMode> = navBarFlow
fun setNavBarMode(mode: NavBarMode) {
navBarFlow.value = mode
AppSettings.putString(app, KEY_NAV_BAR, mode.name)
}
private val whatsNewFlow = MutableStateFlow(true) private val whatsNewFlow = MutableStateFlow(true)
private val whatsNewSeenFlow = MutableStateFlow(0) private val whatsNewSeenFlow = MutableStateFlow(0)
@@ -148,22 +223,41 @@ object CarSettings {
* Whether a media app's sound is captured and sent over the car link. On by default, since * Whether a media app's sound is captured and sent over the car link. On by default, since
* sound in the car is the point of the exercise; the platform allows the capture only behind a * sound in the car is the point of the exercise; the platform allows the capture only behind a
* consent dialog, which [app.seb3thehacker.gearslip.audio.CarAudio] raises once the car has * consent dialog, which [app.seb3thehacker.gearslip.audio.CarAudio] raises once the car has
* somewhere to play it. Turned off, the app plays through the phone's normal output instead. * somewhere to play it. Turned off ("Bluetooth" in the settings), the app plays through the
* phone's normal output instead, and Android never asks.
*/ */
val pipeAudio: StateFlow<Boolean> = pipeAudioFlow val pipeAudio: StateFlow<Boolean> = pipeAudioFlow
/** Gearslip reads the map app's turns aloud on the car's directions channel; see DirectionsVoice. */
val speakDirections: StateFlow<Boolean> = speakDirectionsFlow
/** With Bluetooth audio, warn when the phone isn't connected to the car; see BluetoothAudioCheck. */
val bluetoothWarning: StateFlow<Boolean> = bluetoothWarningFlow
/** Warn on the car when the phone's location is off; see LocationCheck. */
val locationWarning: StateFlow<Boolean> = locationWarningFlow
/**
* Whether the driver has already been offered Bluetooth after turning down the capture
* dialog. Offered once only: someone who chose to keep the USB route shouldn't be nagged.
*/
/** False until the driver picks USB or Bluetooth, in setup or in settings. */
val pipeAudioChosen: Boolean
get() = AppSettings.getString(app, KEY_PIPE_AUDIO, "") != ""
val audioChoiceAsked: Boolean
get() = AppSettings.getString(app, KEY_AUDIO_CHOICE_ASKED, "false") == "true"
fun markAudioChoiceAsked() = AppSettings.putString(app, KEY_AUDIO_CHOICE_ASKED, "true")
/** /**
* Off by default: a driver has to opt into experimental features as a whole before any of * Off by default: a driver has to opt into experimental features as a whole before any of
* them - the voice assistant included - show up to turn on individually. * them - the voice assistant included - show up to turn on individually.
*/ */
val experimentalFeaturesEnabled: StateFlow<Boolean> = experimentalFeaturesFlow val experimentalFeaturesEnabled: StateFlow<Boolean> = experimentalFeaturesFlow
/** /** Listen with the phone's own speech service instead of Gearslip's on-device model. */
* The voice assistant is experimental and off by default; a driver has to turn it on val systemSpeech: StateFlow<Boolean> = systemSpeechFlow
* knowingly, from the settings screen, after reading the warning shown there. This flag
* alone does not run the assistant - [experimentalFeaturesEnabled] must also be on.
*/
val voiceAssistantEnabled: StateFlow<Boolean> = voiceAssistantFlow
/** /**
* Vehicle data is experimental and off by default: the launcher's tile for it, and the * Vehicle data is experimental and off by default: the launcher's tile for it, and the
@@ -173,13 +267,6 @@ object CarSettings {
*/ */
val carSensorsEnabled: StateFlow<Boolean> = carSensorsFlow val carSensorsEnabled: StateFlow<Boolean> = carSensorsFlow
/**
* Searching inside a media app is experimental and off by default: many apps claim to take
* searches and then ignore them or play something else. The media screen's search button
* shows only once this and [experimentalFeaturesEnabled] are both on.
*/
val mediaSearchEnabled: StateFlow<Boolean> = mediaSearchFlow
/** /**
* Whether the car screen's own "drive safely" warning has already been shown and dismissed * Whether the car screen's own "drive safely" warning has already been shown and dismissed
* once, from this same install. Separate from the phone side's own compat/safety dialogs - * once, from this same install. Separate from the phone side's own compat/safety dialogs -
@@ -206,6 +293,11 @@ object CarSettings {
autoplayFlow.value = AppSettings.getString(app, KEY_AUTOPLAY, "false") == "true" autoplayFlow.value = AppSettings.getString(app, KEY_AUTOPLAY, "false") == "true"
playerTitleFlow.value = AppSettings.getString(app, KEY_PLAYER_TITLE, "false") == "true" playerTitleFlow.value = AppSettings.getString(app, KEY_PLAYER_TITLE, "false") == "true"
pipeAudioFlow.value = AppSettings.getString(app, KEY_PIPE_AUDIO, "true") == "true" pipeAudioFlow.value = AppSettings.getString(app, KEY_PIPE_AUDIO, "true") == "true"
// Until the driver sets it, on for USB audio and off for Bluetooth, where the map app's
// own voice already reaches the car.
speakDirectionsFlow.value = AppSettings.getString(app, KEY_SPEAK_DIRECTIONS, pipeAudioFlow.value.toString()) == "true"
bluetoothWarningFlow.value = AppSettings.getString(app, KEY_BLUETOOTH_WARNING, "true") == "true"
locationWarningFlow.value = AppSettings.getString(app, KEY_LOCATION_WARNING, "true") == "true"
nightFlow.value = runCatching { nightFlow.value = runCatching {
NightMode.valueOf(AppSettings.getString(app, KEY_NIGHT, NightMode.AUTO.name)) NightMode.valueOf(AppSettings.getString(app, KEY_NIGHT, NightMode.AUTO.name))
}.getOrDefault(NightMode.AUTO) }.getOrDefault(NightMode.AUTO)
@@ -214,13 +306,23 @@ object CarSettings {
appOrderFlow.value = AppSettings.getString(app, KEY_APP_ORDER, "") appOrderFlow.value = AppSettings.getString(app, KEY_APP_ORDER, "")
.split(",").filter { it.isNotEmpty() } .split(",").filter { it.isNotEmpty() }
experimentalFeaturesFlow.value = AppSettings.getString(app, KEY_EXPERIMENTAL_FEATURES, "false") == "true" experimentalFeaturesFlow.value = AppSettings.getString(app, KEY_EXPERIMENTAL_FEATURES, "false") == "true"
voiceAssistantFlow.value = AppSettings.getString(app, KEY_VOICE_ASSISTANT, "false") == "true" systemSpeechFlow.value = AppSettings.getString(app, KEY_SYSTEM_SPEECH, "false") == "true"
carSensorsFlow.value = AppSettings.getString(app, KEY_CAR_SENSORS, "false") == "true" carSensorsFlow.value = AppSettings.getString(app, KEY_CAR_SENSORS, "false") == "true"
mediaSearchFlow.value = AppSettings.getString(app, KEY_MEDIA_SEARCH, "false") == "true"
seenSafetyWarningFlow.value = AppSettings.getString(app, KEY_SEEN_SAFETY_WARNING, "false") == "true" seenSafetyWarningFlow.value = AppSettings.getString(app, KEY_SEEN_SAFETY_WARNING, "false") == "true"
unitsFlow.value = runCatching { unitsFlow.value = runCatching {
Units.valueOf(AppSettings.getString(app, KEY_UNITS, Units.AUTO.name)) Units.valueOf(AppSettings.getString(app, KEY_UNITS, Units.AUTO.name))
}.getOrDefault(Units.AUTO) }.getOrDefault(Units.AUTO)
screenSizeFlow.value = ScreenSize.parse(AppSettings.getString(app, KEY_SCREEN_SIZE, ""))
screenFitFlow.value = runCatching {
ScreenFit.valueOf(AppSettings.getString(app, KEY_SCREEN_FIT, ScreenFit.CROP.name))
}.getOrDefault(ScreenFit.CROP)
previewSizeFlow.value = runCatching {
PreviewSize.valueOf(AppSettings.getString(app, KEY_PREVIEW_SIZE, PreviewSize.LAST_CAR.name))
}.getOrDefault(PreviewSize.LAST_CAR)
previewCustomFlow.value = ScreenSize.parse(AppSettings.getString(app, KEY_PREVIEW_CUSTOM, ""))
navBarFlow.value = runCatching {
NavBarMode.valueOf(AppSettings.getString(app, KEY_NAV_BAR, NavBarMode.SHOW.name))
}.getOrDefault(NavBarMode.SHOW)
whatsNewFlow.value = AppSettings.getString(app, KEY_WHATS_NEW, "true") == "true" whatsNewFlow.value = AppSettings.getString(app, KEY_WHATS_NEW, "true") == "true"
whatsNewSeenFlow.value = AppSettings.getString(app, KEY_WHATS_NEW_SEEN, "0").toIntOrNull() ?: 0 whatsNewSeenFlow.value = AppSettings.getString(app, KEY_WHATS_NEW_SEEN, "0").toIntOrNull() ?: 0
} }
@@ -265,9 +367,32 @@ object CarSettings {
AppSettings.putString(app, KEY_AUTOPLAY, on.toString()) AppSettings.putString(app, KEY_AUTOPLAY, on.toString())
} }
/**
* Takes effect at once: switching to Bluetooth mid-drive stops the capture, and switching
* back asks for it again (or waits for the car, if none is connected).
*/
fun setPipeAudio(on: Boolean) { fun setPipeAudio(on: Boolean) {
pipeAudioFlow.value = on // Saved even when unchanged, so picking the default still counts as a choice.
AppSettings.putString(app, KEY_PIPE_AUDIO, on.toString()) AppSettings.putString(app, KEY_PIPE_AUDIO, on.toString())
if (pipeAudioFlow.value == on) return
pipeAudioFlow.value = on
if (AppSettings.getString(app, KEY_SPEAK_DIRECTIONS, "") == "") speakDirectionsFlow.value = on
if (on) CarAudio.request() else CarAudio.release()
}
fun setSpeakDirections(on: Boolean) {
speakDirectionsFlow.value = on
AppSettings.putString(app, KEY_SPEAK_DIRECTIONS, on.toString())
}
fun setBluetoothWarning(on: Boolean) {
bluetoothWarningFlow.value = on
AppSettings.putString(app, KEY_BLUETOOTH_WARNING, on.toString())
}
fun setLocationWarning(on: Boolean) {
locationWarningFlow.value = on
AppSettings.putString(app, KEY_LOCATION_WARNING, on.toString())
} }
fun setExperimentalFeaturesEnabled(on: Boolean) { fun setExperimentalFeaturesEnabled(on: Boolean) {
@@ -275,9 +400,9 @@ object CarSettings {
AppSettings.putString(app, KEY_EXPERIMENTAL_FEATURES, on.toString()) AppSettings.putString(app, KEY_EXPERIMENTAL_FEATURES, on.toString())
} }
fun setVoiceAssistantEnabled(on: Boolean) { fun setSystemSpeech(on: Boolean) {
voiceAssistantFlow.value = on systemSpeechFlow.value = on
AppSettings.putString(app, KEY_VOICE_ASSISTANT, on.toString()) AppSettings.putString(app, KEY_SYSTEM_SPEECH, on.toString())
} }
fun setCarSensorsEnabled(on: Boolean) { fun setCarSensorsEnabled(on: Boolean) {
@@ -285,11 +410,6 @@ object CarSettings {
AppSettings.putString(app, KEY_CAR_SENSORS, on.toString()) AppSettings.putString(app, KEY_CAR_SENSORS, on.toString())
} }
fun setMediaSearchEnabled(on: Boolean) {
mediaSearchFlow.value = on
AppSettings.putString(app, KEY_MEDIA_SEARCH, on.toString())
}
fun setNightMode(mode: NightMode) { fun setNightMode(mode: NightMode) {
nightFlow.value = mode nightFlow.value = mode
AppSettings.putString(app, KEY_NIGHT, mode.name) AppSettings.putString(app, KEY_NIGHT, mode.name)
@@ -5,6 +5,8 @@ import app.seb3thehacker.gearslip.car.theme.*
import android.os.Build import android.os.Build
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@@ -51,16 +53,21 @@ fun CarSettingsScreen() {
val open by CarSettings.openOnConnect.collectAsState() val open by CarSettings.openOnConnect.collectAsState()
val night by CarSettings.nightMode.collectAsState() val night by CarSettings.nightMode.collectAsState()
val units by CarSettings.units.collectAsState() val units by CarSettings.units.collectAsState()
val navBar by CarSettings.navBarMode.collectAsState()
val screenSize by CarSettings.screenSize.collectAsState()
val screenFit by CarSettings.screenFit.collectAsState()
val whatsNew by CarSettings.whatsNewAfterUpdates.collectAsState() val whatsNew by CarSettings.whatsNewAfterUpdates.collectAsState()
val pipeAudio by CarSettings.pipeAudio.collectAsState() val pipeAudio by CarSettings.pipeAudio.collectAsState()
val speakDirections by CarSettings.speakDirections.collectAsState()
val bluetoothWarning by CarSettings.bluetoothWarning.collectAsState()
val locationWarning by CarSettings.locationWarning.collectAsState()
val autoplay by CarSettings.autoplay.collectAsState() val autoplay by CarSettings.autoplay.collectAsState()
val playerTitle by CarSettings.playerTitle.collectAsState() val playerTitle by CarSettings.playerTitle.collectAsState()
val theme by CarSettings.appTheme.collectAsState() val theme by CarSettings.appTheme.collectAsState()
val uiTheme by CarSettings.uiTheme.collectAsState() val uiTheme by CarSettings.uiTheme.collectAsState()
val experimentalFeatures by CarSettings.experimentalFeaturesEnabled.collectAsState() val experimentalFeatures by CarSettings.experimentalFeaturesEnabled.collectAsState()
val voiceAssistant by CarSettings.voiceAssistantEnabled.collectAsState()
val carSensors by CarSettings.carSensorsEnabled.collectAsState() val carSensors by CarSettings.carSensorsEnabled.collectAsState()
val mediaSearch by CarSettings.mediaSearchEnabled.collectAsState() val systemSpeech by CarSettings.systemSpeech.collectAsState()
val display by CarEnvironment.display.collectAsState() val display by CarEnvironment.display.collectAsState()
val vehicle by CarEnvironment.vehicle.collectAsState() val vehicle by CarEnvironment.vehicle.collectAsState()
val context = LocalContext.current val context = LocalContext.current
@@ -68,108 +75,215 @@ fun CarSettingsScreen() {
value = withContext(Dispatchers.Default) { CertSummary.read(context) } value = withContext(Dispatchers.Default) { CertSummary.read(context) }
} }
Column( LazyColumn(
Modifier Modifier.fillMaxSize(),
.fillMaxSize() // Built as it scrolls into view rather than all at once: every row here is a set of
.verticalScroll(rememberScrollState()) // buttons, and building the whole screen made opening Settings visibly slow.
.padding(horizontal = 24.dp, vertical = 16.dp), contentPadding = PaddingValues(horizontal = 24.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(20.dp), verticalArrangement = Arrangement.spacedBy(20.dp),
) { ) {
ScreenHeader("Settings") item {
ScreenHeader("Settings")
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) }
ChoiceRow(
"Size",
listOf(1f to "Normal", 1.25f to "Large", 1.5f to "Extra large"),
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) }
ChoiceRow(
"Theme",
GsThemes.builtIn.map { it.id to it.name },
uiTheme,
) { CarSettings.setUiTheme(it) }
ChoiceRow(
"Map day/night",
listOf(NightMode.AUTO to "Auto", NightMode.DAY to "Day", NightMode.NIGHT to "Night"),
night,
) { CarSettings.setNightMode(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) }
ChoiceRow(
"Player in the nav bar",
listOf(false to "Skip buttons", true to "Song title"),
playerTitle,
) { CarSettings.setPlayerTitle(it) }
ChoiceRow(
"Autoplay on connect",
listOf(false to "Off", true to "On"),
autoplay,
) { CarSettings.setAutoplay(it) }
ChoiceRow(
"Media audio",
listOf(false to "Phone audio", true to "Car audio"),
pipeAudio,
) { CarSettings.setPipeAudio(it) }
Text(
"Car audio sends the phone's sound to the car over USB. Android asks for permission " +
"when the car connects, and a few apps block it. Phone audio plays as the phone " +
"normally would, for example over Bluetooth to the car.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
WebPageSetting()
ScreenFit(vehicle)
ChoiceRow(
"Experimental features",
listOf(false to "Hide", true to "Show"),
experimentalFeatures,
) { CarSettings.setExperimentalFeaturesEnabled(it) }
if (experimentalFeatures) {
ExperimentalFeaturesList(voiceAssistant, carSensors, mediaSearch)
} }
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { item {
Text("Connection", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) ChoiceRow(
Info("Phone", "${Build.MANUFACTURER} ${Build.MODEL}") "Open on connect",
Info("Vehicle", vehicle.ifEmpty { "-" }) listOf(CarSettings.HOME to "Home") + CarApps.all.filter { it.id != "settings" }.map { it.id to it.label },
Info("Video", display.ifEmpty { "-" }) open,
Info("Certificate", cert?.headline ?: "Checking…") ) { CarSettings.setOpenOnConnect(it) }
} }
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { item {
Text("About", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) ChoiceRow(
Info("Version", app.seb3thehacker.gearslip.BuildConfig.VERSION_NAME) "Size",
Info("Build", app.seb3thehacker.gearslip.BuildConfig.VERSION_CODE.toString()) listOf(1f to "Normal", 1.25f to "Large", 1.5f to "Extra large"),
scale,
) { CarSettings.setScale(it) }
}
item {
ChoiceRow(
"App theme",
listOf(AppTheme.PHONE to "Follow phone", AppTheme.LIGHT to "Light", AppTheme.DARK to "Dark"),
theme,
) { CarSettings.setAppTheme(it) }
}
item {
ChoiceRow(
"Theme",
GsThemes.builtIn.map { it.id to it.name },
uiTheme,
) { CarSettings.setUiTheme(it) }
}
item {
ChoiceRow(
"Map day/night",
listOf(NightMode.AUTO to "Auto", NightMode.DAY to "Day", NightMode.NIGHT to "Night"),
night,
) { CarSettings.setNightMode(it) }
}
item {
ChoiceRow(
"Units",
listOf(Units.AUTO to "Auto", Units.IMPERIAL to "Imperial (mi, ft, °F)", Units.METRIC to "Metric (km, m, °C)"),
units,
) { CarSettings.setUnits(it) }
}
// For a head unit that reports the wrong size and stretches the picture (issue #12).
item {
ChoiceRow(
"Screen size (next connection)",
listOf<ScreenSize?>(null).plus(SCREEN_PRESETS).plus(screenSize).distinct()
.map { it to (it?.toString() ?: "From the car") },
screenSize,
) { CarSettings.setScreenSize(it) }
}
if (screenSize != null) item {
ChoiceRow(
"Fit to the screen",
listOf(ScreenFit.CROP to "Crop the edges", ScreenFit.SQUASH to "Squash to fit"),
screenFit,
) { CarSettings.setScreenFit(it) }
}
item {
ChoiceRow(
"Nav bar",
listOf(
NavBarMode.SHOW to "Always show",
NavBarMode.HIDE_NAVIGATING to "Hide while navigating",
NavBarMode.HIDE to "Hide until tapped",
),
navBar,
) { CarSettings.setNavBarMode(it) }
}
item {
ChoiceRow(
"Player in the nav bar",
listOf(false to "Skip buttons", true to "Song title"),
playerTitle,
) { CarSettings.setPlayerTitle(it) }
}
item {
ChoiceRow(
"Autoplay on connect",
listOf(false to "Off", true to "On"),
autoplay,
) { CarSettings.setAutoplay(it) }
}
item {
ChoiceRow(
"Car audio",
listOf(false to "Bluetooth", true to "USB"),
pipeAudio,
) { CarSettings.setPipeAudio(it) }
}
item {
Text(
"USB sends the phone's sound through Gearslip. Android asks to share your screen each " +
"drive, and a few apps block it. Bluetooth plays as the phone normally would, and " +
"Android never asks.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (!pipeAudio) item {
ChoiceRow(
"Warn when Bluetooth isn't connected",
listOf(false to "Off", true to "On"),
bluetoothWarning,
) { CarSettings.setBluetoothWarning(it) }
}
item {
ChoiceRow(
"Warn when location is off",
listOf(false to "Off", true to "On"),
locationWarning,
) { CarSettings.setLocationWarning(it) }
}
item {
ChoiceRow(
"Spoken directions",
listOf(false to "Off", true to "On"),
speakDirections,
) { CarSettings.setSpeakDirections(it) }
}
item {
Text(
"Gearslip reads each turn aloud through the car's speakers, and the music dips while it " +
"speaks. Turn it off if your map app's own voice already plays in the car.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
item {
ExperimentalFeatureRow( ExperimentalFeatureRow(
"What's new after updates", title = "Use the phone's speech recognition",
"Show the list of changes once, the first time the car connects after an update.", description = "Listens through the phone's own speech service instead of Gearslip's. On " +
whatsNew, "most phones that's Google's, which may send your voice to Google. GrapheneOS has " +
) { CarSettings.setWhatsNewAfterUpdates(it) } "none, so Gearslip keeps using its own.",
GsButton(onClick = CarSettings::openWhatsNew, tone = GsTone.Tonal, modifier = Modifier.height(56.dp)) { checked = systemSpeech,
Text("What's new", style = MaterialTheme.typography.titleMedium) ) { CarSettings.setSystemSpeech(it) }
}
item {
WebPageSetting()
}
item {
ScreenFit(vehicle)
}
item {
ChoiceRow(
"Experimental features",
listOf(false to "Hide", true to "Show"),
experimentalFeatures,
) { CarSettings.setExperimentalFeaturesEnabled(it) }
}
if (experimentalFeatures) item {
ExperimentalFeaturesList(carSensors)
}
item {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Connection", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
Info("Phone", "${Build.MANUFACTURER} ${Build.MODEL}")
Info("Vehicle", vehicle.ifEmpty { "-" })
Info("Video", display.ifEmpty { "-" })
Info("Certificate", cert?.headline ?: "Checking…")
}
}
item {
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)
}
} }
} }
} }
@@ -205,21 +319,11 @@ private fun <T> ChoiceRow(title: String, options: List<Pair<T, String>>, selecte
* so the list reads fine whether there's one entry or several. * so the list reads fine whether there's one entry or several.
*/ */
@Composable @Composable
private fun ExperimentalFeaturesList(voiceAssistant: Boolean, carSensors: Boolean, mediaSearch: Boolean) { private fun ExperimentalFeaturesList(carSensors: Boolean) {
Column( Column(
Modifier.fillMaxWidth(), Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(10.dp), verticalArrangement = Arrangement.spacedBy(10.dp),
) { ) {
ExperimentalFeatureRow(
title = "Voice assistant",
description = "Push-to-talk and the car's voice button start listening: navigate, " +
"play media, call, or open an app by saying so. Can mishear you or act on the " +
"wrong command - keep your eyes on the road, and confirm anything it does " +
"before you rely on it.",
warning = true,
checked = voiceAssistant,
) { CarSettings.setVoiceAssistantEnabled(it) }
ExperimentalFeatureRow( ExperimentalFeatureRow(
title = "Vehicle data", title = "Vehicle data",
description = "Adds a Vehicle data tile: a live, raw dump of the head unit's sensor " + description = "Adds a Vehicle data tile: a live, raw dump of the head unit's sensor " +
@@ -228,13 +332,6 @@ private fun ExperimentalFeaturesList(voiceAssistant: Boolean, carSensors: Boolea
"guess, not a fact, until you've watched one change for yourself.", "guess, not a fact, until you've watched one change for yourself.",
checked = carSensors, checked = carSensors,
) { CarSettings.setCarSensorsEnabled(it) } ) { CarSettings.setCarSensorsEnabled(it) }
ExperimentalFeatureRow(
title = "Media search",
description = "Adds a search button to the media screen. Some apps ignore the search " +
"or play something else.",
checked = mediaSearch,
) { CarSettings.setMediaSearchEnabled(it) }
} }
} }
@@ -272,7 +369,7 @@ private fun ExperimentalFeatureRow(
} }
} }
/** The URL (or raw HTML) the Web app shows. Edited with [CarKeyboard] - no phone in hand needed. */ /** The URL (or raw HTML) the Browser app shows. Edited with [CarKeyboard] - no phone in hand needed. */
@Composable @Composable
private fun WebPageSetting() { private fun WebPageSetting() {
val context = LocalContext.current val context = LocalContext.current
@@ -297,7 +394,7 @@ private fun WebPageSetting() {
) )
} }
Text( Text(
"Shown by the Web app on the launcher. Applies on the next connection.", "Shown by the Browser app on the launcher. Applies on the next connection.",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@@ -389,3 +486,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),
)
@@ -0,0 +1,62 @@
package app.seb3thehacker.gearslip.car
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalAccessibilityManager
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withTimeoutOrNull
/** Draw inside the encoded car display; a normal Android Toast would appear on the phone. */
@Composable
internal fun CarToastOverlay(modifier: Modifier = Modifier) {
val current by CarToasts.message.collectAsState()
val message = current ?: return
val accessibility = LocalAccessibilityManager.current
LaunchedEffect(message, accessibility) {
val timeout = accessibility?.calculateRecommendedTimeoutMillis(
message.durationMs, containsIcons = false, containsText = true, containsControls = false,
) ?: message.durationMs
// The full time, unless another toast is waiting: then it gives way after the minimum.
delay(minOf(timeout, CarToasts.MIN_SHOWN_MS))
withTimeoutOrNull((timeout - CarToasts.MIN_SHOWN_MS).coerceAtLeast(0)) {
CarToasts.waiting.first { it > 0 }
}
CarToasts.dismiss(message)
}
// A non-interactive overlay leaves the map's touch handling and focus intact.
Box(
modifier
.padding(horizontal = 24.dp, vertical = 16.dp)
.widthIn(max = 560.dp)
.clip(RoundedCornerShape(20.dp))
.background(MaterialTheme.colorScheme.surfaceContainerHigh)
.semantics { liveRegion = LiveRegionMode.Polite },
) {
Text(
message.text,
modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp),
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.titleMedium,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
}
}
@@ -0,0 +1,65 @@
package app.seb3thehacker.gearslip.car
import androidx.annotation.MainThread
import androidx.car.app.CarToast
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* One toast at a time across navigation and browse apps. A newer one waits its turn instead of
* replacing the one on screen, which gets at least [MIN_SHOWN_MS] before it gives way.
*/
@MainThread
internal object CarToasts {
// Identity matters: repeating identical text must restart its display timer.
class Message(val owner: Any, val text: String, val durationMs: Long)
/** Long enough to read a short toast at a glance; a waiting toast cuts the rest short. */
const val MIN_SHOWN_MS = 2_000L
/** A burst beyond this many is stale by the time it would show; the oldest waiting go first. */
private const val MAX_WAITING = 3
private val current = MutableStateFlow<Message?>(null)
val message: StateFlow<Message?> = current.asStateFlow()
private val queue = ArrayDeque<Message>()
private val _waiting = MutableStateFlow(0)
val waiting: StateFlow<Int> = _waiting.asStateFlow()
fun show(owner: Any, text: String, duration: Int) {
if (text.isBlank()) return
// Longer than a phone toast: the driver only glances at the screen now and then.
val durationMs = if (duration == CarToast.LENGTH_LONG) 7_000L else 4_000L
val next = Message(owner, text, durationMs)
val shown = current.value
when {
shown == null -> current.value = next
// The same words again restart the one on screen rather than queueing a copy.
shown.text == text -> current.value = next
queue.lastOrNull()?.text == text -> Unit
else -> {
queue.addLast(next)
while (queue.size > MAX_WAITING) queue.removeFirst()
}
}
_waiting.value = queue.size
}
fun dismiss(message: Message) {
// An old timer must not dismiss a newer toast, even if its text is identical.
if (current.value === message) advance()
}
fun clear(owner: Any) {
// Disconnecting the map must not clear a toast belonging to the browse app.
queue.removeAll { it.owner === owner }
if (current.value?.owner === owner) advance() else _waiting.value = queue.size
}
private fun advance() {
current.value = queue.removeFirstOrNull()
_waiting.value = queue.size
}
}
@@ -1,7 +1,11 @@
package app.seb3thehacker.gearslip.car package app.seb3thehacker.gearslip.car
import androidx.compose.material.icons.filled.Call
import app.seb3thehacker.gearslip.call.CarCalls
import app.seb3thehacker.gearslip.call.CallUiState
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
@@ -43,6 +47,10 @@ import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider 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.State
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -118,14 +126,19 @@ fun CarUi() {
while (true) { while (true) {
delay(250) delay(250)
controlsWake.tick() controlsWake.tick()
CarFullscreen.tick()
} }
} }
// The knob counts as a touch: the map's buttons can't be found while they're faded out.
LaunchedEffect(controlsWake) { CarKeys.focusMoves.collect { controlsWake.touch() } }
remember(appContext) { Prefetch.warm(appContext, force = true); CarServices.init(appContext) } remember(appContext) { Prefetch.warm(appContext, force = true); CarServices.init(appContext) }
DisposableEffect(Unit) { onDispose { CarServices.shutdown() } } DisposableEffect(Unit) { onDispose { CarServices.shutdown() } }
// Ask for the phone's sound as soon as the car connects, not only when a media app opens: // Ask for the phone's sound as soon as the car connects, not only when a media app opens:
// a video in the web browser or a map's voice needs it too. Asks once, after the car offers // a video in the web browser or a map's voice needs it too. Asks once, after the car offers
// somewhere to play it. // somewhere to play it.
LaunchedEffect(Unit) { if (CarSettings.pipeAudio.value) app.seb3thehacker.gearslip.audio.CarAudio.request() } LaunchedEffect(Unit) { if (CarSettings.pipeAudio.value) app.seb3thehacker.gearslip.audio.CarAudio.request() }
// A new version gets one card in the top right, a few seconds in and only while parked.
LaunchedEffect(Unit) { app.seb3thehacker.gearslip.update.UpdateChecker.onCarConnected(appContext) }
// The map follows the light outside, whatever the app's own theme is set to. // The map follows the light outside, whatever the app's own theme is set to.
LaunchedEffect(darkOutside) { CarServices.nav.pushConfiguration() } LaunchedEffect(darkOutside) { CarServices.nav.pushConfiguration() }
@@ -150,14 +163,27 @@ fun CarUi() {
VoiceReply.state.value.phase != VoiceReply.Phase.IDLE -> VoiceReply.cancel() VoiceReply.state.value.phase != VoiceReply.Phase.IDLE -> VoiceReply.cancel()
else -> navigator.back() else -> navigator.back()
} }
CarKeys.ScreenKey.HOME, CarKeys.ScreenKey.MAP -> navigator.home() // Home is the dashboard, over the map, so Back from it lands on the map.
CarKeys.ScreenKey.MEDIA -> if (CarServices.mediaApp.value != null) navigator.media() else navigator.apps() CarKeys.ScreenKey.HOME -> { navigator.home(); navigator.dashboard() }
CarKeys.ScreenKey.MAP -> navigator.home()
CarKeys.ScreenKey.MEDIA ->
if (CarServices.mediaApp.value != null || CarServices.reopenLastMedia()) navigator.media()
else navigator.apps()
CarKeys.ScreenKey.PHONE -> BuiltInApps.Phone.open(navigator) CarKeys.ScreenKey.PHONE -> BuiltInApps.Phone.open(navigator)
} }
} }
} }
// The car screen is drawn on the phone, so every long press or text-handle drag would buzz
// the phone in its mount. Nobody feels that while driving; it's only noise.
val view = LocalView.current
DisposableEffect(view) {
view.isHapticFeedbackEnabled = false
onDispose { }
}
CompositionLocalProvider( CompositionLocalProvider(
LocalHapticFeedback provides NoHaptics,
LocalDensity provides Density(base.density * scale, base.fontScale), LocalDensity provides Density(base.density * scale, base.fontScale),
LocalCarNavigator provides navigator, LocalCarNavigator provides navigator,
LocalDarkOutside provides darkOutside, LocalDarkOutside provides darkOutside,
@@ -165,15 +191,31 @@ fun CarUi() {
LocalGsTheme provides GsThemes.byId(uiThemeId), LocalGsTheme provides GsThemes.byId(uiThemeId),
) { ) {
MaterialTheme(colorScheme = colors) { MaterialTheme(colorScheme = colors) {
CompositionLocalProvider(LocalIndication provides FocusRingIndication(MaterialTheme.colorScheme.primary)) {
Surface( Surface(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
// Watches every touch without taking any: first in line, consumes nothing. // Watches every touch without taking any: first in line, consumes nothing.
.pointerInput(controlsWake) { .pointerInput(controlsWake) {
// In full screen, a swipe that starts at the top or bottom edge brings the
// nav bar back. Only from the very edge, so scrolling a page still scrolls.
val edge = 32.dp.toPx()
val slop = 24.dp.toPx()
var startY = -1f
awaitPointerEventScope { awaitPointerEventScope {
while (true) { while (true) {
awaitPointerEvent(PointerEventPass.Initial) val event = awaitPointerEvent(PointerEventPass.Initial)
controlsWake.touch() controlsWake.touch()
CarFullscreen.touch()
val change = event.changes.firstOrNull() ?: continue
if (!CarFullscreen.active) continue
if (change.pressed && !change.previousPressed) {
val y = change.position.y
startY = if (y < edge || y > size.height - edge) y else -1f
} else if (startY >= 0f && change.pressed && kotlin.math.abs(change.position.y - startY) > slop) {
CarFullscreen.reveal()
startY = -1f
}
} }
} }
}, },
@@ -188,6 +230,26 @@ fun CarUi() {
} }
val screen = navigator.current val screen = navigator.current
val safetyWarningSeen by CarSettings.hasSeenSafetyWarning.collectAsState() val safetyWarningSeen by CarSettings.hasSeenSafetyWarning.collectAsState()
val keyboardVisible by CarKeyboardVisibility.state
// Off "Always show", the bar floats over the map 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 floatingBar = navBarMode != NavBarMode.SHOW
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)
val homeInset = if (floatingBar && barShown) NAV_BAR_HEIGHT else 0.dp
// Only the screen that asked: going Home or anywhere else ends it.
val fullscreen = CarFullscreen.active && screen != CarScreen.Home
Box(Modifier.fillMaxSize().padding(pad)) { Box(Modifier.fillMaxSize().padding(pad)) {
Column(Modifier.fillMaxSize()) { Column(Modifier.fillMaxSize()) {
if (!safetyWarningSeen) { if (!safetyWarningSeen) {
@@ -200,12 +262,29 @@ fun CarUi() {
// Home never leaves the composition; other screens cover it. Dropping it // Home never leaves the composition; other screens cover it. Dropping it
// would tear down the map's surface, and coming back would then show black // would tear down the map's surface, and coming back would then show black
// while the nav app redrew on a new one. // while the nav app redrew on a new one.
CarHome() CompositionLocalProvider(LocalNavBarInset provides homeInset) { CarHome() }
}
// The keyboard takes the nav bar's row instead of squeezing in above it -
// nothing on the nav bar is useful mid-type, and the screen is too small to
// show both.
if (!floatingBar && !keyboardVisible) CarNavBar(navigator)
}
// 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.
if (safetyWarningSeen) {
Box(
Modifier
.fillMaxSize()
.padding(bottom = if (!floatingBar && !keyboardVisible && !fullscreen) NAV_BAR_HEIGHT else 0.dp),
) {
if (screen != CarScreen.Home) { if (screen != CarScreen.Home) {
Box( Box(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.background(MaterialTheme.colorScheme.background) .background(MaterialTheme.colorScheme.background)
// The floating bar never hides here, so keep the screen clear of it.
.padding(bottom = if (floatingBar && !keyboardVisible && !fullscreen) NAV_BAR_HEIGHT else 0.dp)
// Keeps touches that miss this screen's controls off the map. // Keeps touches that miss this screen's controls off the map.
.pointerInput(Unit) { detectTapGestures { } }, .pointerInput(Unit) { detectTapGestures { } },
) { ) {
@@ -224,6 +303,9 @@ fun CarUi() {
} }
} }
} }
CarToastOverlay(Modifier.align(Alignment.BottomCenter).padding(bottom = homeInset))
CallPromptOverlay()
BluetoothPairOverlay()
PopupTimer() PopupTimer()
// The dashboard is already listing them, so it needs no popup. // The dashboard is already listing them, so it needs no popup.
NotificationPopup( NotificationPopup(
@@ -231,31 +313,58 @@ fun CarUi() {
modifier = Modifier.align(Alignment.TopEnd), modifier = Modifier.align(Alignment.TopEnd),
) )
// Above the popup: a call is more urgent than any notification. // Above the popup: a call is more urgent than any notification.
CallOverlay(Modifier.align(Alignment.TopCenter)) VoiceHelpOverlay(Modifier.align(Alignment.Center))
AssistantOverlay(Modifier.align(Alignment.TopCenter)) CallOverlay()
AssistantOverlay(Modifier.align(Alignment.BottomCenter).padding(bottom = homeInset))
if (app.seb3thehacker.gearslip.BuildConfig.DEBUG) { if (app.seb3thehacker.gearslip.BuildConfig.DEBUG) {
// Bottom corner, not top: a screen's own header almost always puts // Bottom corner, not top: a screen's own header almost always puts
// something in the top-right (settings, search, a Liked Songs // something in the top-right (settings, search, a Liked Songs
// shortcut) and this badge used to sit right on top of it, hiding // shortcut) and this badge used to sit right on top of it, hiding
// whatever the screen drew there. // whatever the screen drew there.
DevBuildBadge(Modifier.align(Alignment.BottomEnd)) DebugBuildBadge(Modifier.align(Alignment.BottomEnd).padding(bottom = homeInset))
}
if (floatingBar || fullscreen) {
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("Exit full screen", style = MaterialTheme.typography.titleMedium)
}
}
} }
} }
// The keyboard takes the nav bar's row instead of squeezing in above it -
// nothing on the nav bar is useful mid-type, and the screen is too small to
// show both.
val keyboardVisible by CarKeyboardVisibility.state
if (!keyboardVisible) CarNavBar(navigator)
} }
// Over everything, nav bar included: a long press on an app, anywhere. // Over everything, nav bar included: a long press on an app, anywhere.
AppMenu(navigator, rememberRunningApps()) AppMenu(navigator, rememberRunningApps())
if (safetyWarningSeen) CarWhatsNew() if (safetyWarningSeen) CarWhatsNew()
} }
} }
}
} }
} }
} }
private val NAV_BAR_HEIGHT = 56.dp
/**
* How much of the bottom of Home the floating nav bar covers right now: its height while it shows,
* zero while it's hidden or when it has a row of its own.
*/
internal val LocalNavBarInset = androidx.compose.runtime.compositionLocalOf { 0.dp }
/** /**
* A single blocking screen, shown once per install before anything else the car screen can show - * A single blocking screen, shown once per install before anything else the car screen can show -
* Home included. The phone's own setup guide carries the same warning, but a driver who plugs in * Home included. The phone's own setup guide carries the same warning, but a driver who plugs in
@@ -354,7 +463,7 @@ private fun AppWontStart(app: String, onBack: () -> Unit) {
* that matters while driving, since a debug build is slower and less tested than what ships. * that matters while driving, since a debug build is slower and less tested than what ships.
*/ */
@Composable @Composable
private fun DevBuildBadge(modifier: Modifier = Modifier) { private fun DebugBuildBadge(modifier: Modifier = Modifier) {
Surface( Surface(
modifier = modifier.padding(6.dp), modifier = modifier.padding(6.dp),
color = MaterialTheme.colorScheme.error, color = MaterialTheme.colorScheme.error,
@@ -362,7 +471,7 @@ private fun DevBuildBadge(modifier: Modifier = Modifier) {
shape = MaterialTheme.shapes.small, shape = MaterialTheme.shapes.small,
) { ) {
Text( Text(
"DEV BUILD", "DEBUG BUILD",
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp),
@@ -434,7 +543,7 @@ private fun CarNavBar(navigator: CarNavigator) {
val running = rememberRunningApps() val running = rememberRunningApps()
Surface(color = MaterialTheme.colorScheme.surfaceContainer) { Surface(color = MaterialTheme.colorScheme.surfaceContainer) {
BoxWithConstraints(Modifier.fillMaxWidth().height(56.dp).padding(horizontal = 10.dp)) { BoxWithConstraints(Modifier.fillMaxWidth().height(NAV_BAR_HEIGHT).padding(horizontal = 10.dp)) {
Row(Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) { Row(Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) {
// Home and Apps are two different places now, not a toggle on one button - the // Home and Apps are two different places now, not a toggle on one button - the
// breadcrumb trail is how you retrace your steps, but reaching for either of // breadcrumb trail is how you retrace your steps, but reaching for either of
@@ -443,12 +552,8 @@ private fun CarNavBar(navigator: CarNavigator) {
NavHomeButton(navigator, entries, running) NavHomeButton(navigator, entries, running)
Spacer(Modifier.width(6.dp)) Spacer(Modifier.width(6.dp))
NavItem(MediaIcons.Apps, navigator.current == CarScreen.Apps) { navigator.apps() } NavItem(MediaIcons.Apps, navigator.current == CarScreen.Apps) { navigator.apps() }
val experimentalFeatures by CarSettings.experimentalFeaturesEnabled.collectAsState() Spacer(Modifier.width(6.dp))
val voiceEnabled by CarSettings.voiceAssistantEnabled.collectAsState() NavAssistantIcon()
if (experimentalFeatures && voiceEnabled) {
Spacer(Modifier.width(6.dp))
NavAssistantIcon()
}
// Pinned shortcuts, then whatever else is open right now. A pinned app that's also // 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. // open stays in its pinned spot, marked with a dot, instead of showing up twice.
@@ -464,12 +569,16 @@ private fun CarNavBar(navigator: CarNavigator) {
// Next turn first, off the map only; the player shares the space when it fits, // Next turn first, off the map only; the player shares the space when it fits,
// and gives way to the turn when it doesn't. // and gives way to the turn when it doesn't.
// A call takes the player's place until it ends.
val turn = rememberNextTurn().takeIf { navigator.current != CarScreen.Home } 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) { BoxWithConstraints(Modifier.weight(1f).padding(horizontal = 16.dp), contentAlignment = Alignment.Center) {
val roomForBoth = maxWidth >= TURN_AND_PLAYER_MIN_WIDTH val roomForBoth = maxWidth >= TURN_AND_PLAYER_MIN_WIDTH
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
turn?.let { NavTurnPill(it) { navigator.home() } } if (call == null || roomForBoth) turn?.let { NavTurnPill(it) { navigator.home() } }
if (turn == null || roomForBoth) { 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)) NavNowPlaying(navigator, selected = navigator.current == CarScreen.Media, modifier = Modifier.weight(1f, fill = false))
} }
} }
@@ -736,6 +845,58 @@ private fun NavTurnPill(turn: NextTurn, onClick: () -> Unit) {
} }
} }
/** 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 ?: "On a 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,
if (muted) "Unmute" else "Mute",
CarCalls::toggleMute,
tone = if (muted) GsTone.Primary else GsTone.Neutral, size = 36.dp, iconSize = 22.dp,
)
GsIconButton(
MediaIcons.CallEnd, "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. */ /** "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 = private fun nowPlayingLabel(title: String, artist: String): String =
listOf(title.trim(), artist.trim()).filter { it.isNotEmpty() }.joinToString(" - ") listOf(title.trim(), artist.trim()).filter { it.isNotEmpty() }.joinToString(" - ")
@@ -879,3 +1040,8 @@ private fun ChargingGlyph(modifier: Modifier = Modifier, tint: Color = MaterialT
drawPath(bolt, tint) 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) {}
}
@@ -0,0 +1,221 @@
package app.seb3thehacker.gearslip.car
import androidx.car.app.model.CarText
import androidx.car.app.model.Distance
import androidx.car.app.navigation.model.Maneuver
import androidx.car.app.navigation.model.NavigationTemplate
import androidx.car.app.navigation.model.RoutingInfo
import androidx.car.app.navigation.model.Step
import app.seb3thehacker.gearslip.GearslipLog
import app.seb3thehacker.gearslip.Protobuf
/**
* The next turn on the car's instrument cluster, over its NavigationStatusService (Service
* field 8), the way Android Auto feeds it:
*
* -> NavigationStatus { status = 1 (1 active, 2 inactive, 3 rerouting) }
* -> NavigationNextTurnEvent { road = 1; turn_side = 2; event = 3; turn_number = 5; turn_angle = 6 }
* -> NavigationNextTurnDistanceEvent { distance_meters = 1; time_to_turn_seconds = 2;
* display_distance_e3 = 3; display_distance_unit = 4 }
*
* Newer cars also take a NavigationState message; the deprecated pair above is what a 2018
* Uconnect understands. The turn comes from the map app's trip (what the Car App Library hands
* to clusters), or from its routing card when it sends no trip.
*/
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)
GearslipLog.i("cluster: <- ChannelOpenResponse: status=$status")
if (status == 0) start()
}
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)")
}
}
private fun start() {
if (running) return
running = true
worker = Thread({
while (running) {
runCatching { tick() }.onFailure { GearslipLog.w("cluster: ${it.message}") }
try {
Thread.sleep(updateIntervalMs)
} catch (_: InterruptedException) {
return@Thread
}
}
}, "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
turn.rerouting -> STATUS_REROUTING
else -> STATUS_ACTIVE
}
if (status != sentStatus) {
sendOnChannel(MSG_STATUS, Protobuf.varintField(1, status.toLong()))
GearslipLog.i("cluster: -> ${STATUS_NAMES[status]}")
sentStatus = status
if (status == STATUS_INACTIVE) { sentTurn = null; sentDistance = null }
}
if (turn == null || status == STATUS_REROUTING) return
sendTurn(turn.step)
turn.distance?.let { sendDistance(it, turn.seconds) }
}
private fun sendTurn(step: Step) {
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}|${maneuver?.roundaboutExitAngle}"
if (key == sentTurn) return
sentTurn = key
var body = Protobuf.stringField(1, road) +
Protobuf.varintField(2, side.toLong()) +
Protobuf.varintField(3, event.toLong())
maneuver?.roundaboutExitNumber?.takeIf { it > 0 }?.let { body += Protobuf.varintField(5, it.toLong()) }
maneuver?.roundaboutExitAngle?.takeIf { it > 0 }?.let { body += Protobuf.varintField(6, it.toLong()) }
sendOnChannel(MSG_TURN, body)
GearslipLog.i("cluster: -> turn event=$event side=$side road=\"$road\"")
}
/**
* The Car App Library's distance units use the same numbers as the protocol's (1 metres ...
* 7 yards), so the app's own display choice passes straight through.
*/
private fun sendDistance(distance: Distance, seconds: Long?) {
val meters = NavTurn.toMeters(distance)
val key = "${distance.displayDistance}|${distance.displayUnit}|${seconds ?: -1}"
if (key == sentDistance) return
sentDistance = key
sendOnChannel(
MSG_DISTANCE,
Protobuf.varintField(1, meters.toLong()) +
Protobuf.varintField(2, (seconds ?: 0L).coerceAtLeast(0)) +
Protobuf.varintField(3, (distance.displayDistance * 1000).toLong()) +
Protobuf.varintField(4, distance.displayUnit.toLong()),
)
}
private fun CarText?.text() = this?.toString().orEmpty()
companion object {
private const val TICK_MS = 500L
private const val MSG_CHANNEL_OPEN_RESPONSE = 8
private const val MSG_START = 32769
private const val MSG_STOP = 32770
private const val MSG_STATUS = 32771
private const val MSG_TURN = 32772
private const val MSG_DISTANCE = 32773
private const val STATUS_ACTIVE = 1
private const val STATUS_INACTIVE = 2
private const val STATUS_REROUTING = 3
private val STATUS_NAMES = mapOf(1 to "navigating", 2 to "not navigating", 3 to "rerouting")
private const val SIDE_LEFT = 1
private const val SIDE_RIGHT = 2
private const val SIDE_UNSPECIFIED = 3
private const val EVENT_UNKNOWN = 0
/** The car's turn icon and side for a Car App Library manoeuvre. */
fun eventFor(type: Int): Pair<Int, Int> = when (type) {
Maneuver.TYPE_DEPART -> 1 to SIDE_UNSPECIFIED
Maneuver.TYPE_NAME_CHANGE -> 2 to SIDE_UNSPECIFIED
Maneuver.TYPE_KEEP_LEFT, Maneuver.TYPE_TURN_SLIGHT_LEFT -> 3 to SIDE_LEFT
Maneuver.TYPE_KEEP_RIGHT, Maneuver.TYPE_TURN_SLIGHT_RIGHT -> 3 to SIDE_RIGHT
Maneuver.TYPE_TURN_NORMAL_LEFT -> 4 to SIDE_LEFT
Maneuver.TYPE_TURN_NORMAL_RIGHT -> 4 to SIDE_RIGHT
Maneuver.TYPE_TURN_SHARP_LEFT -> 5 to SIDE_LEFT
Maneuver.TYPE_TURN_SHARP_RIGHT -> 5 to SIDE_RIGHT
Maneuver.TYPE_U_TURN_LEFT -> 6 to SIDE_LEFT
Maneuver.TYPE_U_TURN_RIGHT -> 6 to SIDE_RIGHT
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 -> 7 to SIDE_LEFT
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 -> 7 to SIDE_RIGHT
Maneuver.TYPE_OFF_RAMP_SLIGHT_LEFT, Maneuver.TYPE_OFF_RAMP_NORMAL_LEFT -> 8 to SIDE_LEFT
Maneuver.TYPE_OFF_RAMP_SLIGHT_RIGHT, Maneuver.TYPE_OFF_RAMP_NORMAL_RIGHT -> 8 to SIDE_RIGHT
Maneuver.TYPE_FORK_LEFT -> 9 to SIDE_LEFT
Maneuver.TYPE_FORK_RIGHT -> 9 to SIDE_RIGHT
Maneuver.TYPE_MERGE_LEFT -> 10 to SIDE_LEFT
Maneuver.TYPE_MERGE_RIGHT -> 10 to SIDE_RIGHT
Maneuver.TYPE_MERGE_SIDE_UNSPECIFIED -> 10 to SIDE_UNSPECIFIED
Maneuver.TYPE_ROUNDABOUT_ENTER_CW, Maneuver.TYPE_ROUNDABOUT_ENTER_CCW -> 11 to SIDE_UNSPECIFIED
Maneuver.TYPE_ROUNDABOUT_EXIT_CW, Maneuver.TYPE_ROUNDABOUT_EXIT_CCW -> 12 to SIDE_UNSPECIFIED
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 -> 13 to SIDE_UNSPECIFIED
Maneuver.TYPE_STRAIGHT -> 14 to SIDE_UNSPECIFIED
Maneuver.TYPE_FERRY_BOAT, Maneuver.TYPE_FERRY_BOAT_LEFT, Maneuver.TYPE_FERRY_BOAT_RIGHT -> 16 to SIDE_UNSPECIFIED
Maneuver.TYPE_FERRY_TRAIN, Maneuver.TYPE_FERRY_TRAIN_LEFT, Maneuver.TYPE_FERRY_TRAIN_RIGHT -> 17 to SIDE_UNSPECIFIED
Maneuver.TYPE_DESTINATION, Maneuver.TYPE_DESTINATION_STRAIGHT -> 19 to SIDE_UNSPECIFIED
Maneuver.TYPE_DESTINATION_LEFT -> 19 to SIDE_LEFT
Maneuver.TYPE_DESTINATION_RIGHT -> 19 to SIDE_RIGHT
else -> EVENT_UNKNOWN to SIDE_UNSPECIFIED
}
}
}
/** One turn as the car wants it, from whichever source the map app fills. */
internal class NavTurn(val step: Step, val distance: Distance?, val seconds: Long?, val rerouting: Boolean) {
companion object {
/** The map app's next turn: from its trip (what clusters get), or its routing card. */
fun current(): NavTurn? {
val nav = CarServices.navOrNull() ?: return null
nav.trip.value?.let { trip ->
val step = trip.steps.firstOrNull() ?: return@let
val estimate = trip.stepTravelEstimates.firstOrNull()
return NavTurn(step, estimate?.remainingDistance, estimate?.remainingTimeSeconds?.takeIf { it >= 0 }, trip.isLoading)
}
val info = (nav.template.value as? NavigationTemplate)?.navigationInfo as? RoutingInfo ?: return null
val step = info.currentStep ?: return null
return NavTurn(step, info.currentDistance, null, info.isLoading)
}
fun toMeters(d: Distance): Int = (d.displayDistance * when (d.displayUnit) {
Distance.UNIT_KILOMETERS, Distance.UNIT_KILOMETERS_P1 -> 1000.0
Distance.UNIT_MILES, Distance.UNIT_MILES_P1 -> 1609.344
Distance.UNIT_FEET -> 0.3048
Distance.UNIT_YARDS -> 0.9144
else -> 1.0
}).toInt()
}
}
@@ -0,0 +1,351 @@
package app.seb3thehacker.gearslip.car
import android.content.Context
import android.os.Bundle
import android.speech.tts.TextToSpeech
import android.speech.tts.UtteranceProgressListener
import androidx.car.app.model.CarText
import androidx.car.app.model.Distance
import androidx.car.app.navigation.model.Maneuver
import androidx.car.app.navigation.model.Step
import app.seb3thehacker.gearslip.GearslipLog
import app.seb3thehacker.gearslip.audio.AudioLink
import java.io.File
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CountDownLatch
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import kotlin.math.roundToInt
/**
* Spoken turn-by-turn on the car's directions channel (the GUIDANCE audio sink).
*
* A map app on the phone speaks its own prompts as navigation audio, which Android can't hand to
* Gearslip without root, and which the phone mutes while USB audio runs. So Gearslip reads the
* 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. 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.
*/
object DirectionsVoice {
private enum class Stage { FAR, PREPARED, DONE }
private var appContext: Context? = null
private var tts: TextToSpeech? = null
@Volatile private var ttsReady = false
private val pending = ConcurrentHashMap<String, CountDownLatch>()
@Volatile private var link: AudioLink? = null
@Volatile private var running = false
@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
private var stage = Stage.DONE
fun init(context: Context) {
if (tts != null) return
appContext = context.applicationContext
tts = TextToSpeech(appContext) { status -> ttsReady = status == TextToSpeech.SUCCESS }.apply {
setOnUtteranceProgressListener(object : UtteranceProgressListener() {
override fun onStart(utteranceId: String?) {}
override fun onDone(utteranceId: String?) { pending.remove(utteranceId)?.countDown() }
@Deprecated("Deprecated in Java, no replacement on this API level")
override fun onError(utteranceId: String?) { pending.remove(utteranceId)?.countDown() }
})
}
}
/** The car opened its directions sink: start following the map app's turns. */
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
turnKey = null
Thread({
while (running) {
runCatching { tick() }.onFailure { GearslipLog.w("guidance: ${it.message}") }
Thread.sleep(TICK_MS)
}
}, "gearslip-directions-watch").apply { isDaemon = true }.start()
}
fun detach(link: AudioLink) {
if (this.link !== link) return
this.link = null
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() {
val turn = NavTurn.current()
if (turn == null) {
turnKey = null
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 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)
val key = "${step.road.text()}|${step.maneuver?.type}|${step.maneuver?.roundaboutExitNumber}|${step.cue.text()}"
if (key != turnKey) {
turnKey = key
if (step.maneuver?.type == Maneuver.TYPE_DEPART) {
stage = Stage.DONE
return
}
if (close || turn.distance == null) {
say(instruction)
stage = Stage.DONE
} else {
say("In ${spoken(turn.distance)}, $instruction")
stage = if (meters != null && meters > PREPARE_FROM_METERS) Stage.FAR else Stage.PREPARED
}
return
}
when {
stage != Stage.DONE && close -> {
say(instruction)
stage = Stage.DONE
}
stage == Stage.FAR && meters != null && meters <= PREPARE_AT_METERS -> {
say("In ${spoken(turn.distance)}, $instruction")
stage = Stage.PREPARED
}
}
}
/** 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 road = step.road.text()
val onto = if (road.isBlank()) "" else " onto $road"
val toward = if (road.isBlank()) "" else " toward $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_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_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_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"
else -> null
}
return built ?: step.cue.text().ifBlank { null }
}
/** The app's own rounded distance, read the way a person would say it. */
private fun spoken(d: Distance): String {
val n = d.displayDistance
val number = if (n % 1.0 == 0.0) n.toInt().toString() else "%.1f".format(n)
val one = n == 1.0
return when (d.displayUnit) {
Distance.UNIT_KILOMETERS, Distance.UNIT_KILOMETERS_P1 -> if (one) "1 kilometer" else "$number kilometers"
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"
}
Distance.UNIT_FEET -> "$number feet"
Distance.UNIT_YARDS -> "$number yards"
else -> "${n.roundToInt()} meters"
}
}
/** Newest wins: a prompt still waiting when the next one arrives is already out of date. */
private fun say(text: String) {
val id = ++latest
speaker.execute {
if (id != latest) return@execute
runCatching { play(text, id) }.onFailure { GearslipLog.w("guidance: could not speak a turn: ${it.message}") }
}
}
private fun play(text: String, id: Int) {
val engine = tts ?: return
val context = appContext ?: return
val sink = link ?: return
if (!ttsReady) {
GearslipLog.w("guidance: text-to-speech isn't ready, a turn went unspoken")
return
}
val file = File(context.cacheDir, "directions.wav")
val utterance = "gearslip-directions-$id"
val done = CountDownLatch(1)
pending[utterance] = done
if (engine.synthesizeToFile(text, Bundle(), file, utterance) != TextToSpeech.SUCCESS) {
pending.remove(utterance)
return
}
if (!done.await(SYNTH_TIMEOUT_S, TimeUnit.SECONDS)) {
pending.remove(utterance)
GearslipLog.w("guidance: text-to-speech took too long, a turn went unspoken")
return
}
val pcm = Wav.read(file)?.let { Wav.convert(it, sink.sampleRate, sink.channels) } ?: run {
GearslipLog.w("guidance: text-to-speech gave audio Gearslip can't read")
return
}
// 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)")
}
/** Start, the words in 20 ms chunks at about real time, a little silence, Stop. */
private fun stream(sink: AudioLink, pcm: ByteArray) {
val frame = 2 * sink.channels
val chunk = sink.sampleRate / 50 * frame
val chunkMs = 20L
val padded = pcm + ByteArray(sink.sampleRate / 5 * frame) // 200 ms tail so the last word isn't clipped
sink.start()
try {
val began = System.currentTimeMillis()
var offset = 0
var sent = 0
while (offset < padded.size && link === sink) {
val size = minOf(chunk, padded.size - offset)
sink.write(padded.copyOfRange(offset, offset + size), size)
offset += size
sent++
val ahead = sent * chunkMs - (System.currentTimeMillis() - began)
if (ahead > LEAD_MS) Thread.sleep(ahead - LEAD_MS)
}
// Let the car play what it has before closing the stream on it.
Thread.sleep(LEAD_MS)
} finally {
sink.stop()
}
}
private fun CarText?.text() = this?.toString().orEmpty()
/** 16-bit PCM from the WAV text-to-speech writes, reshaped for the car's sink. */
private object Wav {
class Pcm(val samples: ShortArray, val rate: Int, val channels: Int)
fun read(file: File): Pcm? {
val bytes = file.readBytes()
if (bytes.size < 12 || String(bytes, 0, 4) != "RIFF" || String(bytes, 8, 4) != "WAVE") return null
val buf = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN)
var pos = 12
var rate = 0
var channels = 0
var bits = 0
while (pos + 8 <= bytes.size) {
val name = String(bytes, pos, 4)
val size = buf.getInt(pos + 4)
val body = pos + 8
// Some engines write a streaming WAV with a zero or oversized data length.
val end = if (size <= 0 || body + size > bytes.size) bytes.size else body + size
when (name) {
"fmt " -> {
channels = buf.getShort(body + 2).toInt()
rate = buf.getInt(body + 4)
bits = buf.getShort(body + 14).toInt()
}
"data" -> {
if (bits != 16 || rate <= 0 || channels <= 0) return null
val samples = ShortArray((end - body) / 2)
buf.position(body)
buf.asShortBuffer().get(samples, 0, samples.size)
return Pcm(samples, rate, channels)
}
}
pos = end + (end and 1)
}
return null
}
/** Mixes to mono, resamples linearly, then copies to each of the car's channels. */
fun convert(pcm: Pcm, rate: Int, channels: Int): ByteArray {
val frames = pcm.samples.size / pcm.channels
val mono = FloatArray(frames) { f ->
var sum = 0f
for (c in 0 until pcm.channels) sum += pcm.samples[f * pcm.channels + c]
sum / pcm.channels
}
val outFrames = (frames.toLong() * rate / pcm.rate).toInt()
val out = ByteBuffer.allocate(outFrames * channels * 2).order(ByteOrder.LITTLE_ENDIAN)
val step = pcm.rate.toDouble() / rate
for (i in 0 until outFrames) {
val at = i * step
val j = at.toInt().coerceAtMost(frames - 1)
val next = (j + 1).coerceAtMost(frames - 1)
val v = mono[j] + (mono[next] - mono[j]) * (at - j).toFloat()
val s = v.roundToInt().coerceIn(Short.MIN_VALUE.toInt(), Short.MAX_VALUE.toInt()).toShort()
repeat(channels) { out.putShort(s) }
}
return out.array()
}
}
private const val TICK_MS = 500L
private const val NEAR_SECONDS = 12L
private const val NEAR_METERS = 120
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")
}
@@ -0,0 +1,84 @@
package app.seb3thehacker.gearslip.car
import androidx.compose.foundation.IndicationNodeFactory
import androidx.compose.foundation.interaction.FocusInteraction
import androidx.compose.foundation.interaction.InteractionSource
import androidx.compose.material3.ripple
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.ContentDrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.node.DelegatableNode
import androidx.compose.ui.node.DelegatingNode
import androidx.compose.ui.node.DrawModifierNode
import androidx.compose.ui.node.invalidateDraw
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
/**
* What every car button shows: the usual ripple when pressed, and a ring in the accent color
* while the knob or D-pad is on it. Cars without a knob never see the ring, since nothing takes
* focus while the screen is in touch mode.
*
* It replaces the theme's ripple (LocalIndication) for the whole car UI, so any `clickable` that
* doesn't pass its own indication gets the ring for free.
*/
internal class FocusRingIndication(private val color: Color) : IndicationNodeFactory {
private val ripple = ripple()
override fun create(interactionSource: InteractionSource): DelegatableNode =
FocusRingNode(ripple.create(interactionSource), interactionSource, color)
override fun equals(other: Any?) = other is FocusRingIndication && other.color == color
override fun hashCode() = color.hashCode()
}
private class FocusRingNode(
rippleNode: DelegatableNode,
private val source: InteractionSource,
private val color: Color,
) : DelegatingNode(), DrawModifierNode {
private var focused = false
init { delegate(rippleNode) }
override fun onAttach() {
coroutineScope.launch {
var holding = 0
source.interactions.collect {
when (it) {
is FocusInteraction.Focus -> holding++
is FocusInteraction.Unfocus -> holding--
}
val now = holding > 0
if (now != focused) {
focused = now
invalidateDraw()
}
}
}
}
override fun ContentDrawScope.draw() {
drawContent()
if (!focused) return
// Inside the button's bounds, since most buttons clip to their shape: drawn outside, a
// rounded tile would cut its own ring off.
val width = RING_WIDTH.toPx()
val radius = minOf(CORNER.toPx(), size.minDimension / 2)
drawRoundRect(
color,
topLeft = Offset(width / 2, width / 2),
size = Size(size.width - width, size.height - width),
cornerRadius = CornerRadius(radius),
style = Stroke(width),
)
}
private companion object {
val RING_WIDTH = 3.dp
val CORNER = 16.dp
}
}
@@ -0,0 +1,93 @@
package app.seb3thehacker.gearslip.car
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.location.LocationManager
import android.os.Handler
import android.os.Looper
import app.seb3thehacker.gearslip.GearslipLog
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* Map apps can't find the car with the phone's location turned off. Ten seconds after the car
* shows Gearslip, this checks, and the car says so until location comes on or the drive ends.
*/
object LocationCheck {
private val _off = MutableStateFlow(false)
/** True while the phone's location is turned off during a drive. */
val off: StateFlow<Boolean> = _off
private val _carDismissed = MutableStateFlow(false)
/** The driver tapped OK on the car's card; it stays away for the rest of this drive. */
val carDismissed: StateFlow<Boolean> = _carDismissed
private val log = GearslipLog.tagged("location")
private val main = Handler(Looper.getMainLooper())
private var appContext: Context? = null
private var armed = false
private var receiver: BroadcastReceiver? = null
fun init(context: Context) {
appContext = context.applicationContext
}
/** The car shows Gearslip. Called again whenever the video restarts, so it arms once per drive. */
fun onProjecting() = main.post {
if (armed) return@post
armed = true
main.postDelayed(::start, WAIT_MS)
}
fun onCarDisconnected() = main.post {
armed = false
main.removeCallbacksAndMessages(null)
stopWatching()
_off.value = false
_carDismissed.value = false
}
fun dismissOnCar() {
_carDismissed.value = true
}
/** "Don't show again" on the car: turns the warning off in Settings, for every car. */
fun neverAgain() {
CarSettings.setLocationWarning(false)
log.i("location warning turned off")
_carDismissed.value = true
_off.value = false
}
private fun start() {
if (!armed) return
check()
// Turning location on clears the warning, and turning it off again brings it back.
val context = appContext ?: return
val r = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) = check()
}
context.registerReceiver(r, IntentFilter(LocationManager.MODE_CHANGED_ACTION), Context.RECEIVER_EXPORTED)
receiver = r
}
private fun check() {
if (!armed) return
val manager = appContext?.getSystemService(LocationManager::class.java) ?: return
val off = CarSettings.locationWarning.value && !manager.isLocationEnabled
if (_off.value == off) return
_off.value = off
log.i(if (off) "the phone's location is off" else "the phone's location is on")
}
private fun stopWatching() {
val r = receiver ?: return
receiver = null
runCatching { appContext?.unregisterReceiver(r) }
}
private const val WAIT_MS = 10_000L
}
@@ -11,27 +11,35 @@ import android.os.SystemClock
* *
* They fade out after [IDLE_MS] with no touch anywhere on the car screen, to leave the map clear, * They fade out after [IDLE_MS] with no touch anywhere on the car screen, to leave the map clear,
* and any touch brings them back. [touch] is called for every pointer event and is cheap: it only * and any touch brings them back. [touch] is called for every pointer event and is cheap: it only
* writes state when the buttons were actually hidden. * writes state when the buttons were actually hidden. The nav bar, when set to hide, goes the
* same way but waits longer ([NAV_BAR_IDLE_MS]).
*/ */
class MapControlsWake { class MapControlsWake {
var awake by mutableStateOf(true) var awake by mutableStateOf(true)
private set private set
var navBarAwake by mutableStateOf(true)
private set
private var lastTouch = SystemClock.uptimeMillis() private var lastTouch = SystemClock.uptimeMillis()
fun touch() { fun touch() {
lastTouch = SystemClock.uptimeMillis() lastTouch = SystemClock.uptimeMillis()
if (!awake) awake = true if (!awake) awake = true
if (!navBarAwake) navBarAwake = true
} }
/** Called on a timer; hides the buttons once nothing has touched the screen for [IDLE_MS]. */ /** Called on a timer; hides the buttons once nothing has touched the screen for [IDLE_MS]. */
fun tick() { fun tick() {
if (awake && SystemClock.uptimeMillis() - lastTouch >= IDLE_MS) awake = false val idle = SystemClock.uptimeMillis() - lastTouch
if (awake && idle >= IDLE_MS) awake = false
if (navBarAwake && idle >= NAV_BAR_IDLE_MS) navBarAwake = false
} }
companion object { companion object {
const val IDLE_MS = 4_000L const val IDLE_MS = 4_000L
const val NAV_BAR_IDLE_MS = 8_000L
} }
} }
@@ -61,7 +61,6 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.seb3thehacker.gearslip.audio.CarAudio
import app.seb3thehacker.gearslip.media.CarMedia import app.seb3thehacker.gearslip.media.CarMedia
import app.seb3thehacker.gearslip.media.MediaApp import app.seb3thehacker.gearslip.media.MediaApp
import app.seb3thehacker.gearslip.media.MediaArt import app.seb3thehacker.gearslip.media.MediaArt
@@ -88,11 +87,9 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) {
val queue by media.queue.collectAsStateWithLifecycle() val queue by media.queue.collectAsStateWithLifecycle()
val browse by media.browse.collectAsStateWithLifecycle() val browse by media.browse.collectAsStateWithLifecycle()
val rejection by media.rejection.collectAsStateWithLifecycle() val rejection by media.rejection.collectAsStateWithLifecycle()
val capture by CarAudio.capture.collectAsStateWithLifecycle()
val search by media.search.collectAsStateWithLifecycle() val search by media.search.collectAsStateWithLifecycle()
var tab by remember { mutableStateOf(Tab.NOW_PLAYING) } var tab by remember { mutableStateOf(Tab.NOW_PLAYING) }
var searching by remember { mutableStateOf(false) } var searching by remember { mutableStateOf(false) }
val searchAllowed = CarSettings.experimentalFeaturesEnabled.collectAsState().value && CarSettings.mediaSearchEnabled.collectAsState().value
// The connection outlives this screen: the home screen's player is the same one. // The connection outlives this screen: the home screen's player is the same one.
LaunchedEffect(app) { CarServices.openMedia(app) } LaunchedEffect(app) { CarServices.openMedia(app) }
@@ -174,7 +171,6 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) {
} else { } else {
CompactPlayer(now, media, Modifier.weight(1f).fillMaxWidth()) CompactPlayer(now, media, Modifier.weight(1f).fillMaxWidth())
} }
AudioBadge(capture)
} }
Column(Modifier.weight(1.2f).fillMaxHeight()) { Column(Modifier.weight(1.2f).fillMaxHeight()) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
@@ -182,7 +178,7 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) {
MediaTab("Browse", tab == Tab.BROWSE, Modifier.weight(1f)) { tab = Tab.BROWSE } MediaTab("Browse", tab == Tab.BROWSE, Modifier.weight(1f)) { tab = Tab.BROWSE }
MediaTab("Up next", tab == Tab.QUEUE, Modifier.weight(1f)) { tab = Tab.QUEUE } MediaTab("Up next", tab == Tab.QUEUE, Modifier.weight(1f)) { tab = Tab.QUEUE }
MediaTab("Lyrics", tab == Tab.LYRICS, Modifier.weight(1f)) { tab = Tab.LYRICS } MediaTab("Lyrics", tab == Tab.LYRICS, Modifier.weight(1f)) { tab = Tab.LYRICS }
if (searchAllowed && (now.canSearch || media.canSearchLibrary)) { if (now.canSearch || media.canSearchLibrary) {
GsIconButton( GsIconButton(
Icons.Filled.Search, Icons.Filled.Search,
"Search ${app.label}", "Search ${app.label}",
@@ -447,17 +443,6 @@ private sealed interface LyricsState {
class Found(val result: LyricsResult) : LyricsState class Found(val result: LyricsResult) : LyricsState
} }
@Composable
private fun AudioBadge(capture: CarAudio.Capture) {
val text = when (capture) {
CarAudio.Capture.ON -> "Audio to car"
CarAudio.Capture.ASKING -> "Allow audio capture on the phone"
CarAudio.Capture.DENIED -> "Audio stays on the phone"
CarAudio.Capture.OFF -> return
}
Text(text, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
@Composable @Composable
private fun Notice(title: String, detail: String?, action: Pair<String, () -> Unit>? = null) { private fun Notice(title: String, detail: String?, action: Pair<String, () -> Unit>? = null) {
Column( Column(
@@ -703,6 +688,12 @@ internal object MediaIcons {
val RepeatOne = icon("RepeatOne", "M7,7h10v3l4,-4 -4,-4v3L5,5v6h2L7,7zM17,17L7,17v-3l-4,4 4,4v-3h12v-6h-2v4zM13,15L13,9h-1l-2,1v1h1.5v4L13,15z") val RepeatOne = icon("RepeatOne", "M7,7h10v3l4,-4 -4,-4v3L5,5v6h2L7,7zM17,17L7,17v-3l-4,4 4,4v-3h12v-6h-2v4zM13,15L13,9h-1l-2,1v1h1.5v4L13,15z")
/** A screen with a filled right-hand panel: "put the player beside the map". */ /** A screen with a filled right-hand panel: "put the player beside the map". */
val DockRight = icon("DockRight", "M2,4h20v16H2zM4,6h10v12H4z", PathFillType.EvenOdd) val DockRight = icon("DockRight", "M2,4h20v16H2zM4,6h10v12H4z", 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( private fun icon(name: String, path: String, fillType: PathFillType = PathFillType.NonZero) = ImageVector.Builder(
name, 24.dp, 24.dp, 24f, 24f, name, 24.dp, 24.dp, 24f, 24f,
@@ -72,7 +72,7 @@ internal class BuiltInApp(
) )
internal object BuiltInApps { internal object BuiltInApps {
val Web = BuiltInApp("web", "Web", Icons.Filled.Search, CarScreen.App("web")) { it.open("web") } 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 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 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 VehicleData = BuiltInApp("vehicle", "Vehicle data", Icons.Filled.Info, CarScreen.VehicleData) { it.vehicleData() }
@@ -8,6 +8,7 @@ import android.content.pm.PackageManager
import android.net.Uri import android.net.Uri
import android.provider.ContactsContract import android.provider.ContactsContract
import android.provider.Settings import android.provider.Settings
import app.seb3thehacker.gearslip.GearslipLog
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
@@ -32,6 +33,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState import androidx.compose.runtime.produceState
@@ -79,14 +81,49 @@ internal fun loadContacts(context: Context): List<Contact> {
return out.distinctBy { it.number } 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) { internal fun placeCall(context: Context, number: String) {
if (number.isBlank()) return if (number.isBlank()) return
if (context.checkSelfPermission(Manifest.permission.CALL_PHONE) != PackageManager.PERMISSION_GRANTED) return if (context.checkSelfPermission(Manifest.permission.CALL_PHONE) != PackageManager.PERMISSION_GRANTED) {
runCatching { GearslipLog.w("calls: can't place a call without CALL_PHONE")
context.startActivity( return
Intent(Intent.ACTION_CALL, Uri.parse("tel:${Uri.encode(number)}")).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
)
} }
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. */
internal object CallPrompt {
val pending = kotlinx.coroutines.flow.MutableStateFlow<Contact?>(null)
fun ask(name: String, number: String) {
pending.value = Contact(name, number)
}
}
/** The dialer's own confirmation, drawn over the whole car screen for [CallPrompt]. */
@Composable
internal fun CallPromptOverlay() {
val context = LocalContext.current
val contact by CallPrompt.pending.collectAsState()
val c = contact ?: return
ConfirmCallOverlay(
c,
onConfirm = {
CallPrompt.pending.value = null
placeCall(context, c.number)
},
onDismiss = { CallPrompt.pending.value = null },
)
} }
private const val TAB_CONTACTS = 0 private const val TAB_CONTACTS = 0
@@ -138,29 +138,47 @@ internal fun PlayModeRow(
val repeatOn = repeatKnown && modes.repeat != PlaybackStateCompat.REPEAT_MODE_NONE val repeatOn = repeatKnown && modes.repeat != PlaybackStateCompat.REPEAT_MODE_NONE
val icon = size * 0.52f val icon = size * 0.52f
Row(modifier, horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.CenterVertically) { Row(modifier, horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.CenterVertically) {
GsIconButton( // An app's own like, shuffle or repeat button shows whether it's on only through its icon,
if (liked == true) Icons.Filled.Favorite else Icons.Filled.FavoriteBorder, // so that icon is drawn, as Android Auto does, and lit when its name says it's on.
if (liked == true) "Unlike" else "Like", // Gearslip's own stand in for apps that report a heart rating or shuffle and repeat modes.
media::toggleLike, val likeAction = now.likeAction
enabled = liked != null, if (likeAction != null) {
tone = if (liked == true) GsTone.Primary else GsTone.Neutral, CustomActionButton(likeAction, media, size, toggle = true)
size = size, iconSize = icon, } else {
) GsIconButton(
GsIconButton( if (liked == true) Icons.Filled.Favorite else Icons.Filled.FavoriteBorder,
MediaIcons.Shuffle, if (shuffleOn) "Shuffle off" else "Shuffle on", if (liked == true) "Unlike" else "Like",
media::toggleShuffle, media::toggleLike,
enabled = shuffleKnown || now.shuffleAction != null, enabled = liked != null,
tone = if (shuffleOn) GsTone.Primary else GsTone.Neutral, tone = if (liked == true) GsTone.Primary else GsTone.Neutral,
size = size, iconSize = icon, size = size, iconSize = icon,
) )
GsIconButton( }
if (modes.repeat == PlaybackStateCompat.REPEAT_MODE_ONE) MediaIcons.RepeatOne else MediaIcons.Repeat, val shuffleAction = now.shuffleAction
"Repeat", if (shuffleAction != null) {
media::cycleRepeat, CustomActionButton(shuffleAction, media, size, toggle = true)
enabled = repeatKnown || now.repeatAction != null, } else {
tone = if (repeatOn) GsTone.Primary else GsTone.Neutral, GsIconButton(
size = size, iconSize = icon, MediaIcons.Shuffle, if (shuffleOn) "Shuffle off" else "Shuffle on",
) media::toggleShuffle,
enabled = shuffleKnown,
tone = if (shuffleOn) GsTone.Primary else GsTone.Neutral,
size = size, iconSize = icon,
)
}
val repeatAction = now.repeatAction
if (repeatAction != null) {
CustomActionButton(repeatAction, media, size, toggle = true)
} else {
GsIconButton(
if (modes.repeat == PlaybackStateCompat.REPEAT_MODE_ONE) MediaIcons.RepeatOne else MediaIcons.Repeat,
"Repeat",
media::cycleRepeat,
enabled = repeatKnown,
tone = if (repeatOn) GsTone.Primary else GsTone.Neutral,
size = size, iconSize = icon,
)
}
now.otherActions.take(maxOther).forEach { CustomActionButton(it, media, size) } now.otherActions.take(maxOther).forEach { CustomActionButton(it, media, size) }
trailing() trailing()
} }
@@ -171,12 +189,12 @@ internal fun PlayModeRow(
* not Gearslip's, so it's loaded from there. Its name stands in if the icon won't load. * not Gearslip's, so it's loaded from there. Its name stands in if the icon won't load.
*/ */
@Composable @Composable
private fun CustomActionButton(action: CustomAction, media: CarMedia, size: Dp) { private fun CustomActionButton(action: CustomAction, media: CarMedia, size: Dp, toggle: Boolean = false) {
val icon = rememberActionIcon(action) val icon = rememberActionIcon(action)
GsIconBox( GsIconBox(
onClick = { media.custom(action) }, onClick = { media.custom(action) },
modifier = Modifier.size(size), modifier = Modifier.size(size),
colors = gsColors(GsTone.Neutral), colors = gsColors(if (toggle && action.on) GsTone.Primary else GsTone.Neutral),
) { ) {
if (icon != null) { if (icon != null) {
Icon(icon, action.name, Modifier.size(size * 0.52f)) Icon(icon, action.name, Modifier.size(size * 0.52f))
@@ -2,6 +2,13 @@ package app.seb3thehacker.gearslip.car
import app.seb3thehacker.gearslip.car.theme.* import app.seb3thehacker.gearslip.car.theme.*
import android.graphics.Rect import android.graphics.Rect
import androidx.compose.foundation.layout.widthIn
import androidx.compose.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.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -10,6 +17,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@@ -86,89 +94,183 @@ 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 * 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. * 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 * A template that isn't map-backed covers the surface completely, and reporting an empty
* rectangle is how the app is told to stop bothering. * rectangle is how the app is told to stop bothering.
*/ */
fun visibleAreaFor(template: Template?, width: Int, height: Int): Rect = when (template) { fun visibleAreaFor(template: Template?, width: Int, height: Int, measured: Rect? = null): Rect = when (template) {
is NavigationTemplate -> Rect(0, 0, width, (height * (1f - ESTIMATE_FRACTION)).toInt()) is NavigationTemplate -> measured ?: Rect(0, 0, width, height)
is MapWithContentTemplate, is MapTemplate, is PlaceListNavigationTemplate, is MapWithContentTemplate, is MapTemplate, is PlaceListNavigationTemplate,
is RoutePreviewNavigationTemplate, is PlaceListMapTemplate, is RoutePreviewNavigationTemplate, is PlaceListMapTemplate,
-> Rect((width * CONTENT_PANE_FRACTION).toInt(), 0, width, height) -> Rect((width * CONTENT_PANE_FRACTION).toInt(), 0, width, measured?.bottom ?: height)
null -> Rect(0, 0, width, height) null -> Rect(0, 0, width, height)
else -> Rect(0, 0, 0, 0) else -> Rect(0, 0, 0, 0)
} }
/** 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, 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): Rect {
var left = 0f
var top = 0f
var right = width.toFloat()
var bottom = height.toFloat()
pieces.values.forEach { (edge, b) ->
when (edge) {
CoverEdge.TOP -> top = maxOf(top, b.bottom - surface.top)
CoverEdge.BOTTOM -> bottom = minOf(bottom, b.top - surface.top)
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 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 ------------------------------------------------------------------------------ // --- navigation ------------------------------------------------------------------------------
@Composable @Composable
private fun NavigationChrome(template: NavigationTemplate, modifier: Modifier) { private fun NavigationChrome(template: NavigationTemplate, modifier: Modifier) {
val pan by CarServices.nav.panMode.collectAsState() val pan by CarServices.nav.panMode.collectAsState()
Box(modifier.fillMaxWidth()) { // Pan mode clears the map for looking around: only the buttons stay.
if (!pan) (template.navigationInfo as? RoutingInfo)?.let { val routing = (template.navigationInfo as? RoutingInfo)?.takeUnless { pan }
RoutingCard(it, Modifier.align(Alignment.TopStart).padding(12.dp).width(320.dp)) val estimate = template.destinationTravelEstimate?.takeUnless { pan }
NavigationBars(template, routing, estimate, modifier)
}
/**
* The turn in one short bar across the top, the arrival time in another along the bottom, so the
* map keeps its full width. Neither grows past [NAV_BAR_MAX_WIDTH]. The bottom one is short and
* left out of the visible area on purpose, so a speedometer in the app's bottom corner sits level
* with it rather than stacked above it. The map buttons sit between them on the right.
*/
@Composable
private fun NavigationBars(
template: NavigationTemplate,
routing: RoutingInfo?,
estimate: TravelEstimate?,
modifier: Modifier,
) {
val pan by CarServices.nav.panMode.collectAsState()
Column(modifier.fillMaxSize()) {
routing?.let {
RoutingBar(
it,
Modifier.covers("nav-top", CoverEdge.TOP).padding(start = 12.dp, top = 12.dp, end = 12.dp)
.widthIn(max = NAV_BAR_MAX_WIDTH).fillMaxWidth(),
)
} }
RightEdgeControls( Row(Modifier.weight(1f).fillMaxWidth()) {
if (pan) null else template.actionStrip, Box(Modifier.weight(1f).fillMaxHeight()) {
template.mapActionStrip, estimate?.let {
Modifier.align(Alignment.TopEnd).padding(12.dp), TravelEstimateBar(
) it,
if (!pan) template.destinationTravelEstimate?.let { Modifier.align(Alignment.BottomStart).padding(12.dp)
TravelEstimateBar(it, Modifier.align(Alignment.BottomStart).padding(12.dp)) .widthIn(max = NAV_BAR_MAX_WIDTH),
)
}
}
RightEdgeControls(
if (pan) null else template.actionStrip,
template.mapActionStrip,
Modifier.padding(12.dp),
// Only the column: the app's own buttons are a wide row up top, over the map's
// edge rather than where a speedometer or the like would go.
mapActionsModifier = Modifier.covers("nav-buttons", CoverEdge.END),
)
} }
} }
} }
/** The manoeuvre card: what to do next, how far away it is, and onto what road. */ /** The manoeuvre in one row: arrow, distance, what to do, onto what road, and lanes or the next turn. */
@Composable @Composable
private fun RoutingCard(info: RoutingInfo, modifier: Modifier = Modifier) { private fun RoutingBar(info: RoutingInfo, modifier: Modifier = Modifier) {
if (info.isLoading) { if (info.isLoading) {
ChromeSurface(modifier) { ChromeSurface(modifier) {
Text("Finding a route…", Modifier.padding(16.dp), style = ChromeType.title) Text("Finding a route…", Modifier.padding(horizontal = 16.dp, vertical = 12.dp), style = ChromeType.title)
} }
return return
} }
val step = info.currentStep ?: return val step = info.currentStep ?: return
ChromeSurface(modifier) { ChromeSurface(modifier) {
Column(Modifier.padding(14.dp)) { Column(Modifier.padding(horizontal = 14.dp, vertical = 10.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
CarGlyph(step.maneuver?.icon, Modifier.size(44.dp)) CarGlyph(step.maneuver?.icon, Modifier.size(40.dp))
Spacer(Modifier.width(12.dp)) Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) { val distance = info.currentDistance.display()
val distance = info.currentDistance.display() if (distance.isNotEmpty()) {
if (distance.isNotEmpty()) { Text(distance, style = ChromeType.headline, maxLines = 1)
Text(distance, style = ChromeType.headline) Spacer(Modifier.width(14.dp))
}
Text(step.cue.text(), style = ChromeType.title, maxLines = 2, overflow = TextOverflow.Ellipsis)
} }
} Column(Modifier.weight(1f)) {
val road = step.road.text() Text(step.cue.text(), style = ChromeType.title, maxLines = 1, overflow = TextOverflow.Ellipsis)
if (road.isNotEmpty()) { val road = step.road.text()
Text( if (road.isNotEmpty()) {
road, Text(
style = ChromeType.body, road,
color = MaterialTheme.colorScheme.onSurfaceVariant, style = ChromeType.body,
maxLines = 1, color = MaterialTheme.colorScheme.onSurfaceVariant,
overflow = TextOverflow.Ellipsis, 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 -> val next = info.nextStep
Spacer(Modifier.height(10.dp)) if (step.lanesImage != null || step.lanes.orEmpty().isNotEmpty()) {
JunctionImage(junction) LaneGuidance(step, Modifier.padding(start = 12.dp).widthIn(max = 200.dp).heightIn(max = 48.dp))
} } else if (next != null) {
LaneGuidance(step, Modifier.padding(top = 10.dp)) Spacer(Modifier.width(12.dp))
info.nextStep?.let { next ->
Spacer(Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Text("then", style = ChromeType.label, color = MaterialTheme.colorScheme.onSurfaceVariant) Text("then", style = ChromeType.label, color = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
CarGlyph(next.maneuver?.icon, Modifier.size(20.dp)) CarGlyph(next.maneuver?.icon, Modifier.size(24.dp))
Spacer(Modifier.width(8.dp))
Text(next.cue.text(), style = ChromeType.body, maxLines = 1, overflow = TextOverflow.Ellipsis)
} }
} }
// Rare, and only where an arrow can't explain the junction: worth the bar growing for.
info.junctionImage?.let { junction ->
Spacer(Modifier.height(8.dp))
JunctionImage(junction)
}
} }
} }
} }
@@ -244,9 +346,8 @@ 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, * 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 * where a thumb reaches - the app's own way to stop navigating. One short line along the bottom,
* full-width bar, so it reads as one thing beside the manoeuvre card above it instead of a strip * stopping short of the map buttons. The close button is Gearslip's own, not the app's: an
* 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 * 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 * 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. * going Home already does, rather than guess at the app's own controls and risk getting it wrong.
@@ -265,7 +366,7 @@ private fun TravelEstimateBar(estimate: TravelEstimate, modifier: Modifier = Mod
trip.takeIf { it.isNotEmpty() }, trip.takeIf { it.isNotEmpty() },
).joinToString(" • ") ).joinToString(" • ")
// The same solid card the manoeuvre card above it uses - one surface for the whole map // The same solid card the manoeuvre bar 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. // chrome, rather than this one spot styled on its own as a bar over the map.
ChromeSurface(modifier, shadowElevation = 8.dp) { ChromeSurface(modifier, shadowElevation = 8.dp) {
val dark = MaterialTheme.colorScheme.surface.luminanceIsDark() val dark = MaterialTheme.colorScheme.surface.luminanceIsDark()
@@ -288,17 +389,23 @@ private fun TravelEstimateBar(estimate: TravelEstimate, modifier: Modifier = Mod
shape = CircleShape, shape = CircleShape,
) )
Spacer(Modifier.width(10.dp)) Spacer(Modifier.width(10.dp))
Column { if (time.isNotEmpty()) {
if (time.isNotEmpty()) { Text(
Text( time,
time, style = ChromeType.headline,
style = ChromeType.headline, color = estimate.remainingTimeColor.color(dark, MaterialTheme.colorScheme.onSurface),
color = estimate.remainingTimeColor.color(dark, MaterialTheme.colorScheme.onSurface), maxLines = 1,
) )
} }
if (detail.isNotEmpty()) { if (detail.isNotEmpty()) {
Text(detail, style = ChromeType.body, color = MaterialTheme.colorScheme.onSurfaceVariant) Spacer(Modifier.width(16.dp))
} Text(
detail,
style = ChromeType.body,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
} }
} }
} }
@@ -454,8 +561,9 @@ private fun ListPane(
/** Fraction of the surface the content pane covers beside a map. */ /** Fraction of the surface the content pane covers beside a map. */
private const val CONTENT_PANE_FRACTION = 0.42f 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 /** Past this the bars only spread the same words further apart, across a map left with no music beside it. */
private val NAV_BAR_MAX_WIDTH = 460.dp
/** Search, sign-in and the like are laid out for a whole column, so they keep one. */ /** Search, sign-in and the like are laid out for a whole column, so they keep one. */
private fun Template?.needsFullHeight() = private fun Template?.needsFullHeight() =
@@ -45,6 +45,7 @@ import androidx.car.app.model.MessageTemplate
import androidx.car.app.model.PaneTemplate import androidx.car.app.model.PaneTemplate
import androidx.car.app.model.Row as CarRow import androidx.car.app.model.Row as CarRow
import androidx.car.app.model.SearchTemplate import androidx.car.app.model.SearchTemplate
import androidx.compose.foundation.clickable
import androidx.car.app.model.Section import androidx.car.app.model.Section
import androidx.car.app.model.SectionedItemTemplate import androidx.car.app.model.SectionedItemTemplate
import androidx.car.app.model.TabTemplate import androidx.car.app.model.TabTemplate
@@ -238,6 +239,9 @@ private fun SearchContent(template: SearchTemplate, modifier: Modifier) {
// initialSearchText would fight whatever is being typed. // initialSearchText would fight whatever is being typed.
var query by remember { mutableStateOf(template.initialSearchText.orEmpty()) } var query by remember { mutableStateOf(template.initialSearchText.orEmpty()) }
val delegate = template.searchCallbackDelegate val delegate = template.searchCallbackDelegate
// An app that opens on results ("navigate to Starbucks") asks for the keyboard to stay down,
// so the results aren't hidden under it. Tapping the field brings it up.
var typing by remember { mutableStateOf(template.isShowKeyboardByDefault) }
ContentSurface(modifier) { ContentSurface(modifier) {
// Back button and field share a row: on a 480px screen the keyboard and the results // Back button and field share a row: on a 480px screen the keyboard and the results
@@ -251,7 +255,7 @@ private fun SearchContent(template: SearchTemplate, modifier: Modifier) {
SearchField( SearchField(
query, query,
template.searchHint.orEmpty(), template.searchHint.orEmpty(),
Modifier.weight(1f), Modifier.weight(1f).clickable { typing = true },
onClear = { onClear = {
query = "" query = ""
delegate.textChanged("") delegate.textChanged("")
@@ -265,14 +269,17 @@ private fun SearchContent(template: SearchTemplate, modifier: Modifier) {
else ItemListColumn(template.itemList, modifier = Modifier.fillMaxSize()) else ItemListColumn(template.itemList, modifier = Modifier.fillMaxSize())
} }
CarKeyboard( if (typing) {
text = query, CarKeyboard(
onTextChange = { text = query,
query = it onTextChange = {
delegate.textChanged(it) query = it
}, delegate.textChanged(it)
onSubmit = { delegate.submitted(query) }, },
) onSubmit = { delegate.submitted(query) },
onDismiss = { typing = false },
)
}
} }
} }
@@ -3,8 +3,10 @@ package app.seb3thehacker.gearslip.car
import app.seb3thehacker.gearslip.car.theme.* import app.seb3thehacker.gearslip.car.theme.*
import androidx.compose.runtime.produceState import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalContentColor
import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -29,6 +31,7 @@ import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Call
import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Warning import androidx.compose.material.icons.filled.Warning
@@ -219,6 +222,8 @@ internal fun RightEdgeControls(
actionStrip: ActionStrip?, actionStrip: ActionStrip?,
mapActionStrip: ActionStrip?, mapActionStrip: ActionStrip?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
/** For the stack of map buttons alone, without the row of the app's own above it. */
mapActionsModifier: Modifier = Modifier,
) { ) {
val actions = actionStrip?.actions.orEmpty().filter { it.isDrawable() } val actions = actionStrip?.actions.orEmpty().filter { it.isDrawable() }
val mapActions = mapActionStrip?.actions.orEmpty().filter { it.isDrawable() } val mapActions = mapActionStrip?.actions.orEmpty().filter { it.isDrawable() }
@@ -231,7 +236,9 @@ internal fun RightEdgeControls(
// the first touch on one wakes them rather than pressing it. // the first touch on one wakes them rather than pressing it.
val awake = LocalMapControlsWake.current.awake val awake = LocalMapControlsWake.current.awake
val pan by CarServices.nav.panMode.collectAsState() val pan by CarServices.nav.panMode.collectAsState()
val visible = awake || pan // 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
val alpha by androidx.compose.animation.core.animateFloatAsState( val alpha by androidx.compose.animation.core.animateFloatAsState(
targetValue = if (visible) 1f else 0f, targetValue = if (visible) 1f else 0f,
animationSpec = androidx.compose.animation.core.tween(if (visible) 150 else 500), animationSpec = androidx.compose.animation.core.tween(if (visible) 150 else 500),
@@ -240,6 +247,7 @@ internal fun RightEdgeControls(
Column( Column(
modifier modifier
.alpha(alpha) .alpha(alpha)
.onFocusChanged { holdsFocus = it.hasFocus }
.let { base -> .let { base ->
if (visible) base else base.pointerInput(Unit) { if (visible) base else base.pointerInput(Unit) {
awaitPointerEventScope { awaitPointerEventScope {
@@ -257,7 +265,15 @@ internal fun RightEdgeControls(
actions.forEach { ActionButton(it) } actions.forEach { ActionButton(it) }
} }
} }
mapActions.forEach { ActionButton(it) } if (mapActions.isNotEmpty()) {
Column(
mapActionsModifier,
horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
mapActions.forEach { ActionButton(it) }
}
}
} }
} }
@@ -468,7 +484,10 @@ internal fun CarGlyph(icon: CarIcon?, modifier: Modifier = Modifier, standard: I
val white = remember(bitmap) { bitmap.isPlainWhite() } val white = remember(bitmap) { bitmap.isPlainWhite() }
// Untinted white artwork is a template-app habit from dark surfaces; it is coloured like // Untinted white artwork is a template-app habit from dark surfaces; it is coloured like
// text so it stays readable on a light one. // text so it stays readable on a light one.
val tint = icon?.tint?.color(dark, content) ?: content.takeIf { white } // An app's own tint is kept only if it can be seen: MAPS.ME asks for dark grey on dark
// buttons. Android Auto drops colours that fail contrast the same way.
val tint = icon?.tint?.color(dark, content)?.takeIf { it.readableOn(content) }
?: content.takeIf { white || icon?.tint != null }
Image( Image(
bitmap, contentDescription = null, modifier = modifier, bitmap, contentDescription = null, modifier = modifier,
colorFilter = tint?.let { ColorFilter.tint(it) }, colorFilter = tint?.let { ColorFilter.tint(it) },
@@ -587,16 +606,141 @@ internal fun PaneRows(pane: Pane?, modifier: Modifier = Modifier, autoStart: Boo
// Some apps pad a pane with a row of blank strings; drawn, it is just a gap. // 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 rows = pane?.rows.orEmpty().filterNot { it.isBlank() }
val actions = pane?.actions.orEmpty().filter { it.isDrawable() } val actions = pane?.actions.orEmpty().filter { it.isDrawable() }
val card = PlaceCard.of(rows)
Column(modifier) { Column(modifier) {
rows.forEach { RowItem(it, large = true) } 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) { if (autoStart && actions.size == 1) {
AutoStartButton(actions.single(), Modifier.padding(horizontal = 14.dp, vertical = 10.dp)) AutoStartButton(actions.single(), Modifier.padding(horizontal = 14.dp, vertical = 10.dp))
} else { } else {
ActionRow(actions, Modifier.padding(horizontal = 14.dp, vertical = 10.dp), large = true) 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 @Composable
internal fun RowItem(row: CarRow, selection: RowSelection? = null, large: Boolean = false) { internal fun RowItem(row: CarRow, selection: RowSelection? = null, large: Boolean = false) {
val toggle = row.toggle val toggle = row.toggle
@@ -661,6 +805,17 @@ internal fun RowItem(row: CarRow, selection: RowSelection? = null, large: Boolea
/** A row's place in a single-choice list: whether it is the chosen one, and what picking it does. */ /** 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) 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 = internal fun Color.luminanceIsDark(): Boolean =
(red * 0.299f + green * 0.587f + blue * 0.114f) < 0.5f (red * 0.299f + green * 0.587f + blue * 0.114f) < 0.5f
@@ -0,0 +1,94 @@
package app.seb3thehacker.gearslip.car
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import app.seb3thehacker.gearslip.car.theme.GsIconButton
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/** What the assistant understands, shown on the car screen when the driver says "help". */
object VoiceHelp {
private val _shown = MutableStateFlow(false)
val shown: StateFlow<Boolean> = _shown.asStateFlow()
fun show() { _shown.value = true }
fun hide() { _shown.value = false }
/** Say this, and this happens. Kept in step with [AssistantCommands]. */
val COMMANDS = listOf(
"Navigate to…" to "Directions there. Add \"with Organic Maps\" to pick the app",
"Play…" to "A song, artist, or album in the open music app",
"Pause · Resume · Next · Previous" to "Control the music",
"What's playing" to "The song and artist",
"Call…" to "A contact by name",
"Open…" to "Any app, like Browser, Phone, or Screen sharing",
"Exit" to "Back to the car's own screen",
"Go home · Open apps · Settings" to "Move around Gearslip",
"What time is it · Weather" to "The time, or the forecast",
"Help" to "This list",
)
}
/** The command list over the car screen: read it at a glance, tap the cross or wait to dismiss. */
@Composable
fun VoiceHelpOverlay(modifier: Modifier = Modifier) {
val shown by VoiceHelp.shown.collectAsState()
if (!shown) return
// A list left up would cover the map for the rest of the drive.
LaunchedEffect(Unit) {
delay(30_000)
VoiceHelp.hide()
}
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHigh,
contentColor = MaterialTheme.colorScheme.onSurface,
shape = MaterialTheme.shapes.extraLarge,
tonalElevation = 6.dp,
shadowElevation = 8.dp,
modifier = modifier.padding(16.dp).widthIn(max = 640.dp),
) {
Column(Modifier.padding(20.dp)) {
Row(Modifier.fillMaxWidth()) {
Text(
"You can say",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f).padding(top = 6.dp),
)
GsIconButton(Icons.Filled.Close, "Close", VoiceHelp::hide)
}
// Full width, so a drag anywhere on the card scrolls, not just one on the words.
Column(
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(top = 8.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
VoiceHelp.COMMANDS.forEach { (say, does) ->
Column(Modifier.fillMaxWidth()) {
Text(say, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
Text(does, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
}
}
}
@@ -2,7 +2,14 @@ package app.seb3thehacker.gearslip.car
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.view.View
import android.view.ViewGroup
import android.webkit.JavascriptInterface 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.WebResourceError
import android.webkit.WebResourceRequest import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse import android.webkit.WebResourceResponse
@@ -12,6 +19,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -19,6 +27,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import app.seb3thehacker.gearslip.GearslipLog import app.seb3thehacker.gearslip.GearslipLog
import org.json.JSONObject import org.json.JSONObject
@@ -38,13 +47,33 @@ import org.json.JSONObject
fun WebApp(content: String) { fun WebApp(content: String) {
val navigator = LocalCarNavigator.current val navigator = LocalCarNavigator.current
val darkOutside = LocalDarkOutside.current val darkOutside = LocalDarkOutside.current
val ring = String.format("#%06X", MaterialTheme.colorScheme.primary.toArgb() and 0xFFFFFF)
var web by remember { mutableStateOf<WebView?>(null) } var web by remember { mutableStateOf<WebView?>(null) }
var fieldText by remember { mutableStateOf<String?>(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
// 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) { DisposableEffect(web) {
navigator.backInterceptor = { navigator.backInterceptor = {
val view = web val view = web
if (view != null && view.canGoBack()) { if (CarFullscreen.active) {
CarFullscreen.exit()
true
} else if (view != null && view.canGoBack()) {
view.goBack() view.goBack()
true true
} else { } else {
@@ -56,16 +85,34 @@ fun WebApp(content: String) {
Column(Modifier.fillMaxSize()) { Column(Modifier.fillMaxSize()) {
// Back steps through the page's history first (the interceptor above), then leaves. // Back steps through the page's history first (the interceptor above), then leaves.
ScreenHeader("Web", Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) if (!fullscreen) {
ScreenHeader("Browser", Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
GsIconButton(MediaIcons.Fullscreen, "Full screen", CarFullscreen::enter)
}
}
Box(Modifier.weight(1f).fillMaxSize()) {
AndroidView( AndroidView(
modifier = Modifier.weight(1f).fillMaxSize(), modifier = Modifier.fillMaxSize(),
factory = { context -> factory = { context ->
WebView(context).apply { WebView(context).apply {
settings.javaScriptEnabled = true settings.javaScriptEnabled = true
settings.domStorageEnabled = true settings.domStorageEnabled = true
settings.loadWithOverviewMode = true settings.loadWithOverviewMode = true
settings.useWideViewPort = true settings.useWideViewPort = true
webViewClient = LoggingClient() webViewClient = LoggingClient(ring)
webChromeClient = object : WebChromeClient() {
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( addJavascriptInterface(
FieldFocusBridge(onFocus = { fieldText = it }, onBlur = { fieldText = null }), FieldFocusBridge(onFocus = { fieldText = it }, onBlur = { fieldText = null }),
"GearslipField", "GearslipField",
@@ -82,6 +129,22 @@ fun WebApp(content: String) {
update = { it.settings.isAlgorithmicDarkeningAllowed = darkOutside }, update = { it.settings.isAlgorithmicDarkeningAllowed = darkOutside },
onRelease = { it.destroy() }, 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() },
)
}
}
}
fieldText?.let { text -> fieldText?.let { text ->
CarKeyboard( CarKeyboard(
text = text, text = text,
@@ -99,10 +162,11 @@ fun WebApp(content: String) {
} }
} }
private class LoggingClient : WebViewClient() { private class LoggingClient(private val ring: String) : WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) { override fun onPageFinished(view: WebView?, url: String?) {
GearslipLog.i("car web page loaded: $url") GearslipLog.i("car web page loaded: $url")
view?.evaluateJavascript(FOCUS_TRACKING_JS, null) view?.evaluateJavascript(FOCUS_TRACKING_JS, null)
view?.evaluateJavascript(focusRingJs(ring), null)
} }
// onPageFinished fires for error pages too, so without this a blocked load (missing // onPageFinished fires for error pages too, so without this a blocked load (missing
@@ -170,6 +234,20 @@ private const val SUBMIT_FIELD_JS = """
})(); })();
""" """
/**
* The same ring the rest of the car UI shows, on whatever link or field the knob is on. Many
* sites hide the browser's own focus outline, which leaves a knob driver with no idea where they are.
*/
private fun focusRingJs(color: String) = """
(function() {
if (document.getElementById('gearslip-focus-ring')) return;
var style = document.createElement('style');
style.id = 'gearslip-focus-ring';
style.textContent = ':focus-visible { outline: 3px solid $color !important; outline-offset: 2px !important; }';
(document.head || document.documentElement).appendChild(style);
})();
"""
/** Reports focus/blur on text inputs, textareas and contenteditable elements to [FieldFocusBridge]. */ /** Reports focus/blur on text inputs, textareas and contenteditable elements to [FieldFocusBridge]. */
private const val FOCUS_TRACKING_JS = """ private const val FOCUS_TRACKING_JS = """
(function() { (function() {
@@ -50,6 +50,8 @@ enum class HoldMarkerStyle {
* colour, so the peel's flap matches whatever it sits on. Round and pill-shaped controls get the * colour, so the peel's flap matches whatever it sits on. Round and pill-shaped controls get the
* dot whatever the theme says: a circle has no corner to peel. * dot whatever the theme says: a circle has no corner to peel.
*/ */
private const val SHOW_HOLD_DOT = false
@Composable @Composable
internal fun BoxScope.GsHoldMarker(style: HoldMarkerStyle, container: Color, shape: Shape, press: InteractionSource) { internal fun BoxScope.GsHoldMarker(style: HoldMarkerStyle, container: Color, shape: Shape, press: InteractionSource) {
if (style == HoldMarkerStyle.NONE) return if (style == HoldMarkerStyle.NONE) return
@@ -68,8 +70,9 @@ internal fun BoxScope.GsHoldMarker(style: HoldMarkerStyle, container: Color, sha
val side = min(size.width, size.height) val side = min(size.width, size.height)
val r = (shape as? CornerBasedShape)?.topEnd?.toPx(size, this) ?: 0f val r = (shape as? CornerBasedShape)?.topEnd?.toPx(size, this) ?: 0f
val round = r >= side * 0.4f val round = r >= side * 0.4f
// The dot is off until it's redesigned: flat themes and round controls show nothing.
if (style == HoldMarkerStyle.DOT || round) { if (style == HoldMarkerStyle.DOT || round) {
drawHoldDot(r, side, ink) if (SHOW_HOLD_DOT) drawHoldDot(r, side, ink)
} else { } else {
// Drawn for the bottom-right corner, then flipped up to the top-right. // Drawn for the bottom-right corner, then flipped up to the top-right.
scale(1f, -1f, pivot = center) { scale(1f, -1f, pivot = center) {
@@ -112,7 +112,7 @@ object GsThemes {
control = ControlLook(holdMarker = HoldMarkerStyle.DOT), control = ControlLook(holdMarker = HoldMarkerStyle.DOT),
) )
val default = Tactile val default = Flat
val builtIn = listOf(Tactile, Keycap, Flat) val builtIn = listOf(Tactile, Keycap, Flat)
fun byId(id: String?): GsTheme = builtIn.firstOrNull { it.id == id } ?: default fun byId(id: String?): GsTheme = builtIn.firstOrNull { it.id == id } ?: default
@@ -0,0 +1,98 @@
package app.seb3thehacker.gearslip.host
import android.os.SystemClock
import android.text.Spanned
import androidx.car.app.model.CarText
import androidx.car.app.model.DistanceSpan
import androidx.car.app.model.ItemList
import androidx.car.app.model.ListTemplate
import androidx.car.app.model.PaneTemplate
import androidx.car.app.model.PlaceListMapTemplate
import androidx.car.app.model.Row
import androidx.car.app.model.SearchTemplate
import androidx.car.app.model.Template
import androidx.car.app.navigation.model.MapTemplate
import androidx.car.app.navigation.model.MapWithContentTemplate
import androidx.car.app.navigation.model.NavigationTemplate
import androidx.car.app.navigation.model.PlaceListNavigationTemplate
import androidx.car.app.navigation.model.RoutePreviewNavigationTemplate
import app.seb3thehacker.gearslip.GearslipLog
/**
* "Navigate to X" the way Android Auto does it: straight onto a route, no list to pick from.
*
* Android Auto looks the place up on Google's servers first. Gearslip stays on the phone, so it
* asks the map app to search, then taps the nearest result in the app's list for the driver.
* It stops at the app's start screen, so the driver sees where it's going and taps Start. It
* gives up after [WINDOW_MS], or as soon as the driver taps anything themselves.
*/
internal class AutoRoute {
private val log = GearslipLog.tagged("route")
private var until = 0L
private var pickedRow = false
/** A destination was just sent to the map app. */
fun arm() {
armedAt = SystemClock.elapsedRealtime()
until = armedAt + WINDOW_MS
pickedRow = false
}
fun disarm() {
until = 0L
}
private var armedAt = 0L
private val armed get() = SystemClock.elapsedRealtime() < until && driverTappedAt < armedAt
/** Each screen the map app sends while armed: tap the next step if it's there. */
@Suppress("DEPRECATION") // Apps still send the older navigation templates.
fun onTemplate(template: Template) {
if (!armed) return
when (template) {
is NavigationTemplate -> disarm()
// The start screen: the driver takes it from here.
is RoutePreviewNavigationTemplate -> arrived()
is MapWithContentTemplate -> onTemplate(template.contentTemplate)
is PaneTemplate -> arrived()
is MapTemplate -> if (template.pane != null) arrived() else template.itemList?.let(::onList)
is SearchTemplate -> if (!template.isLoading) template.itemList?.let(::onList)
is ListTemplate -> if (!template.isLoading) template.singleList?.let(::onList)
is PlaceListMapTemplate -> if (!template.isLoading) template.itemList?.let(::onList)
is PlaceListNavigationTemplate -> if (!template.isLoading) template.itemList?.let(::onList)
}
}
/** Search results: the nearest place, or the first when the app gives no distances. */
private fun onList(list: ItemList) {
if (pickedRow) return
val places = list.items.filterIsInstance<Row>()
.filter { it.isEnabled && it.onClickDelegate != null && !it.isBrowsable }
if (places.isEmpty()) return
val nearest = places.minByOrNull { it.meters() ?: Double.MAX_VALUE } ?: return
pickedRow = true
log.i("picking the nearest of ${places.size} results")
nearest.onClickDelegate.click(AUTO_LABEL)
}
private fun arrived() {
if (pickedRow) log.i("at the start screen")
disarm()
}
/** The first distance the app attached to this row's title or text lines. */
private fun Row.meters(): Double? = (listOfNotNull(title) + texts).firstNotNullOfOrNull { it.distanceMeters() }
private fun CarText.distanceMeters(): Double? {
val chars = toCharSequence() as? Spanned ?: return null
return chars.getSpans(0, chars.length, DistanceSpan::class.java).firstOrNull()?.distance?.meters()
}
companion object {
/** Taps sent by this class carry this label, so they don't count as the driver's. */
const val AUTO_LABEL = "auto-route"
/** When the driver last tapped one of the app's buttons; that hands control back. */
@Volatile var driverTappedAt = 0L
private const val WINDOW_MS = 30_000L
}
}
@@ -78,5 +78,7 @@ object CarAppCatalog {
CarAppService.CATEGORY_SETTINGS_APP, CarAppService.CATEGORY_SETTINGS_APP,
CarAppService.CATEGORY_IOT_APP, CarAppService.CATEGORY_IOT_APP,
CarAppService.CATEGORY_WEATHER_APP, CarAppService.CATEGORY_WEATHER_APP,
// Newer apps declare MEDIA, but our AndroidX 1.7 dependency has no constant for it.
"androidx.car.app.category.MEDIA",
) )
} }
@@ -1,6 +1,7 @@
package app.seb3thehacker.gearslip.host package app.seb3thehacker.gearslip.host
import app.seb3thehacker.gearslip.car.CarEnvironment import app.seb3thehacker.gearslip.car.CarEnvironment
import app.seb3thehacker.gearslip.car.CarToasts
import android.content.ComponentName import android.content.ComponentName
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
@@ -9,6 +10,7 @@ import android.content.res.Configuration
import android.graphics.Rect import android.graphics.Rect
import android.location.Location import android.location.Location
import android.net.Uri import android.net.Uri
import android.os.Build
import android.os.Handler import android.os.Handler
import android.os.IBinder import android.os.IBinder
import android.os.Looper import android.os.Looper
@@ -80,6 +82,8 @@ class CarAppConnection(private val context: Context) {
val template: StateFlow<Template?> = _template.asStateFlow() val template: StateFlow<Template?> = _template.asStateFlow()
private val main = Handler(Looper.getMainLooper()) private val main = Handler(Looper.getMainLooper())
/** Captured by each host Binder so callbacks queued by a replaced app cannot show a toast. */
private var toastOwner = Any()
private var carApp: ICarApp? = null private var carApp: ICarApp? = null
private var appManager: IAppManager? = null private var appManager: IAppManager? = null
@@ -87,6 +91,9 @@ class CarAppConnection(private val context: Context) {
private var navigationManager: INavigationManager? = null private var navigationManager: INavigationManager? = null
/** Main thread only, both of these - the handoff is a race between them either way round. */ /** Main thread only, both of these - the handoff is a race between them either way round. */
private var surfaceCallback: ISurfaceCallback? = null 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 lent: Lent? = null
private var binding: ServiceConnection? = null private var binding: ServiceConnection? = null
@@ -139,7 +146,11 @@ class CarAppConnection(private val context: Context) {
// Replaced by another connect (or a disconnect) while waiting on the location service. // Replaced by another connect (or a disconnect) while waiting on the location service.
if (binding !== connection) return@bind if (binding !== connection) return@bind
var bindError: Throwable? = null var bindError: Throwable? = null
val flags = Context.BIND_AUTO_CREATE or LocationKeepAlive.BIND_INCLUDE_CAPABILITIES // BIND_ALLOW_ACTIVITY_STARTS lets the app start its own location service while
// Gearslip's car window is showing. Since Android 14 a bound app only inherits that
// from a visible host that opts in, and MapQuest crashes as navigation starts without it.
val flags = Context.BIND_AUTO_CREATE or LocationKeepAlive.BIND_INCLUDE_CAPABILITIES or
(if (Build.VERSION.SDK_INT >= 34) Context.BIND_ALLOW_ACTIVITY_STARTS else 0)
val bound = runCatching { val bound = runCatching {
// Without BIND_FOREGROUND_SERVICE, an app bound by a foreground service (which is // Without BIND_FOREGROUND_SERVICE, an app bound by a foreground service (which is
// all Gearslip is once the phone screen leaves it) only reaches "important // all Gearslip is once the phone screen leaves it) only reaches "important
@@ -215,6 +226,8 @@ class CarAppConnection(private val context: Context) {
fun disconnect() = disconnect(keepLocation = false) fun disconnect() = disconnect(keepLocation = false)
private fun disconnect(keepLocation: Boolean) { private fun disconnect(keepLocation: Boolean) {
main.removeCallbacks(handshakeTimeout)
clearToasts()
lent?.let { sendSurfaceDestroyed(it) } lent?.let { sendSurfaceDestroyed(it) }
lent = null lent = null
val carApp = this.carApp val carApp = this.carApp
@@ -231,6 +244,10 @@ class CarAppConnection(private val context: Context) {
this.carApp = null this.carApp = null
appManager = null appManager = null
navigationManager = null navigationManager = null
navigating = false
main.removeCallbacks(sendReroute)
reroute = null
autoRoute.disarm()
_panMode.value = false _panMode.value = false
surfaceCallback = null surfaceCallback = null
component = null component = null
@@ -241,11 +258,24 @@ class CarAppConnection(private val context: Context) {
// --- handshake --------------------------------------------------------------------- // --- 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) { private fun onBound(service: IBinder) {
val app = ICarApp.Stub.asInterface(service) val app = ICarApp.Stub.asInterface(service)
carApp = app carApp = app
update { it.copy(phase = Phase.HANDSHAKE, detail = "bound, asking for app info") } update { it.copy(phase = Phase.HANDSHAKE, detail = "bound, asking for app info") }
GearslipLog.i("host: bound; requesting app info") GearslipLog.i("host: bound; requesting app info")
main.postDelayed(handshakeTimeout, HANDSHAKE_TIMEOUT_MS)
call("getAppInfo", onValue = { value -> call("getAppInfo", onValue = { value ->
val info = value as? AppInfo ?: return@call fail(Phase.FAILED, "app info was ${value?.javaClass?.simpleName}") val info = value as? AppInfo ?: return@call fail(Phase.FAILED, "app info was ${value?.javaClass?.simpleName}")
@@ -258,6 +288,7 @@ class CarAppConnection(private val context: Context) {
* offers cannot be rendered correctly, so it is refused here rather than half-drawn. * offers cannot be rendered correctly, so it is refused here rather than half-drawn.
*/ */
private fun onAppInfo(info: AppInfo) { private fun onAppInfo(info: AppInfo) {
main.removeCallbacks(handshakeTimeout)
val negotiated = minOf(HOST_API_LEVEL, info.latestCarAppApiLevel) val negotiated = minOf(HOST_API_LEVEL, info.latestCarAppApiLevel)
GearslipLog.i( GearslipLog.i(
"host: app api ${info.minCarAppApiLevel}..${info.latestCarAppApiLevel} " + "host: app api ${info.minCarAppApiLevel}..${info.latestCarAppApiLevel} " +
@@ -281,34 +312,66 @@ class CarAppConnection(private val context: Context) {
private fun onHandshakeAccepted() { private fun onHandshakeAccepted() {
val app = carApp ?: return val app = carApp ?: return
GearslipLog.i("host: handshake accepted; creating the app") GearslipLog.i("host: handshake accepted; creating the app")
val intent = pendingDestination?.let { navigateIntent(it) } val intent = pendingDestination?.let { autoRoute.arm(); navigateIntent(it) }
?: Intent(Intent.ACTION_MAIN).setComponent(component) ?: Intent(Intent.ACTION_MAIN).setComponent(component)
pendingDestination = null pendingDestination = null
call("onAppCreate", onValue = { onCreated() }) { call("onAppCreate", onValue = { onCreated() }) {
app.onAppCreate(carHost, intent, carConfiguration(), it) app.onAppCreate(createCarHost(toastOwner), intent, carConfiguration(), it)
} }
} }
/** /**
* "navigate to X" deep link, the same one Android Auto's own Assistant integration sends - * "navigate to X" deep link, the same one Android Auto's own Assistant integration sends -
* [ACTION_NAVIGATE] with the destination as a `geo:` search query, rather than anything * [ACTION_NAVIGATE] with the destination as a `geo:0,0` search query, rather than anything
* app-specific. * app-specific. The app searches near its own location; [AutoRoute] then picks the nearest
* result. Coordinates in the link would mean "go exactly here": Organic Maps routed to the
* phone's own position when it was given them.
*/ */
private fun navigateIntent(destination: String) = private fun navigateIntent(destination: String): Intent =
Intent(CarContext.ACTION_NAVIGATE, Uri.parse("geo:0,0?q=" + Uri.encode(destination))) Intent(CarContext.ACTION_NAVIGATE, Uri.parse("geo:0,0?q=" + Uri.encode(destination)))
.setComponent(component) .setComponent(component)
/** /**
* Sends a fresh destination to an app that's already running, instead of tearing it down * Sends a fresh destination to an app that's already running, instead of tearing it down
* and rebuilding the whole session - see [app.seb3thehacker.gearslip.car.CarServices.connectNav]. * and rebuilding the whole session - see [app.seb3thehacker.gearslip.car.CarServices.connectNav].
*
* An app already on a route ends it first: Organic Maps ignored a new destination mid-route
* and kept the old directions. The new one goes once the app says the route ended, or after
* [REROUTE_WAIT_MS] if it never does.
*/ */
fun navigateTo(destination: String) { fun navigateTo(destination: String) {
if (carApp == null) return
main.removeCallbacks(sendReroute)
if (!navigating) {
reroute = null
sendDestination(destination)
return
}
reroute = destination
stopNavigating()
main.postDelayed(sendReroute, REROUTE_WAIT_MS)
}
private fun sendDestination(destination: String) {
val app = carApp ?: return val app = carApp ?: return
autoRoute.arm()
call("onNewIntent", onValue = {}) { call("onNewIntent", onValue = {}) {
app.onNewIntent(navigateIntent(destination), it) app.onNewIntent(navigateIntent(destination), it)
} }
} }
private val autoRoute = AutoRoute()
/** A destination waiting for the current route to end; see [navigateTo]. */
private var reroute: String? = null
private val sendReroute = Runnable {
reroute?.let { reroute = null; sendDestination(it) }
}
/** Whether the app has said it's on a route, between navigationStarted and navigationEnded. */
@Volatile var navigating = false
private set
private fun onCreated() { private fun onCreated() {
val app = carApp ?: return val app = carApp ?: return
GearslipLog.i("host: created; starting") GearslipLog.i("host: created; starting")
@@ -380,6 +443,7 @@ class CarAppConnection(private val context: Context) {
GearslipLog.i("host: template = ${template.javaClass.simpleName}") GearslipLog.i("host: template = ${template.javaClass.simpleName}")
_template.value = template _template.value = template
update { it.copy(template = template.javaClass.simpleName) } update { it.copy(template = template.javaClass.simpleName) }
autoRoute.onTemplate(template)
}) { manager.getTemplate(it) } }) { manager.getTemplate(it) }
} }
@@ -506,9 +570,11 @@ class CarAppConnection(private val context: Context) {
// The app draws for the whole surface; the visible area is what isn't covered by host // 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 // 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 // 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) 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( GearslipLog.i(
"host: lent the app a ${surface.width}x${surface.height} @${surface.dpi}dpi surface" "host: lent the app a ${surface.width}x${surface.height} @${surface.dpi}dpi surface"
@@ -529,6 +595,7 @@ class CarAppConnection(private val context: Context) {
/** Tells the app which part of its surface the driver can actually see. */ /** Tells the app which part of its surface the driver can actually see. */
fun reportAreas(visible: Rect, stable: Rect) { fun reportAreas(visible: Rect, stable: Rect) {
areas = visible to stable
val callback = surfaceCallback ?: return val callback = surfaceCallback ?: return
runCatching { callback.onStableAreaChanged(stable, noop("onStableAreaChanged")) } runCatching { callback.onStableAreaChanged(stable, noop("onStableAreaChanged")) }
runCatching { callback.onVisibleAreaChanged(visible, noop("onVisibleAreaChanged")) } runCatching { callback.onVisibleAreaChanged(visible, noop("onVisibleAreaChanged")) }
@@ -558,14 +625,19 @@ class CarAppConnection(private val context: Context) {
// --- host interfaces the app calls back into ----------------------------------------- // --- host interfaces the app calls back into -----------------------------------------
private val appHost = object : IAppHost.Stub() { private fun createAppHost(owner: Any) = object : IAppHost.Stub() {
override fun invalidate() { override fun invalidate() {
GearslipLog.i("host: app invalidated its template") GearslipLog.i("host: app invalidated its template")
main.post { requestTemplate() } main.post { requestTemplate() }
} }
override fun showToast(text: CharSequence?, duration: Int) { override fun showToast(text: CharSequence?, duration: Int) {
GearslipLog.i("host: toast \"$text\"") val message = text?.toString() ?: return
main.post {
if (toastOwner !== owner) return@post
GearslipLog.i("host: toast \"$message\"")
CarToasts.show(owner, message, duration)
}
} }
/** /**
@@ -587,10 +659,24 @@ class CarAppConnection(private val context: Context) {
override fun openMicrophone(request: Bundleable?): Bundleable? = null override fun openMicrophone(request: Bundleable?): Bundleable? = null
} }
private val _trip = MutableStateFlow<androidx.car.app.navigation.model.Trip?>(null)
/** The route as the app tells clusters about it: steps and how far to each. Null when not navigating. */
val trip: StateFlow<androidx.car.app.navigation.model.Trip?> = _trip.asStateFlow()
private val navigationHost = object : INavigationHost.Stub() { private val navigationHost = object : INavigationHost.Stub() {
override fun navigationStarted() = GearslipLog.i("host: navigation started") override fun navigationStarted() {
override fun navigationEnded() = GearslipLog.i("host: navigation ended") GearslipLog.i("host: navigation started")
override fun updateTrip(trip: Bundleable?) = Unit navigating = true
}
override fun navigationEnded() {
GearslipLog.i("host: navigation ended")
navigating = false
_trip.value = null
main.post { if (reroute != null) { main.removeCallbacks(sendReroute); sendReroute.run() } }
}
override fun updateTrip(trip: Bundleable?) {
_trip.value = runCatching { trip?.get() as? androidx.car.app.navigation.model.Trip }.getOrNull()
}
} }
/** /**
@@ -656,7 +742,9 @@ class CarAppConnection(private val context: Context) {
override fun updateSuggestions(suggestions: Bundleable?) = Unit override fun updateSuggestions(suggestions: Bundleable?) = Unit
} }
private val carHost = object : ICarHost.Stub() { private fun createCarHost(owner: Any) = object : ICarHost.Stub() {
private val appHost = createAppHost(owner)
override fun getHost(type: String?): IBinder? = when (type) { override fun getHost(type: String?): IBinder? = when (type) {
CarContext.APP_SERVICE -> appHost.asBinder() CarContext.APP_SERVICE -> appHost.asBinder()
CarContext.NAVIGATION_SERVICE -> navigationHost.asBinder() CarContext.NAVIGATION_SERVICE -> navigationHost.asBinder()
@@ -745,10 +833,16 @@ class CarAppConnection(private val context: Context) {
} }
private fun fail(phase: Phase, detail: String) { private fun fail(phase: Phase, detail: String) {
clearToasts()
GearslipLog.e("host: $detail") GearslipLog.e("host: $detail")
update { it.copy(phase = phase, detail = detail) } update { it.copy(phase = phase, detail = detail) }
} }
private fun clearToasts() {
CarToasts.clear(toastOwner)
toastOwner = Any()
}
private fun update(block: (Status) -> Status) { private fun update(block: (Status) -> Status) {
_status.value = block(_status.value) _status.value = block(_status.value)
} }
@@ -757,6 +851,12 @@ class CarAppConnection(private val context: Context) {
/** The newest template API this host claims to understand. */ /** The newest template API this host claims to understand. */
const val HOST_API_LEVEL = CarAppApiLevels.LEVEL_8 const val HOST_API_LEVEL = CarAppApiLevels.LEVEL_8
/** 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. */ /** Context.BIND_FOREGROUND_SERVICE; the constant itself is hidden from the SDK. */
const val BIND_FOREGROUND_SERVICE = 0x04000000 const val BIND_FOREGROUND_SERVICE = 0x04000000
} }
@@ -141,7 +141,7 @@ fun Distance?.display(): String {
return "%.${decimals}f $unit".format(distance.displayDistance) return "%.${decimals}f $unit".format(distance.displayDistance)
} }
private fun Distance.meters(): Double = displayDistance * when (displayUnit) { internal fun Distance.meters(): Double = displayDistance * when (displayUnit) {
Distance.UNIT_KILOMETERS, Distance.UNIT_KILOMETERS_P1 -> 1000.0 Distance.UNIT_KILOMETERS, Distance.UNIT_KILOMETERS_P1 -> 1000.0
Distance.UNIT_MILES, Distance.UNIT_MILES_P1 -> 1609.344 Distance.UNIT_MILES, Distance.UNIT_MILES_P1 -> 1609.344
Distance.UNIT_FEET -> 0.3048 Distance.UNIT_FEET -> 0.3048
@@ -195,6 +195,7 @@ fun androidx.car.app.model.DateTimeWithZone?.clockTime(): String {
*/ */
fun OnClickDelegate?.click(label: String = "action") { fun OnClickDelegate?.click(label: String = "action") {
val delegate = this ?: return val delegate = this ?: return
if (label != AutoRoute.AUTO_LABEL) AutoRoute.driverTappedAt = android.os.SystemClock.elapsedRealtime()
runCatching { delegate.sendClick(doneCallback("a $label tap")) } runCatching { delegate.sendClick(doneCallback("a $label tap")) }
.onFailure { GearslipLog.w("host: could not send a $label tap: ${it.message}") } .onFailure { GearslipLog.w("host: could not send a $label tap: ${it.message}") }
} }
@@ -1,5 +1,9 @@
package app.seb3thehacker.gearslip.host package app.seb3thehacker.gearslip.host
import android.content.Context
import android.content.pm.PackageManager
import java.security.MessageDigest
/** /**
* Apps that have been run through Gearslip from start to finish and behave. The launcher marks * Apps that have been run through Gearslip from start to finish and behave. The launcher marks
* them so a driver can pick something that works without finding out at the wheel. * them so a driver can pick something that works without finding out at the wheel.
@@ -18,6 +22,8 @@ object KnownApps {
"com.vivi.vivimusic", // Vivi Music: browse, play, seek, works well "com.vivi.vivimusic", // Vivi Music: browse, play, seek, works well
"com.metrolist.music", // Metrolist: browse, play, works "com.metrolist.music", // Metrolist: browse, play, works
"nl.flitsmeister", // Flitsmeister: map, routing, alerts - works well "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
) )
fun works(packageName: String): Boolean = packageName in working fun works(packageName: String): Boolean = packageName in working
@@ -63,7 +69,30 @@ object KnownApps {
"com.generalmagic.magicearth", // Magic Earth: never gets past connecting "com.generalmagic.magicearth", // Magic Earth: never gets past connecting
) )
fun isBroken(packageName: String): Boolean = packageName in broken fun isBroken(context: Context, packageName: String): Boolean =
packageName in broken && !patched(context, packageName)
/**
* The certificate each broken app's own publisher signs it with. A copy signed with another
* key has been patched to accept Gearslip, like Waze with the waze-gearslip Morphe patch, and
* works.
*/
private val officialSigners = mapOf(
"com.waze" to "03637f6c5d8f604e6fdb79a6ffbfa578de4e318f8da22fc6106665247f8807d7",
)
/** Whether the installed copy is signed by someone other than its publisher. */
private fun patched(context: Context, packageName: String): Boolean {
val official = officialSigners[packageName] ?: return false
val signers = runCatching {
context.packageManager.getPackageInfo(packageName, PackageManager.GET_SIGNING_CERTIFICATES)
.signingInfo?.apkContentsSigners
}.getOrNull() ?: return false
val sha256 = MessageDigest.getInstance("SHA-256")
return signers.isNotEmpty() && signers.none { cert ->
sha256.digest(cert.toByteArray()).joinToString("") { "%02x".format(it) } == official
}
}
/** /**
* Shown on the launcher with a yellow mark: starts and can be used, but not cleanly enough to * Shown on the launcher with a yellow mark: starts and can be used, but not cleanly enough to
@@ -71,7 +100,6 @@ object KnownApps {
*/ */
private val partial = setOf( private val partial = setOf(
"app.vela", // Vela Maps: connects and draws, but rough enough not to call it working yet "app.vela", // Vela Maps: connects and draws, but rough enough not to call it working yet
"com.mapquest.android.ace", // MapQuest: map, search and routing run, but not cleanly
"com.spotify.music", // Spotify: plays through Gearslip's player, but the app has to be opened on the phone first "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 "deezer.android.app", // Deezer: the same - it has to be opened on the phone first
) )
@@ -40,7 +40,19 @@ class MediaEntry(
) )
/** [icon] is a drawable id inside the media app's own package, not Gearslip's; 0 for none. */ /** [icon] is a drawable id inside the media app's own package, not Gearslip's; 0 for none. */
class CustomAction(val id: String, val name: String, val extras: Bundle?, val icon: Int = 0) class CustomAction(val id: String, val name: String, val extras: Bundle?, val icon: Int = 0, val iconName: String = "") {
/**
* Whether a toggle like heart or shuffle is on, read from the icon's name - Tidal swaps
* ic_favorite for ic_favorite_filled and ic_shuffle for ic_shuffle_active, and keeps the
* button's name the same. Whole words only, so "inactive" doesn't count as "active".
*/
val on: Boolean
get() = iconName.lowercase().split('_', '.', '-').any { it in ON_WORDS }
private companion object {
val ON_WORDS = setOf("filled", "fill", "active", "on", "selected", "checked", "enabled", "solid", "one", "all")
}
}
/** One entry in the app's own queue - not its library, whatever it has lined up to play next. */ /** One entry in the app's own queue - not its library, whatever it has lined up to play next. */
class QueueTrack( class QueueTrack(
@@ -156,6 +168,9 @@ class CarMedia(private val context: Context) {
private val main = Handler(Looper.getMainLooper()) private val main = Handler(Looper.getMainLooper())
private var connecting: MediaApp? = null private var connecting: MediaApp? = null
/** The music app Gearslip is attached to, for the assistant to name; null when there's none. */
val appLabel: String? get() = connecting?.label?.takeIf { controller != null }
private val _phase = MutableStateFlow(Phase.IDLE) private val _phase = MutableStateFlow(Phase.IDLE)
val phase: StateFlow<Phase> = _phase.asStateFlow() val phase: StateFlow<Phase> = _phase.asStateFlow()
@@ -225,7 +240,11 @@ class CarMedia(private val context: Context) {
private fun refreshModes() { private fun refreshModes() {
val c = compat val c = compat
_modes.value = if (c == null) Modes() else Modes(shuffle = c.shuffleMode, repeat = c.repeatMode) val modes = if (c == null) Modes() else Modes(shuffle = c.shuffleMode, repeat = c.repeatMode)
if (modes.shuffle != _modes.value.shuffle || modes.repeat != _modes.value.repeat) {
GearslipLog.i("media: shuffle mode ${modes.shuffle}, repeat mode ${modes.repeat}, rating type ${controller?.ratingType}")
}
_modes.value = modes
} }
private var subscribed: String? = null private var subscribed: String? = null
private var rootId: String? = null private var rootId: String? = null
@@ -236,6 +255,12 @@ class CarMedia(private val context: Context) {
connecting = app connecting = app
_phase.value = Phase.CONNECTING _phase.value = Phase.CONNECTING
_browse.value = BrowseState() _browse.value = BrowseState()
// Known to refuse its library (Spotify): asking only costs the seconds it takes to say no,
// so go straight to its playback session.
if (KnownApps.playerWorks(app.component.packageName)) {
attachToSession(app)
return
}
val mb = MediaBrowser(context, app.component, object : MediaBrowser.ConnectionCallback() { val mb = MediaBrowser(context, app.component, object : MediaBrowser.ConnectionCallback() {
override fun onConnected() = this@CarMedia.onConnected(app) override fun onConnected() = this@CarMedia.onConnected(app)
override fun onConnectionFailed() { override fun onConnectionFailed() {
@@ -248,10 +273,20 @@ class CarMedia(private val context: Context) {
} }
}, null) }, null)
browser = mb browser = mb
GearslipLog.i("media: connecting to ${app.label}'s library")
runCatching { mb.connect() }.onFailure { runCatching { mb.connect() }.onFailure {
GearslipLog.e("media: connect threw", it) GearslipLog.e("media: connect threw", it)
_phase.value = Phase.REJECTED _phase.value = Phase.REJECTED
} }
// An app that never answers would leave the player spinning, and every later tap on it
// would only reopen that spinner. Give up on the library and drive its playback instead.
main.postDelayed({
if (browser !== mb || _phase.value != Phase.CONNECTING) return@postDelayed
GearslipLog.w("media: ${app.label} didn't answer in ${CONNECT_WAIT_MS / 1000} s, using its playback session")
runCatching { mb.disconnect() }
browser = null
attachToSession(app)
}, CONNECT_WAIT_MS)
} }
/** /**
@@ -273,7 +308,12 @@ class CarMedia(private val context: Context) {
return return
} }
if (session == null) { if (session == null) {
val limit = if (launched) SESSION_RETRIES_LAUNCHED else SESSION_RETRIES // A known session-only app that isn't running won't start by waiting: say so at once.
val limit = when {
launched -> SESSION_RETRIES_LAUNCHED
KnownApps.playerWorks(app.component.packageName) -> 0
else -> SESSION_RETRIES
}
// The app only publishes its session once its service has started; give it a moment. // The app only publishes its session once its service has started; give it a moment.
if (attempt < limit) { if (attempt < limit) {
main.postDelayed({ attachToSession(app, attempt + 1, launched) }, SESSION_RETRY_MS) main.postDelayed({ attachToSession(app, attempt + 1, launched) }, SESSION_RETRY_MS)
@@ -342,16 +382,39 @@ class CarMedia(private val context: Context) {
speed = state?.playbackSpeed ?: 1f, speed = state?.playbackSpeed ?: 1f,
updatedAt = state?.lastPositionUpdateTime ?: 0, updatedAt = state?.lastPositionUpdateTime ?: 0,
actions = state?.actions ?: 0, actions = state?.actions ?: 0,
custom = state?.customActions.orEmpty().map { CustomAction(it.action, it.name.toString(), it.extras, it.icon) }, custom = state?.customActions.orEmpty().map { CustomAction(it.action, it.name.toString(), it.extras, it.icon, iconName(it.icon)) },
error = state?.errorMessage?.toString(), error = state?.errorMessage?.toString(),
activeQueueItemId = state?.activeQueueItemId ?: MediaSession.QueueItem.UNKNOWN_ID.toLong(), activeQueueItemId = state?.activeQueueItemId ?: MediaSession.QueueItem.UNKNOWN_ID.toLong(),
heart = if (controller?.ratingType == Rating.RATING_HEART) { heart = if (controller?.ratingType == Rating.RATING_HEART) {
metadata?.getRating(MediaMetadata.METADATA_KEY_USER_RATING)?.takeIf { it.isRated }?.hasHeart() ?: false metadata?.getRating(MediaMetadata.METADATA_KEY_USER_RATING)?.takeIf { it.isRated }?.hasHeart() ?: false
} else null, } else null,
) )
logButtons()
checkSearch() checkSearch()
} }
private var appResources: Pair<String, android.content.res.Resources?>? = null
/** The icon's resource name in the app's package, or "" when it can't be read. */
private fun iconName(icon: Int): String {
val pkg = controller?.packageName ?: return ""
if (appResources?.first != pkg) {
appResources = pkg to runCatching { context.packageManager.getResourcesForApplication(pkg) }.getOrNull()
}
val res = appResources?.second ?: return ""
return runCatching { res.getResourceEntryName(icon) }.getOrDefault("")
}
private var loggedButtons: String? = null
/** The app's own buttons, by id, name and icon, each time they change. */
private fun logButtons() {
val text = _now.value.custom.joinToString { a -> "${a.id} \"${a.name}\" icon=${a.iconName.ifEmpty { a.icon.toString() }}" }
if (text == loggedButtons) return
loggedButtons = text
GearslipLog.i("media: app buttons: ${text.ifEmpty { "none" }}")
}
// --- browsing ------------------------------------------------------------------------ // --- browsing ------------------------------------------------------------------------
private fun open(parentId: String) { private fun open(parentId: String) {
@@ -419,6 +482,12 @@ class CarMedia(private val context: Context) {
val controls = controller?.transportControls ?: return val controls = controller?.transportControls ?: return
if (_now.value.playing) controls.pause() else controls.play() if (_now.value.playing) controls.pause() else controls.play()
} }
/**
* Pauses whatever the app thinks it's doing. Voice pauses the music to listen, so by the time
* "pause" is heard the app already looks stopped; a toggle did nothing and the song came back
* as soon as the microphone let go. A real pause tells the app to stay stopped.
*/
fun pause() = controller?.transportControls?.pause()
fun next() = controller?.transportControls?.skipToNext() fun next() = controller?.transportControls?.skipToNext()
fun previous() = controller?.transportControls?.skipToPrevious() fun previous() = controller?.transportControls?.skipToPrevious()
fun seek(ms: Long) = controller?.transportControls?.seekTo(ms) fun seek(ms: Long) = controller?.transportControls?.seekTo(ms)
@@ -454,7 +523,7 @@ class CarMedia(private val context: Context) {
} }
val component = searchComponent val component = searchComponent
if (component == null) { if (component == null) {
_results.value = Results(q, emptyList(), supported = false) noLibrarySearch(q)
return return
} }
resultsQuery = q resultsQuery = q
@@ -476,13 +545,13 @@ class CarMedia(private val context: Context) {
override fun onConnectionFailed() { override fun onConnectionFailed() {
GearslipLog.w("media: no library connection for search") GearslipLog.w("media: no library connection for search")
searchBrowser = null searchBrowser = null
resultsQuery?.let { _results.value = Results(it, emptyList(), supported = false) } resultsQuery?.let { noLibrarySearch(it) }
} }
}, null) }, null)
searchBrowser = created searchBrowser = created
runCatching { created.connect() }.onFailure { runCatching { created.connect() }.onFailure {
searchBrowser = null searchBrowser = null
_results.value = Results(q, emptyList(), supported = false) noLibrarySearch(q)
} }
} }
@@ -492,18 +561,42 @@ class CarMedia(private val context: Context) {
override fun onSearchResult(query: String, extras: Bundle?, items: List<MediaBrowserCompat.MediaItem>) { override fun onSearchResult(query: String, extras: Bundle?, items: List<MediaBrowserCompat.MediaItem>) {
if (query != resultsQuery) return if (query != resultsQuery) return
GearslipLog.i("media: library search found ${items.size}") GearslipLog.i("media: library search found ${items.size}")
_results.value = Results(query, items.map(::entryOfCompat)) val entries = items.map(::entryOfCompat)
if (query == playFirstFor) {
playFirstFor = null
val first = entries.firstOrNull { it.playable } ?: entries.firstOrNull()
if (first == null) {
clearResults()
search(query)
} else {
GearslipLog.i("media: playing the library's best match")
clearResults()
searchBefore = _now.value
_search.value = Search(query)
main.removeCallbacks(searchTimeout)
controller?.transportControls?.playFromMediaId(first.id, null)
main.postDelayed(searchTimeout, SEARCH_WAIT_MS)
}
return
}
_results.value = Results(query, entries)
} }
override fun onError(query: String, extras: Bundle?) { override fun onError(query: String, extras: Bundle?) {
if (query != resultsQuery) return if (query != resultsQuery) return
if (query == playFirstFor) {
playFirstFor = null
clearResults()
search(query)
return
}
GearslipLog.i("media: the app has no library search") GearslipLog.i("media: the app has no library search")
_results.value = Results(query, emptyList(), supported = false) _results.value = Results(query, emptyList(), supported = false)
} }
}) })
}.onFailure { }.onFailure {
GearslipLog.w("media: library search failed: ${it.message}") GearslipLog.w("media: library search failed: ${it.message}")
_results.value = Results(q, emptyList(), supported = false) noLibrarySearch(q)
} }
} }
@@ -520,6 +613,36 @@ class CarMedia(private val context: Context) {
) )
} }
/** The library can't search: a voice "play X" falls back to the app's own search; a typed one says so. */
private fun noLibrarySearch(q: String) {
if (q == playFirstFor) {
playFirstFor = null
clearResults()
search(q)
} else {
_results.value = Results(q, emptyList(), supported = false)
}
}
/** A voice "play X" waiting on the library search, to play its first match instead of listing them. */
private var playFirstFor: String? = null
/**
* Plays the best match for [query], for voice. Asks the app's library first and plays its top
* result, which apps like ViVi honour where they ignore [search]; without a library search,
* falls back to [search].
*/
fun playBestMatch(query: String) {
val q = query.trim()
if (q.isEmpty()) return
if (!canSearchLibrary) {
search(q)
return
}
playFirstFor = q
searchLibrary(q)
}
/** The connected app's service, for the search connection; null when nothing is connected. */ /** The connected app's service, for the search connection; null when nothing is connected. */
private var searchComponent: android.content.ComponentName? = null private var searchComponent: android.content.ComponentName? = null
private var searchBrowser: MediaBrowserCompat? = null private var searchBrowser: MediaBrowserCompat? = null
@@ -637,6 +760,7 @@ class CarMedia(private val context: Context) {
} }
private companion object { private companion object {
const val CONNECT_WAIT_MS = 8_000L
const val SESSION_RETRIES = 6 const val SESSION_RETRIES = 6
const val SESSION_RETRY_MS = 500L const val SESSION_RETRY_MS = 500L
/** After opening a cold app, how long to wait before the first look for its session. */ /** After opening a cold app, how long to wait before the first look for its session. */
@@ -0,0 +1,119 @@
package app.seb3thehacker.gearslip.media
import android.graphics.Bitmap
import android.media.session.PlaybackState
import app.seb3thehacker.gearslip.GearslipLog
import app.seb3thehacker.gearslip.Protobuf
import app.seb3thehacker.gearslip.car.CarServices
import java.io.ByteArrayOutputStream
/**
* Tells the car what's playing, so its own screens show it: the instrument cluster, the media
* source screen, the steering wheel display. Android Auto does the same over the car's
* MediaPlaybackStatusService (Service field 9):
*
* -> MediaPlaybackMetadata { song = 1; artist = 2; album = 3; album_art = 4; duration_seconds = 6 }
* -> MediaPlaybackStatus { state = 1 (1 stopped, 2 playing, 3 paused); media_source = 2;
* playback_seconds = 3; shuffle = 4; repeat = 5; repeat_one = 6 }
*
* Read once a second from the media app Gearslip is driving. The track goes only when it changes;
* the status goes every second while playing, so the car's position counts along.
*/
class CarMediaStatus(
val channelId: Int,
private val sendOnChannel: (messageId: Int, body: ByteArray) -> Unit,
) {
@Volatile private var running = false
private var sentTrack: String? = null
private var sentState = -1
private var inputsLogged = 0
fun onMessage(messageId: Int, body: ByteArray) {
when (messageId) {
MSG_CHANNEL_OPEN_RESPONSE -> {
val status = Protobuf.readInt32Field(body, 1)
GearslipLog.i("media status: <- ChannelOpenResponse: status=$status")
if (status == 0) start()
}
// The car's own buttons for the media source (play, next...). Logged to learn what
// this car sends; the steering wheel keys already arrive on the input channel.
MSG_INPUT -> if (inputsLogged++ < 10) GearslipLog.i("media status: <- input from the car:\n" + Protobuf.describe(body))
else -> GearslipLog.i("media status: <- unhandled message id=$messageId (${body.size} bytes)")
}
}
private fun start() {
if (running) return
running = true
Thread({
while (running) {
runCatching { tick() }.onFailure { GearslipLog.w("media status: ${it.message}") }
Thread.sleep(TICK_MS)
}
}, "gearslip-media-status").start()
}
fun stop() {
running = false
}
private fun tick() {
val now = CarServices.mediaOrNull()?.now?.value
val active = now != null && now.isActive
val state = when {
!active -> STATE_STOPPED
now!!.state == PlaybackState.STATE_PAUSED -> STATE_PAUSED
else -> STATE_PLAYING
}
if (active) sendTrack(now!!)
// Nothing to say while stopped, beyond saying so once.
if (state == STATE_STOPPED && sentState == STATE_STOPPED) return
if (state == STATE_PAUSED && sentState == STATE_PAUSED) return
val source = CarServices.mediaApp.value?.label ?: "Gearslip"
val seconds = if (active) (now!!.currentPosition() / 1000).coerceAtLeast(0) else 0
sendOnChannel(
MSG_STATUS,
Protobuf.varintField(1, state.toLong()) +
Protobuf.stringField(2, source) +
Protobuf.varintField(3, seconds) +
Protobuf.varintField(4, 0L) +
Protobuf.varintField(5, 0L) +
Protobuf.varintField(6, 0L),
)
if (state != sentState) GearslipLog.i("media status: -> ${STATE_NAMES[state]} ($source)")
sentState = state
}
private fun sendTrack(now: NowPlaying) {
val key = "${now.title}\u0000${now.artist}\u0000${now.album}\u0000${now.durationMs}\u0000${now.art?.generationId}"
if (key == sentTrack) return
sentTrack = key
var body = Protobuf.stringField(1, now.title) +
Protobuf.stringField(2, now.artist) +
Protobuf.stringField(3, now.album)
now.art?.let { art -> artBytes(art)?.let { body += Protobuf.bytesField(4, it) } }
if (now.durationMs > 0) body += Protobuf.varintField(6, now.durationMs / 1000)
sendOnChannel(MSG_METADATA, body)
GearslipLog.i("media status: -> track \"${now.title}\" by ${now.artist.ifBlank { "?" }}")
}
/** Album art as a small PNG: car screens show it at thumbnail size, and the link is shared with video. */
private fun artBytes(art: Bitmap): ByteArray? = runCatching {
val scale = minOf(1f, ART_PX.toFloat() / maxOf(art.width, art.height))
val small = if (scale < 1f) Bitmap.createScaledBitmap(art, (art.width * scale).toInt(), (art.height * scale).toInt(), true) else art
ByteArrayOutputStream().also { small.compress(Bitmap.CompressFormat.PNG, 100, it) }.toByteArray()
}.getOrNull()
companion object {
private const val TICK_MS = 1_000L
private const val ART_PX = 256
private const val MSG_CHANNEL_OPEN_RESPONSE = 8
private const val MSG_STATUS = 32769
private const val MSG_INPUT = 32770
private const val MSG_METADATA = 32771
private const val STATE_STOPPED = 1
private const val STATE_PLAYING = 2
private const val STATE_PAUSED = 3
private val STATE_NAMES = mapOf(1 to "stopped", 2 to "playing", 3 to "paused")
}
}
@@ -30,7 +30,7 @@ class GearslipNotificationListener : NotificationListenerService() {
override fun onNotificationRemoved(sbn: StatusBarNotification) = CarNotifications.gone(sbn.key) override fun onNotificationRemoved(sbn: StatusBarNotification) = CarNotifications.gone(sbn.key)
private fun ingest(sbn: StatusBarNotification, quiet: Boolean) { private fun ingest(sbn: StatusBarNotification, quiet: Boolean) {
// Gearslip's own notifications are skipped, except a dev build's test message. // Gearslip's own notifications are skipped, except a debug build's test message.
if (sbn.packageName == packageName && if (sbn.packageName == packageName &&
!(app.seb3thehacker.gearslip.BuildConfig.DEBUG && sbn.notification.channelId == TestMessage.CHANNEL) !(app.seb3thehacker.gearslip.BuildConfig.DEBUG && sbn.notification.channelId == TestMessage.CHANNEL)
) return ) return
@@ -13,7 +13,7 @@ import androidx.core.app.RemoteInput
import app.seb3thehacker.gearslip.GearslipLog import app.seb3thehacker.gearslip.GearslipLog
/** /**
* Dev builds only: posts a chat message from Gearslip to itself, shaped like Signal's or * Debug builds only: posts a chat message from Gearslip to itself, shaped like Signal's or
* WhatsApp's (MessagingStyle, a Reply action, a Mark as read action), so popups and voice reply * WhatsApp's (MessagingStyle, a Reply action, a Mark as read action), so popups and voice reply
* can be tried without asking someone to text you. The listener lets these through only in a * can be tried without asking someone to text you. The listener lets these through only in a
* debug build; everything else Gearslip posts is still ignored. * debug build; everything else Gearslip posts is still ignored.
Loaded 100 of 153 files, more files were not shown because too many files have changed in this diff. Show more