Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2344169af | ||
|
|
dc287a7fa2 | ||
|
|
2fcd586e67 | ||
|
|
184e4b70c8 | ||
|
|
30b185e86a | ||
|
|
c888d41d51 | ||
|
|
b0ffcf2b54 | ||
|
|
feab4b4e96 | ||
|
|
88e871c65e | ||
|
|
dc7018f394 | ||
|
|
e920093fa8 | ||
|
|
b71905c8c6 | ||
|
|
0a5acc012e | ||
|
|
8d673d855b | ||
|
|
6751951308 | ||
|
|
0f2582f638 | ||
|
|
bb07b95a9f | ||
|
|
9796320b58 | ||
|
|
3cea17d178 | ||
|
|
795963f178 | ||
|
|
ca33b5d01c | ||
|
|
aaa8babd09 | ||
|
|
636e40d88a | ||
|
|
1481bd9c52 | ||
|
|
b57bfb5324 | ||
|
|
e2e0d3f8bb | ||
|
|
d90c39f50a | ||
|
|
8a6c358257 | ||
|
|
de01448f1b | ||
|
|
f414a3c2c8 | ||
|
|
ea45bf994b | ||
|
|
f8aa0a259a | ||
|
|
0cf28b875c | ||
|
|
1c4b472ce0 | ||
|
|
e54fb08fc9 | ||
|
|
0afe857420 | ||
|
|
4b627bffdd | ||
|
|
fed652f9d3 | ||
|
|
ef006dc695 | ||
|
|
55c25fe967 | ||
|
|
46302b7d60 | ||
|
|
2c1fad6f8c | ||
|
|
75d0df19c3 | ||
|
|
456dda897e | ||
|
|
405cecf2e9 | ||
|
|
8fbd1701b1 | ||
|
|
6945f6755e | ||
|
|
a26605c0ab | ||
|
|
068288a0e7 | ||
|
|
dc61607028 | ||
|
|
ac8488200d | ||
|
|
c7077f2a57 | ||
|
|
6efdad5e53 | ||
|
|
c04c35b5cd | ||
|
|
75c11a92d9 | ||
|
|
99ad0d03d7 | ||
|
|
e42cb767c9 | ||
|
|
19d3afb153 | ||
|
|
32bef88a75 | ||
|
|
18571622d8 | ||
|
|
86a1f968ec | ||
|
|
325e115752 | ||
|
|
08ce09ad61 | ||
|
|
d78585fda1 | ||
|
|
4e71689f8e | ||
|
|
e8f51f4947 | ||
|
|
2281347728 | ||
|
|
6d08fbd871 | ||
|
|
1322cbaf39 | ||
|
|
db6f1b8e82 | ||
|
|
0177975bc8 | ||
|
|
5f30e7b014 | ||
|
|
c42406f353 | ||
|
|
e8d025df47 | ||
|
|
c6d8d8e787 | ||
|
|
77b84212db | ||
|
|
b12e9651c1 | ||
|
|
a5c46967b7 |
No files matched your search
@@ -10,6 +10,6 @@ liberapay: Seb3thehacker
|
|||||||
issuehunt: # Replace with a single IssueHunt username
|
issuehunt: # Replace with a single IssueHunt username
|
||||||
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
||||||
polar: # Replace with a single Polar username
|
polar: # Replace with a single Polar username
|
||||||
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
|
buy_me_a_coffee: seb3thehacker
|
||||||
thanks_dev: # Replace with a single thanks.dev username
|
thanks_dev: # Replace with a single thanks.dev username
|
||||||
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -1,26 +1,20 @@
|
|||||||
Gearslip 0.3 goes wireless, takes voice commands, and works with the car's rotary knob.
|
Gearslip 0.3.12 gives the car screen a fresh look and fixes the wireless loop 0.3.02 caused on some head units.
|
||||||
|
|
||||||
## New
|
## New
|
||||||
- **Wireless Android Auto.** Pair the phone with the car once, and leave the cable at home.
|
- Navigation cards sit down the left side, with a bigger turn card that shows your lane. The map keeps the right side.
|
||||||
- **Voice control, on the phone.** Say "navigate to a gas station", "play" a song or artist, or open an app by name. Gearslip turns speech into text itself, so it needs no Google account. "Navigate to" picks the nearest place and stops at the map app's start screen.
|
- The car screen and the phone app take their colors from your phone's Material You palette.
|
||||||
- **The rotary knob.** The car's knob moves between buttons, so cars without a touchscreen work too.
|
- The car's settings sit on the phone's home screen, each group on its own screen.
|
||||||
- **Music over Bluetooth or USB.** Bluetooth plays as the phone normally would. USB has no delay but asks to share audio each drive. Gearslip offers to pair with the car the first time.
|
- Put the home screen's player left or right of the map.
|
||||||
- **Directions on the car.** The car speaks each turn from your map app, and shows the directions on its own display where it can.
|
- The browser shows the page title, and it has an address bar, forward, reload, and a start page.
|
||||||
- **Updates in the app.** Gearslip finds new versions and installs them, with an optional beta channel.
|
- If Gearslip lacks call permissions, the car's Phone screen asks for them on the phone.
|
||||||
- **Button styles** for the car screen, picked in the intro or the car's settings.
|
|
||||||
|
|
||||||
## Fixed
|
|
||||||
- More cars play music over USB, including ones that refused Gearslip's audio before.
|
|
||||||
- Cars that give no screen size get a sharper picture, up to 1080p, and taps land where you press on cars that crop the edges.
|
|
||||||
- A new destination mid-route replaces the old route, and switching map apps ends the old app's route.
|
|
||||||
- Tidal's like and shuffle buttons show their real state.
|
|
||||||
- MapQuest finds your location.
|
|
||||||
|
|
||||||
## Changed
|
## Changed
|
||||||
- The app is half the size: 30 MB instead of 65.
|
- Gearslip uses Car App Library 1.8. Map apps show the right turn arrows and route times, and Vela works fully.
|
||||||
- On GrapheneOS, Gearslip no longer needs exploit protection compatibility mode.
|
- Setup ends with recommended apps, then the button picker, then usage notes.
|
||||||
- The car warns you when the phone's location is off.
|
|
||||||
- Support Gearslip through Liberapay or Monero, in Settings.
|
|
||||||
|
|
||||||
## Known issues
|
## Fixed
|
||||||
- Phone calls are unstable right now and may drop or fail to connect.
|
- Some head units, such as ZLink adapters, sent their Wi-Fi details twice. Gearslip rejoined the network each time and dropped the connection. It now ignores the repeat.
|
||||||
|
- Gearslip no longer crashes when a wireless connection ends.
|
||||||
|
- Opening a second music app without playing anything keeps the player on the song that's still playing.
|
||||||
|
- Vela's speed sign is full size again.
|
||||||
|
- RF Sentinel and other non-media car apps get their own tile in the launcher.
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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).
|
||||||
@@ -7,7 +7,7 @@ An Android Auto alternative built for everyone.
|
|||||||
|
|
||||||
[](https://forthebadge.com)
|
[](https://forthebadge.com)
|
||||||
|
|
||||||
[<img src="https://raw.githubusercontent.com/ImranR98/Obtainium/main/assets/graphics/badge_obtainium.png" alt="Get it on Obtainium" height="80">](https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://github.com/Seb3thehacker/gearslip)
|
[<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?
|
Questions, ideas?
|
||||||
[Join the discussion on Matrix](https://matrix.to/#/!mf0_mgBd5KqKpc9FfX_wHp1jFCcwAtPrndIn_oGIyMk?via=matrix.org).
|
[Join the discussion on Matrix](https://matrix.to/#/!mf0_mgBd5KqKpc9FfX_wHp1jFCcwAtPrndIn_oGIyMk?via=matrix.org).
|
||||||
@@ -29,25 +29,29 @@ screen-sharing mode.
|
|||||||
|
|
||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
|  |  |
|
|  |  |
|
||||||
| 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 in Gearslip's player. | A new message, read aloud at a tap, over the map. |
|
| Metrolist in Gearslip's player. | A new message, read aloud at a tap, over the map. |
|
||||||
|  |  |
|
|  |  |
|
||||||
| The weather, your next event, and what you missed. | Browse the web while parked. |
|
| The weather, your week, and what you missed. | Browse the web while parked. |
|
||||||
|
|  |  |
|
||||||
|
| Ask for a place, a song, or an app. | Pick a route to coffee nearby. Each one shows its time and distance. |
|
||||||
|
|
||||||
## 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. When a call comes in, answer or decline it on the car screen.
|
call goes out. When a call comes in, answer or decline it on the car screen. During a
|
||||||
|
call, the nav bar shows who you're talking to, and you mute or hang up from there.
|
||||||
- **Navigation.** Gearslip hosts real Car App Library map apps on the car screen.
|
- **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, Vela, OsmAnd, Flitsmeister, MAPS.ME, and MapQuest work today. While you
|
||||||
edges.
|
navigate, a big turn card on the left shows the arrow, the distance, and your lane, and
|
||||||
|
the map keeps the right side.
|
||||||
- **Music.** Browse, play, and queue tracks from a car-enabled media app, with a Now
|
- **Music.** Browse, play, and queue tracks from a car-enabled media app, with a Now
|
||||||
Playing view, an Up Next queue, and lyrics. Type a search, and apps that accept one
|
Playing view, an Up Next queue, and lyrics. Type a search, and apps that accept one
|
||||||
play the best match.
|
play the best match.
|
||||||
- **The web.** A browser template with its own on-screen keyboard, for anything that
|
- **The web.** A browser with an address bar, back and forward, and its own on-screen
|
||||||
doesn't ship a car interface of its own.
|
keyboard, for anything that doesn't ship a car interface of its own.
|
||||||
- **Weather and the day ahead.** A dashboard screen shows the week's calendar next to
|
- **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
|
- **Voice.** Tap the mic, or press the talk button on the wheel, and say "navigate to the
|
||||||
@@ -61,30 +65,35 @@ screen-sharing mode.
|
|||||||
- **Your apps, your order.** The launcher groups apps by kind: phone, maps, music,
|
- **Your apps, your order.** The launcher groups apps by kind: phone, maps, music,
|
||||||
messaging, and the rest. Press and hold an app to move it.
|
messaging, and the rest. Press and hold an app to move it.
|
||||||
- **Car controls.** Steering wheel buttons, control knobs, and D-pads all work.
|
- **Car controls.** Steering wheel buttons, control knobs, and D-pads all work.
|
||||||
|
- **Your phone's colors.** The car screen and the phone app take their colors from your
|
||||||
|
phone's Material You palette, and every card and button matches.
|
||||||
- **Themes.** Choose flat buttons or skeuomorphic ones that look like real keys.
|
- **Themes.** Choose flat buttons or skeuomorphic ones that look like real keys.
|
||||||
|
- **Settings on the phone.** The car's settings sit on the phone's home screen too, so you
|
||||||
|
can set things up before you drive. A change on either screen shows on both.
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
- **Wired or wireless.** Plug the phone into the car's USB port, or, in a car with wireless
|
- **Wired or wireless.** Plug the phone into the car's USB port, or, in a car with wireless
|
||||||
Android Auto, tap Connect wirelessly and pick the car. Pair the phone with the car over
|
Android Auto, tap Connect wirelessly and pick the car. Pair the phone with the car over
|
||||||
Bluetooth first.
|
Bluetooth first. After the first wireless drive, Android asks once whether Gearslip may
|
||||||
|
watch for that car; from then on, Gearslip connects whenever the car's Bluetooth does.
|
||||||
- **Setup walks you through it.** First launch asks for the Android Auto certificate,
|
- **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.
|
||||||
- **Templated apps work.** Gearslip renders all 16 template types that the Car App
|
- **Templated apps work.** Gearslip renders the Car App Library's templates on the car
|
||||||
Library defines, and it draws its own keyboard for search, sign-in, and text fields.
|
screen, and it draws its own keyboard for search, sign-in, and text fields. It has been
|
||||||
It has been tested against several real navigation and media apps.
|
tested against several real navigation and media apps.
|
||||||
- **Gearslip works with [LIVI](https://github.com/f-io/LIVI),** the open-source head unit
|
- **Gearslip works with [LIVI](https://github.com/f-io/LIVI),** the open-source head unit
|
||||||
used in homemade car dashboards.
|
used in homemade car dashboards.
|
||||||
- **Some apps refuse Gearslip.** Spotify, Waze, and Google Messages use a version of
|
- **Some apps refuse Gearslip.** Spotify, 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. TIDAL lets only approved apps browse its library.
|
||||||
- **Calls use the car's Bluetooth,** as they do in Android Auto. Pair the phone with the
|
- **Calls use the car's Bluetooth,** as they do in Android Auto. Pair the phone with the
|
||||||
car to hear calls through its speakers.
|
car to hear calls through its speakers.
|
||||||
- **Music reaches the car over Bluetooth or USB.** Setup asks which. Bluetooth is the
|
- **Music reaches the car over Bluetooth or USB.** Setup asks which. Bluetooth is the
|
||||||
default: Android never asks for permission, and your map app's voice reaches the car
|
default: Android never asks for permission, and your map app's voice reaches the car
|
||||||
too. USB sounds a little clearer, with no delay, but Android shows a screen-share
|
too. USB sounds a little clearer, with no delay, but each drive Android asks you to
|
||||||
consent dialog once per drive. Gearslip never reads the screen; capturing playback is
|
share your screen, which lets Gearslip send the sound. Gearslip never reads the screen; capturing playback is
|
||||||
the only way an ordinary app can pass another app's audio along.
|
the only way an ordinary app can pass another app's audio along.
|
||||||
- **Gearslip needs no adb, root, or Shizuku.** Every feature relies on an ordinary
|
- **Gearslip needs no adb, root, or Shizuku.** Every feature relies on an ordinary
|
||||||
Android permission or an on-device consent dialog. For USB audio, setup offers two
|
Android permission or an on-device consent dialog. For USB audio, setup offers two
|
||||||
@@ -95,6 +104,17 @@ screen-sharing mode.
|
|||||||
SHA-256, and installs it through Android's own installer. Turn off automatic checks in
|
SHA-256, and installs it through Android's own installer. Turn off automatic checks in
|
||||||
Settings.
|
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
|
||||||
|
|
||||||
Gearslip can send short notes that show which cars it works in. They stay off unless you
|
Gearslip can send short notes that show which cars it works in. They stay off unless you
|
||||||
@@ -170,5 +190,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).
|
||||||
|
After Width: | Height: | Size: 523 KiB |
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 211 KiB After Width: | Height: | Size: 160 KiB |
|
Before Width: | Height: | Size: 390 KiB After Width: | Height: | Size: 373 KiB |
|
Before Width: | Height: | Size: 161 KiB After Width: | Height: | Size: 351 KiB |
|
Before Width: | Height: | Size: 219 KiB After Width: | Height: | Size: 397 KiB |
|
After Width: | Height: | Size: 586 KiB |
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 116 KiB |
@@ -38,9 +38,9 @@ android {
|
|||||||
applicationId = "app.seb3thehacker.gearslip"
|
applicationId = "app.seb3thehacker.gearslip"
|
||||||
minSdk = 31
|
minSdk = 31
|
||||||
targetSdk = 37
|
targetSdk = 37
|
||||||
versionCode = 9
|
versionCode = 12
|
||||||
// -PgearslipVersionName=0.1.99-test makes a build that thinks it's older, to try the updater.
|
// -PgearslipVersionName=0.1.99-test makes a build that thinks it's older, to try the updater.
|
||||||
versionName = project.findProperty("gearslipVersionName")?.toString() ?: "0.3.00-alpha"
|
versionName = project.findProperty("gearslipVersionName")?.toString() ?: "0.3.12-alpha"
|
||||||
manifestPlaceholders["appLabel"] = "Gearslip"
|
manifestPlaceholders["appLabel"] = "Gearslip"
|
||||||
// Where opted-in phones send their daily usage note: the stats-worker/ Worker's address,
|
// Where opted-in phones send their daily usage note: the stats-worker/ Worker's address,
|
||||||
// set as gearslipStatsUrl in gradle.properties. Left empty, nothing is ever sent.
|
// set as gearslipStatsUrl in gradle.properties. Left empty, nothing is ever sent.
|
||||||
@@ -74,8 +74,9 @@ android {
|
|||||||
applicationIdSuffix = ".dev"
|
applicationIdSuffix = ".dev"
|
||||||
signingConfigs.findByName("release")?.let { signingConfig = it }
|
signingConfigs.findByName("release")?.let { signingConfig = it }
|
||||||
manifestPlaceholders["appLabel"] = "Gearslip Debug"
|
manifestPlaceholders["appLabel"] = "Gearslip Debug"
|
||||||
// -Pr8 shrinks a debug build like a release one, to test R8 in the debug install.
|
// Shrunk with R8 like a release, so a debug install behaves like what ships (a keep
|
||||||
if (project.hasProperty("r8")) {
|
// rule missing for release shows up here first). -PnoR8 skips it for a quicker build.
|
||||||
|
if (!project.hasProperty("noR8")) {
|
||||||
isMinifyEnabled = true
|
isMinifyEnabled = true
|
||||||
isShrinkResources = true
|
isShrinkResources = true
|
||||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||||
@@ -105,9 +106,10 @@ android {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Renames the produced APK and moves it to the root build/ folder (<root>/build/Gearslip-debug.apk)
|
// Copies the produced APK to the root build/ folder (<root>/build/Gearslip-debug.apk) after the
|
||||||
// after the fact rather than fighting AGP's variant-output API across versions - simpler and
|
// fact rather than fighting AGP's variant-output API across versions - simpler and doesn't break
|
||||||
// doesn't break when that API moves again.
|
// when that API moves again. A copy, not a move: AGP packages incrementally into the APK it left
|
||||||
|
// last time, and with that file gone a build came out holding nothing but classes.dex.
|
||||||
tasks.register("renameApk") {
|
tasks.register("renameApk") {
|
||||||
doLast {
|
doLast {
|
||||||
val rootBuildDir = rootProject.layout.buildDirectory.get().asFile
|
val rootBuildDir = rootProject.layout.buildDirectory.get().asFile
|
||||||
@@ -117,7 +119,6 @@ tasks.register("renameApk") {
|
|||||||
.forEach { apk ->
|
.forEach { apk ->
|
||||||
val renamed = File(rootBuildDir, "Gearslip-${apk.parentFile.name}.apk")
|
val renamed = File(rootBuildDir, "Gearslip-${apk.parentFile.name}.apk")
|
||||||
apk.copyTo(renamed, overwrite = true)
|
apk.copyTo(renamed, overwrite = true)
|
||||||
apk.delete()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -129,7 +130,6 @@ dependencies {
|
|||||||
implementation(platform(libs.androidx.compose.bom))
|
implementation(platform(libs.androidx.compose.bom))
|
||||||
implementation(libs.androidx.compose.ui)
|
implementation(libs.androidx.compose.ui)
|
||||||
implementation(libs.androidx.compose.material3)
|
implementation(libs.androidx.compose.material3)
|
||||||
implementation(libs.commons.compress)
|
|
||||||
implementation(libs.androidx.compose.material.icons.core)
|
implementation(libs.androidx.compose.material.icons.core)
|
||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.androidx.activity.compose)
|
||||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||||
|
|||||||
@@ -11,10 +11,3 @@
|
|||||||
|
|
||||||
# Speech-to-text: the native library calls back into these classes by name.
|
# Speech-to-text: the native library calls back into these classes by name.
|
||||||
-keep class com.k2fsa.sherpa.onnx.** { *; }
|
-keep class com.k2fsa.sherpa.onnx.** { *; }
|
||||||
|
|
||||||
# commons-compress refers to optional codecs Gearslip doesn't ship.
|
|
||||||
-dontwarn org.tukaani.xz.**
|
|
||||||
-dontwarn com.github.luben.zstd.**
|
|
||||||
-dontwarn org.brotli.dec.**
|
|
||||||
-dontwarn org.objectweb.asm.**
|
|
||||||
-dontwarn org.osgi.**
|
|
||||||
@@ -25,6 +25,9 @@
|
|||||||
hands over its Wi-Fi details; Gearslip then joins that network to reach the car.
|
hands over its Wi-Fi details; Gearslip then joins that network to reach the car.
|
||||||
-->
|
-->
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||||
|
<!-- Wireless auto-connect: Android tells Gearslip when a car it manages connects. -->
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_OBSERVE_COMPANION_DEVICE_PRESENCE" />
|
||||||
|
<uses-feature android:name="android.software.companion_device_setup" android:required="false" />
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
|
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
|
||||||
<uses-permission
|
<uses-permission
|
||||||
android:name="android.permission.NEARBY_WIFI_DEVICES"
|
android:name="android.permission.NEARBY_WIFI_DEVICES"
|
||||||
@@ -159,6 +162,12 @@
|
|||||||
activity so Settings can switch off just this (AppSettings.setGearslipEnabled) and leave
|
activity so Settings can switch off just this (AppSettings.setGearslipEnabled) and leave
|
||||||
the head unit to Android Auto, without disabling the screen the switch is on.
|
the head unit to Android Auto, without disabling the screen the switch is on.
|
||||||
-->
|
-->
|
||||||
|
<activity
|
||||||
|
android:name=".PermissionActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:excludeFromRecents="true"
|
||||||
|
android:theme="@style/Theme.Gearslip.Invisible" />
|
||||||
|
|
||||||
<activity-alias
|
<activity-alias
|
||||||
android:name=".GearslipUsbAccessory"
|
android:name=".GearslipUsbAccessory"
|
||||||
android:targetActivity=".GearslipActivity"
|
android:targetActivity=".GearslipActivity"
|
||||||
@@ -244,5 +253,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>
|
||||||
@@ -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")
|
||||||
|
val first = message.first
|
||||||
|
if (first.encrypted != frame.encrypted || first.messageType != frame.messageType) fail("Fragment flags changed")
|
||||||
|
val expected = first.totalSize ?: fail("Missing total size")
|
||||||
|
if (expected !in 2..MAX_MESSAGE_SIZE || payload.size > expected - message.data.size()) fail("Fragment exceeds message length")
|
||||||
|
if (payload.size > MAX_BUFFERED_SIZE - buffered) fail("Fragment buffer limit exceeded")
|
||||||
|
message.data.write(payload)
|
||||||
|
buffered += payload.size
|
||||||
|
if (frame.frameType != TYPE_LAST) return null
|
||||||
|
if (message.data.size() != expected) fail("Truncated fragmented message")
|
||||||
|
partial.remove(frame.channel)
|
||||||
|
buffered -= message.data.size()
|
||||||
|
return message.data.toByteArray()
|
||||||
}
|
}
|
||||||
TYPE_LAST -> partial.remove(frame.channel)?.apply { write(payload) }?.toByteArray()
|
|
||||||
else -> null
|
private fun fail(message: String): Nothing {
|
||||||
|
partial.clear()
|
||||||
|
buffered = 0
|
||||||
|
throw IOException(message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -14,7 +14,6 @@ import android.os.ParcelFileDescriptor
|
|||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
import android.view.MotionEvent
|
import android.view.MotionEvent
|
||||||
import android.view.Surface
|
import android.view.Surface
|
||||||
import android.view.WindowManager
|
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
@@ -117,6 +116,17 @@ class GearslipActivity : ComponentActivity(), Projection {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Android's one-time "Allow" for a car to connect by itself; see [WirelessAuto]. */
|
||||||
|
private var approvingCar: String? = null
|
||||||
|
private val carApproval = registerForActivityResult(
|
||||||
|
androidx.activity.result.contract.ActivityResultContracts.StartIntentSenderForResult(),
|
||||||
|
) { result ->
|
||||||
|
val address = approvingCar ?: return@registerForActivityResult
|
||||||
|
approvingCar = null
|
||||||
|
if (result.resultCode == RESULT_OK) app.seb3thehacker.gearslip.wireless.WirelessAuto.approved(this, address)
|
||||||
|
else app.seb3thehacker.gearslip.wireless.WirelessAuto.declined(this, address)
|
||||||
|
}
|
||||||
|
|
||||||
/** Setting Gearslip as the phone's caller ID and spam app - what lets it decline a ringing call. */
|
/** Setting Gearslip as the phone's caller ID and spam app - what lets it decline a ringing call. */
|
||||||
private val callScreeningRole = registerForActivityResult(StartActivityForResult()) { result ->
|
private val callScreeningRole = registerForActivityResult(StartActivityForResult()) { result ->
|
||||||
if (result.resultCode != RESULT_OK) GearslipLog.w("calls: call screening role declined")
|
if (result.resultCode != RESULT_OK) GearslipLog.w("calls: call screening role declined")
|
||||||
@@ -130,7 +140,6 @@ class GearslipActivity : ComponentActivity(), Projection {
|
|||||||
// is both a bad first impression and unreliable: Android does not consistently show more
|
// is both a bad first impression and unreliable: Android does not consistently show more
|
||||||
// than one system permission dialog fired back-to-back, which was silently dropping some.
|
// than one system permission dialog fired back-to-back, which was silently dropping some.
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
|
||||||
|
|
||||||
GearslipLog.init(this)
|
GearslipLog.init(this)
|
||||||
GearslipLog.installCrashHandler()
|
GearslipLog.installCrashHandler()
|
||||||
@@ -138,6 +147,12 @@ class GearslipActivity : ComponentActivity(), Projection {
|
|||||||
app.seb3thehacker.gearslip.wireless.WirelessLink.onConnected = { socket ->
|
app.seb3thehacker.gearslip.wireless.WirelessLink.onConnected = { socket ->
|
||||||
runOnUiThread { startWireless(socket) }
|
runOnUiThread { startWireless(socket) }
|
||||||
}
|
}
|
||||||
|
app.seb3thehacker.gearslip.wireless.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
|
||||||
@@ -216,6 +231,7 @@ class GearslipActivity : ComponentActivity(), Projection {
|
|||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
app.seb3thehacker.gearslip.wireless.WirelessLink.onConnected = null
|
app.seb3thehacker.gearslip.wireless.WirelessLink.onConnected = null
|
||||||
|
app.seb3thehacker.gearslip.wireless.WirelessAuto.showApproval = null
|
||||||
runCatching { unregisterReceiver(detachReceiver) }
|
runCatching { unregisterReceiver(detachReceiver) }
|
||||||
PhoneMirror.onStartRequested = null
|
PhoneMirror.onStartRequested = null
|
||||||
PhoneMirror.onTokenReady = null
|
PhoneMirror.onTokenReady = null
|
||||||
@@ -304,6 +320,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.
|
||||||
@@ -321,6 +341,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
|
||||||
|
|||||||
@@ -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,6 +2,7 @@ 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.media.CarMediaStatus
|
||||||
import app.seb3thehacker.gearslip.car.BluetoothAudioCheck
|
import app.seb3thehacker.gearslip.car.BluetoothAudioCheck
|
||||||
@@ -12,12 +13,18 @@ import app.seb3thehacker.gearslip.car.DirectionsVoice
|
|||||||
import app.seb3thehacker.gearslip.car.CarAssistant
|
import app.seb3thehacker.gearslip.car.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:
|
||||||
@@ -59,6 +66,7 @@ class GearslipRunner(
|
|||||||
/** 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 authenticationFailed = false
|
||||||
|
private var drainWriterOnExit = false
|
||||||
@Volatile private var stopped = false
|
@Volatile private var stopped = false
|
||||||
|
|
||||||
@Volatile private var state = State.WAIT_VERSION
|
@Volatile private var state = State.WAIT_VERSION
|
||||||
@@ -71,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()
|
||||||
@@ -146,6 +160,8 @@ class GearslipRunner(
|
|||||||
reportDisconnect()
|
reportDisconnect()
|
||||||
} finally {
|
} finally {
|
||||||
running = false
|
running = false
|
||||||
|
writer.shutdown()
|
||||||
|
if (drainWriterOnExit) writer.awaitTermination(2, TimeUnit.SECONDS)
|
||||||
writer.shutdownNow()
|
writer.shutdownNow()
|
||||||
log.flush()
|
log.flush()
|
||||||
}
|
}
|
||||||
@@ -160,6 +176,9 @@ 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?.close()
|
||||||
@@ -181,7 +200,7 @@ 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.
|
// A rejected handshake cannot consume later messages as if authentication succeeded.
|
||||||
if (authenticationFailed || stopped) return
|
if (authenticationFailed || stopped || !running) return
|
||||||
val payload = if (frame.encrypted) {
|
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")
|
||||||
@@ -192,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
|
||||||
@@ -204,6 +226,7 @@ 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) {
|
||||||
@@ -239,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)
|
||||||
@@ -247,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
|
||||||
@@ -265,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, "bye $reason")
|
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
|
||||||
@@ -284,24 +331,16 @@ 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)
|
||||||
|
|
||||||
// Always 1.3, on purpose. Every car that accepts Gearslip was tested this way, 1.5 units
|
val response = ProtocolVersion.respond(major, minor)
|
||||||
// included. Echoing the car's own version was tried too: a 4.3 unit rejected the
|
send(MSG_VERSION_RESPONSE, response.bytes, encrypted = false)
|
||||||
// certificate either way, so the version doesn't decide acceptance. The log says so, since
|
log.i("-> VersionResponse: ${response.major}.${response.minor}, supported=${response.supported}")
|
||||||
// a car offering 1.5 and getting 1.3 back looks like a bug to anyone reading it.
|
if (!response.supported) {
|
||||||
val claim = CLAIM_VERSION ?: (major to minor)
|
authenticationFailed = true
|
||||||
val response = byteArrayOf(
|
drainWriterOnExit = true
|
||||||
((claim.first shr 8) and 0xFF).toByte(), (claim.first and 0xFF).toByte(),
|
running = false
|
||||||
((claim.second shr 8) and 0xFF).toByte(), (claim.second and 0xFF).toByte(),
|
SessionStatus.failed("Unsupported protocol", "The head unit requested protocol $major.$minor.")
|
||||||
0, 0, // STATUS_SUCCESS
|
return
|
||||||
)
|
}
|
||||||
send(MSG_VERSION_RESPONSE, response, encrypted = false)
|
|
||||||
log.i(
|
|
||||||
"-> VersionResponse: ${claim.first}.${claim.second} status=0 (STATUS_SUCCESS)" +
|
|
||||||
if (CLAIM_VERSION != null && claim != (major to minor)) {
|
|
||||||
" - intended: Gearslip always answers ${claim.first}.${claim.second}, which cars " +
|
|
||||||
"offering $major.$minor accept too"
|
|
||||||
} else "",
|
|
||||||
)
|
|
||||||
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")
|
||||||
@@ -318,17 +357,13 @@ class GearslipRunner(
|
|||||||
if (stopped) return
|
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
|
authenticationFailed = true
|
||||||
|
running = false
|
||||||
SessionReport.print()
|
SessionReport.print()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -345,7 +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 in listOf(-2, -3))) return
|
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) }
|
||||||
|
|
||||||
@@ -356,17 +391,17 @@ 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, "auth -2")
|
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
|
authenticationFailed = true
|
||||||
@@ -397,6 +432,7 @@ class GearslipRunner(
|
|||||||
authenticationFailed = true
|
authenticationFailed = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (authenticationFailed) running = false
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun sendServiceDiscoveryRequest() {
|
private fun sendServiceDiscoveryRequest() {
|
||||||
@@ -407,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.
|
||||||
@@ -527,7 +564,17 @@ class GearslipRunner(
|
|||||||
// as Android Auto does, instead of letting it play on to no one.
|
// as Android Auto does, instead of letting it play on to no one.
|
||||||
onTakenBack = { CarServices.pausePhoneMedia("the car switched to another source") },
|
onTakenBack = { CarServices.pausePhoneMedia("the car switched to another source") },
|
||||||
)
|
)
|
||||||
|
if (CarSettings.pipeAudio.value) {
|
||||||
openChannel(media.serviceId)
|
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. */
|
/** The car's directions channel, where [DirectionsVoice] reads out each turn. */
|
||||||
@@ -545,7 +592,7 @@ class GearslipRunner(
|
|||||||
tag = "guidance",
|
tag = "guidance",
|
||||||
sessionId = GUIDANCE_SESSION_ID,
|
sessionId = GUIDANCE_SESSION_ID,
|
||||||
asksFocus = false,
|
asksFocus = false,
|
||||||
onReady = { DirectionsVoice.attach(it) },
|
onReady = { DirectionsVoice.attach(it) { audioLink?.streaming == true } },
|
||||||
onClose = { DirectionsVoice.detach(it) },
|
onClose = { DirectionsVoice.detach(it) },
|
||||||
)
|
)
|
||||||
openChannel(guidance.serviceId)
|
openChannel(guidance.serviceId)
|
||||||
@@ -619,12 +666,15 @@ class GearslipRunner(
|
|||||||
|
|
||||||
/** Turn-by-turn for the instrument cluster: the next turn, its road, and how far. */
|
/** Turn-by-turn for the instrument cluster: the next turn, its road, and how far. */
|
||||||
private fun startClusterNavChannel(serviceDiscoveryResponse: ByteArray) {
|
private fun startClusterNavChannel(serviceDiscoveryResponse: ByteArray) {
|
||||||
val channel = ServiceDiscovery.findNavStatusChannel(serviceDiscoveryResponse) ?: run {
|
val navigation = ServiceDiscovery.findNavStatusService(serviceDiscoveryResponse) ?: run {
|
||||||
log.i("no navigation status channel - the cluster won't show turns")
|
log.i("no navigation status channel - the cluster won't show turns")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
val channel = navigation.serviceId
|
||||||
log.i("navigation status channel: $channel")
|
log.i("navigation status channel: $channel")
|
||||||
clusterNav = ClusterNav(channel) { id, body -> send(id, body, encrypted = true, channel = channel) }
|
clusterNav = ClusterNav(channel, navigation.minimumIntervalMs.toLong()) { id, body ->
|
||||||
|
send(id, body, encrypted = true, channel = channel)
|
||||||
|
}
|
||||||
openChannel(channel)
|
openChannel(channel)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -680,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"}")
|
||||||
@@ -763,41 +813,46 @@ 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
|
else -> log.i("<- unhandled input message id=$messageId (${body.size} bytes)")
|
||||||
// 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)
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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, 4)?.let(::onKeyEvent)
|
||||||
Wire.bytes(report, 6)?.let(::onRelativeEvent)
|
Wire.bytes(report, 6)?.let(::onRelativeEvent)
|
||||||
val touch = Wire.bytes(report, 3) ?: Wire.bytes(report, 7)
|
// 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) {
|
if (touch == null) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val touchFields = Wire.fields(touch)
|
// The 2018 Uconnect agrees to Exit but keeps showing our last frame and sending
|
||||||
val action = Wire.varint(touchFields, 3)?.toInt() ?: 0
|
// touches, which looked like a crash. A touch means our screen is still up there.
|
||||||
val pointers = Wire.allBytes(touchFields, 1)
|
if (videoStarted && !videoProjected) {
|
||||||
if (pointers.isEmpty()) return
|
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)
|
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()
|
val size = config?.pixelSize()
|
||||||
// The car crops its margin off the frame and measures taps from the corner of what
|
// A stretched frame (see [screenShape]): the car's tap is on the frame, the UI is drawn smaller.
|
||||||
// it shows, so they come in short by half the margin: the 2024 VW (margin 240) sent
|
val drawn = drawSize
|
||||||
// y=6, which is inside the cropped band, and the Uconnect's never passed 351 on a
|
val stretched = drawn != null && size != null
|
||||||
// 480-high frame with a 130 margin. Without this every tap landed 120 px too high
|
val (action, actionIndex, raw) =
|
||||||
// and the VW couldn't press past its first screen.
|
(if (stretched) InputProtocol.touch(touch, size) else InputProtocol.touch(touch, size, shift.left, shift.top)) ?: return
|
||||||
val shift = config?.marginInsets() ?: Insets.NONE
|
val points = if (drawn != null && size != null) {
|
||||||
val points = pointers.mapIndexedNotNull { index, pointer ->
|
raw.map { TouchPoint(it.id, it.x * drawn.first / size.first, it.y * drawn.second / size.second) }
|
||||||
val fields = Wire.fields(pointer)
|
} else {
|
||||||
val rawX = Wire.varint(fields, 1)?.toInt() ?: return@mapIndexedNotNull null
|
raw
|
||||||
val rawY = Wire.varint(fields, 2)?.toInt() ?: return@mapIndexedNotNull null
|
|
||||||
val id = Wire.varint(fields, 3)?.toInt() ?: index
|
|
||||||
TouchPoint(id, clamp(rawX + shift.left, size?.first), clamp(rawY + shift.top, size?.second))
|
|
||||||
}
|
}
|
||||||
if (points.isEmpty()) return
|
|
||||||
val actionIndex = (Wire.varint(touchFields, 2)?.toInt() ?: 0).coerceIn(0, points.size - 1)
|
|
||||||
if (points.size > 1 && touchesSeen < 3) {
|
if (points.size > 1 && touchesSeen < 3) {
|
||||||
log.i("multi-touch: ${points.size} pointers, action=$action index=$actionIndex")
|
log.i("multi-touch: ${points.size} pointers, action=$action index=$actionIndex")
|
||||||
}
|
}
|
||||||
@@ -812,10 +867,6 @@ class GearslipRunner(
|
|||||||
projection?.onTouch(action, actionIndex, points)
|
projection?.onTouch(action, actionIndex, points)
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> log.i("<- unhandled input message id=$messageId (${body.size} bytes)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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
|
||||||
@@ -967,6 +1018,37 @@ class GearslipRunner(
|
|||||||
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
|
||||||
@@ -987,18 +1069,37 @@ 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")
|
||||||
}
|
}
|
||||||
|
|
||||||
val useProjection = projection != null
|
val useProjection = projection != null
|
||||||
|
// 60fps only over a cable: over Wi-Fi it doubles the data for little the driver sees.
|
||||||
|
videoFrameRate = if (config.frameRate == 1 && !wireless) 60 else 30
|
||||||
|
smoothWindows = 0
|
||||||
|
lastCut = null
|
||||||
|
decoderBound = false
|
||||||
|
adaptiveVideo = CarSettings.experimentalFeaturesEnabled.value && CarSettings.adaptiveVideoEnabled.value
|
||||||
|
if (adaptiveVideo) log.i("video: adaptive bitrate on (experimental)")
|
||||||
videoSource = VideoSource(
|
videoSource = VideoSource(
|
||||||
width = width,
|
width = width,
|
||||||
height = height,
|
height = height,
|
||||||
// 60fps only over a cable: over Wi-Fi it doubles the data for little the driver sees.
|
frameRate = videoFrameRate,
|
||||||
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,
|
wireless = wireless,
|
||||||
onCodecConfig = { csd ->
|
onCodecConfig = { csd ->
|
||||||
@@ -1015,7 +1116,16 @@ class GearslipRunner(
|
|||||||
source.start()
|
source.start()
|
||||||
val surface = source.inputSurface
|
val surface = source.inputSurface
|
||||||
if (projection != null && surface != null) {
|
if (projection != null && surface != null) {
|
||||||
|
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)
|
projection.onSurfaceReady(surface, width, height, config.density)
|
||||||
|
}
|
||||||
SessionStatus.projecting()
|
SessionStatus.projecting()
|
||||||
BluetoothAudioCheck.onProjecting()
|
BluetoothAudioCheck.onProjecting()
|
||||||
LocationCheck.onProjecting()
|
LocationCheck.onProjecting()
|
||||||
@@ -1156,18 +1266,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)")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1189,41 +1296,16 @@ 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.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1302,8 +1384,10 @@ class GearslipRunner(
|
|||||||
SessionReport.stream("${"%.1f".format(fps)} fps, sent=$sent acked=$acksSeen")
|
SessionReport.stream("${"%.1f".format(fps)} fps, sent=$sent acked=$acksSeen")
|
||||||
log.i(
|
log.i(
|
||||||
"video: ${"%.1f".format(fps)} fps over 5s | sent=$sent waited=${waited}ms " +
|
"video: ${"%.1f".format(fps)} fps over 5s | sent=$sent waited=${waited}ms " +
|
||||||
"acked=$acksSeen inFlight=$inFlight split=$splitMessages",
|
"acked=$acksSeen inFlight=$inFlight split=$splitMessages " +
|
||||||
|
"kbps=${(videoSource?.bitRate ?: 0) / 1000}",
|
||||||
)
|
)
|
||||||
|
adaptBitRate(fps, waited)
|
||||||
|
|
||||||
// Self-healing safety net: force a fresh keyframe periodically regardless
|
// Self-healing safety net: force a fresh keyframe periodically regardless
|
||||||
// of whether we think one is owed. Protects against any future case where
|
// of whether we think one is owed. Protects against any future case where
|
||||||
@@ -1318,13 +1402,65 @@ class GearslipRunner(
|
|||||||
}.apply { isDaemon = true }.start()
|
}.apply { isDaemon = true }.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
/** The frame rate the stream was set up for, which adaptive bitrate measures against. */
|
||||||
|
@Volatile private var videoFrameRate = 30
|
||||||
|
|
||||||
|
/** Five-second windows in a row the stream kept up, towards climbing back up. */
|
||||||
|
private var smoothWindows = 0
|
||||||
|
/** The bitrate and frame rate before the last cut, to see whether cutting helped. */
|
||||||
|
private var lastCut: Pair<Int, Double>? = null
|
||||||
|
/** Experimental: read once per stream, so flipping the switch mid-drive changes nothing. */
|
||||||
|
@Volatile private var adaptiveVideo = false
|
||||||
|
/** Set once a cut didn't help: this car's decoder, not the link, sets the pace. */
|
||||||
|
private var decoderBound = false
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The protocol version Gearslip answers every car with, as (major, minor), or null to echo
|
* Adaptive bitrate. A window where the encoder spent most of its time held back by the
|
||||||
* the head unit's. Kept at 1.3 because every working car was tested with it; see
|
* car's acks while the frame rate sagged means the car, or the Wi-Fi to it, can't take
|
||||||
* onVersionRequest.
|
* frames this size: a quarter comes off. Two easy windows in a row put a tenth back, up to
|
||||||
|
* where the stream started. A still screen (few frames, no waiting) changes nothing.
|
||||||
*/
|
*/
|
||||||
val CLAIM_VERSION: Pair<Int, Int>? = 1 to 3
|
private fun adaptBitRate(fps: Double, waitedMs: Long) {
|
||||||
|
if (!adaptiveVideo) return
|
||||||
|
val source = videoSource ?: return
|
||||||
|
val target = videoFrameRate.toDouble()
|
||||||
|
val behind = waitedMs > ADAPT_WINDOW_MS / 2 && fps < target * 0.8
|
||||||
|
val easy = waitedMs < ADAPT_WINDOW_MS / 10
|
||||||
|
when {
|
||||||
|
behind && !decoderBound -> {
|
||||||
|
smoothWindows = 0
|
||||||
|
val cut = lastCut
|
||||||
|
if (cut != null && fps < cut.second * 1.05) {
|
||||||
|
// The last cut bought no frames: the car's decoder is the limit, not the
|
||||||
|
// link, and smaller frames would only look worse. Undo it and stop here.
|
||||||
|
decoderBound = true
|
||||||
|
source.setBitRate(cut.first)
|
||||||
|
log.i("video: a lower bitrate didn't help the car keep up - back to ${cut.first / 1000} kbps, adapting off")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val before = source.bitRate
|
||||||
|
source.setBitRate((before * 0.75).toInt())
|
||||||
|
if (source.bitRate != before) {
|
||||||
|
lastCut = before to fps
|
||||||
|
log.i("video: car falling behind - bitrate ${before / 1000} -> ${source.bitRate / 1000} kbps")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
easy && source.bitRate < source.fullBitRate -> {
|
||||||
|
lastCut = null
|
||||||
|
if (++smoothWindows >= 2) {
|
||||||
|
smoothWindows = 0
|
||||||
|
val before = source.bitRate
|
||||||
|
source.setBitRate((before * 1.1).toInt() + 1)
|
||||||
|
log.i("video: keeping up - bitrate ${before / 1000} -> ${source.bitRate / 1000} kbps")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> smoothWindows = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
/** The stats window adaptive bitrate reads, matching the watchdog's sleep. */
|
||||||
|
const val ADAPT_WINDOW_MS = 5_000L
|
||||||
|
|
||||||
// protobuf/aap_protobuf/service/control/ControlMessageType.proto:7-17
|
// protobuf/aap_protobuf/service/control/ControlMessageType.proto:7-17
|
||||||
const val MSG_VERSION_REQUEST = 1
|
const val MSG_VERSION_REQUEST = 1
|
||||||
@@ -1338,6 +1474,8 @@ class GearslipRunner(
|
|||||||
const val MSG_BYEBYE_REQUEST = 15
|
const val MSG_BYEBYE_REQUEST = 15
|
||||||
const val MSG_BYEBYE_RESPONSE = 16
|
const val MSG_BYEBYE_RESPONSE = 16
|
||||||
const val MSG_VOICE_SESSION_NOTIFICATION = 17
|
const val MSG_VOICE_SESSION_NOTIFICATION = 17
|
||||||
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package app.seb3thehacker.gearslip
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.app.ActivityOptions
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.Display
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asks for runtime permissions on the phone's own screen, for a car screen that can't show
|
||||||
|
* Android's dialog itself. It has no look of its own: the dialog appears, the answer is given,
|
||||||
|
* and it finishes.
|
||||||
|
*/
|
||||||
|
class PermissionActivity : Activity() {
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
val wanted = intent.getStringArrayExtra(EXTRA_PERMISSIONS)
|
||||||
|
?.filter { checkSelfPermission(it) != android.content.pm.PackageManager.PERMISSION_GRANTED }
|
||||||
|
.orEmpty()
|
||||||
|
if (wanted.isEmpty() || savedInstanceState != null) {
|
||||||
|
if (wanted.isEmpty()) finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
requestPermissions(wanted.toTypedArray(), REQUEST)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
|
||||||
|
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||||
|
GearslipLog.i("permissions: ${permissions.zip(grantResults.toList()).joinToString { (p, r) -> "${p.substringAfterLast('.')}=${r == 0}" }}")
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val EXTRA_PERMISSIONS = "permissions"
|
||||||
|
private const val REQUEST = 1
|
||||||
|
|
||||||
|
/** Shows the permission dialog on the phone, whichever screen [context] belongs to. */
|
||||||
|
fun ask(context: Context, permissions: Array<String>) {
|
||||||
|
val intent = Intent(context, PermissionActivity::class.java)
|
||||||
|
.putExtra(EXTRA_PERMISSIONS, permissions)
|
||||||
|
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
val options = ActivityOptions.makeBasic().setLaunchDisplayId(Display.DEFAULT_DISPLAY)
|
||||||
|
runCatching { context.startActivity(intent, options.toBundle()) }
|
||||||
|
.onFailure { GearslipLog.w("permissions: could not ask on the phone: ${it.message}") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package app.seb3thehacker.gearslip
|
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 {
|
||||||
@@ -41,29 +34,7 @@ object Protobuf {
|
|||||||
fun readInt32Field(data: ByteArray, field: Int): Int? = readInt64Field(data, field)?.toInt()
|
fun readInt32Field(data: ByteArray, field: Int): Int? = readInt64Field(data, field)?.toInt()
|
||||||
|
|
||||||
/** Reads a varint field whole, for int64s such as a ping's timestamp. */
|
/** Reads a varint field whole, for int64s such as a ping's timestamp. */
|
||||||
fun readInt64Field(data: ByteArray, field: Int): Long? {
|
fun readInt64Field(data: ByteArray, field: Int): Long? = Wire.varint(Wire.fields(data), field)
|
||||||
var pos = 0
|
|
||||||
while (pos < data.size) {
|
|
||||||
val (tag, afterTag) = readVarint(data, pos) ?: return null
|
|
||||||
pos = afterTag
|
|
||||||
val number = (tag ushr 3).toInt()
|
|
||||||
when ((tag and 7L).toInt()) {
|
|
||||||
0 -> {
|
|
||||||
val (value, afterValue) = readVarint(data, pos) ?: return null
|
|
||||||
pos = afterValue
|
|
||||||
if (number == field) return value
|
|
||||||
}
|
|
||||||
2 -> {
|
|
||||||
val (len, afterLen) = readVarint(data, pos) ?: return null
|
|
||||||
pos = afterLen + len.toInt()
|
|
||||||
}
|
|
||||||
5 -> pos += 4
|
|
||||||
1 -> pos += 8
|
|
||||||
else -> return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shallow field dump. Used on ServiceDiscoveryResponse: its make/model/year strings
|
* Shallow field dump. Used on ServiceDiscoveryResponse: its make/model/year strings
|
||||||
@@ -71,6 +42,7 @@ object Protobuf {
|
|||||||
* proof that the session is live and we are decrypting correctly.
|
* proof that the session is live and we are decrypting correctly.
|
||||||
*/
|
*/
|
||||||
fun describe(data: ByteArray, indent: String = " "): String {
|
fun describe(data: ByteArray, indent: String = " "): String {
|
||||||
|
if (indent.length > 32) return "$indent(nested message)\n"
|
||||||
val sb = StringBuilder()
|
val sb = StringBuilder()
|
||||||
var pos = 0
|
var pos = 0
|
||||||
while (pos < data.size) {
|
while (pos < data.size) {
|
||||||
@@ -89,6 +61,7 @@ object Protobuf {
|
|||||||
}
|
}
|
||||||
2 -> {
|
2 -> {
|
||||||
val (len, afterLen) = readVarint(data, pos) ?: break
|
val (len, afterLen) = readVarint(data, pos) ?: break
|
||||||
|
if (len < 0 || len > data.size - afterLen) break
|
||||||
val end = afterLen + len.toInt()
|
val end = afterLen + len.toInt()
|
||||||
if (end > data.size) break
|
if (end > data.size) break
|
||||||
val body = data.copyOfRange(afterLen, end)
|
val body = data.copyOfRange(afterLen, end)
|
||||||
@@ -117,6 +90,7 @@ object Protobuf {
|
|||||||
var pos = start
|
var pos = start
|
||||||
while (pos < data.size && shift <= 63) {
|
while (pos < data.size && shift <= 63) {
|
||||||
val b = data[pos].toInt() and 0xFF
|
val b = data[pos].toInt() and 0xFF
|
||||||
|
if (shift == 63 && b > 1) return null
|
||||||
result = result or ((b and 0x7F).toLong() shl shift)
|
result = result or ((b and 0x7F).toLong() shl shift)
|
||||||
pos++
|
pos++
|
||||||
if (b and 0x80 == 0) return result to pos
|
if (b and 0x80 == 0) return result to pos
|
||||||
@@ -135,45 +109,68 @@ object Wire {
|
|||||||
val out = mutableListOf<Field>()
|
val out = mutableListOf<Field>()
|
||||||
var pos = 0
|
var pos = 0
|
||||||
while (pos < data.size) {
|
while (pos < data.size) {
|
||||||
val tag = readVarint(data, pos) ?: break
|
val tag = readVarint(data, pos) ?: return emptyList()
|
||||||
pos = tag.second
|
pos = tag.second
|
||||||
val number = (tag.first ushr 3).toInt()
|
val number = (tag.first ushr 3).toInt()
|
||||||
|
if (tag.first ushr 3 !in 1..0x1fff_ffffL) return emptyList()
|
||||||
when ((tag.first and 7L).toInt()) {
|
when ((tag.first and 7L).toInt()) {
|
||||||
0 -> {
|
0 -> {
|
||||||
val v = readVarint(data, pos) ?: break
|
val v = readVarint(data, pos) ?: return emptyList()
|
||||||
pos = v.second
|
pos = v.second
|
||||||
out += Field(number, 0, v.first, null)
|
out += Field(number, 0, v.first, null)
|
||||||
}
|
}
|
||||||
1 -> { if (pos + 8 > data.size) break; pos += 8 }
|
1 -> { if (data.size - pos < 8) return emptyList(); pos += 8 }
|
||||||
2 -> {
|
2 -> {
|
||||||
val len = readVarint(data, pos) ?: break
|
val len = readVarint(data, pos) ?: return emptyList()
|
||||||
|
if (len.first < 0 || len.first > data.size - len.second) return emptyList()
|
||||||
val end = len.second + len.first.toInt()
|
val end = len.second + len.first.toInt()
|
||||||
if (end > data.size || end < len.second) break
|
if (end > data.size || end < len.second) return emptyList()
|
||||||
out += Field(number, 2, 0, data.copyOfRange(len.second, end))
|
out += Field(number, 2, 0, data.copyOfRange(len.second, end))
|
||||||
pos = end
|
pos = end
|
||||||
}
|
}
|
||||||
5 -> { if (pos + 4 > data.size) break; pos += 4 }
|
5 -> { if (data.size - pos < 4) return emptyList(); pos += 4 }
|
||||||
else -> break
|
else -> return emptyList()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
fun varint(fields: List<Field>, number: Int): Long? =
|
fun varint(fields: List<Field>, number: Int): Long? =
|
||||||
fields.firstOrNull { it.number == number && it.wireType == 0 }?.varint
|
fields.lastOrNull { it.number == number && it.wireType == 0 }?.varint
|
||||||
|
|
||||||
fun bytes(fields: List<Field>, number: Int): ByteArray? =
|
fun bytes(fields: List<Field>, number: Int): ByteArray? =
|
||||||
fields.firstOrNull { it.number == number && it.wireType == 2 }?.bytes
|
fields.lastOrNull { it.number == number && it.wireType == 2 }?.bytes
|
||||||
|
|
||||||
fun allBytes(fields: List<Field>, number: Int): List<ByteArray> =
|
fun allBytes(fields: List<Field>, number: Int): List<ByteArray> =
|
||||||
fields.filter { it.number == number && it.wireType == 2 }.mapNotNull { it.bytes }
|
fields.filter { it.number == number && it.wireType == 2 }.mapNotNull { it.bytes }
|
||||||
|
|
||||||
|
/** Protobuf readers accept both packed and unpacked repeated numeric fields. */
|
||||||
|
fun repeatedInts(fields: List<Field>, number: Int): List<Int> =
|
||||||
|
repeatedLongs(fields, number)?.map(Long::toInt).orEmpty()
|
||||||
|
|
||||||
|
/** Null means a packed field contained an incomplete or overflowing varint. */
|
||||||
|
fun repeatedLongs(fields: List<Field>, number: Int): List<Long>? = buildList {
|
||||||
|
for (field in fields.filter { it.number == number }) {
|
||||||
|
if (field.wireType == 0) add(field.varint)
|
||||||
|
else if (field.wireType == 2) {
|
||||||
|
val bytes = field.bytes ?: continue
|
||||||
|
var position = 0
|
||||||
|
while (position < bytes.size) {
|
||||||
|
val (value, next) = readVarint(bytes, position) ?: return null
|
||||||
|
add(value)
|
||||||
|
position = next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun readVarint(data: ByteArray, start: Int): Pair<Long, Int>? {
|
private fun readVarint(data: ByteArray, start: Int): Pair<Long, Int>? {
|
||||||
var result = 0L
|
var result = 0L
|
||||||
var shift = 0
|
var shift = 0
|
||||||
var pos = start
|
var pos = start
|
||||||
while (pos < data.size && shift <= 63) {
|
while (pos < data.size && shift <= 63) {
|
||||||
val b = data[pos].toInt() and 0xFF
|
val b = data[pos].toInt() and 0xFF
|
||||||
|
if (shift == 63 && b > 1) return null
|
||||||
result = result or ((b and 0x7F).toLong() shl shift)
|
result = result or ((b and 0x7F).toLong() shl shift)
|
||||||
pos++
|
pos++
|
||||||
if (b and 0x80 == 0) return result to pos
|
if (b and 0x80 == 0) return result to pos
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package app.seb3thehacker.gearslip
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Advertises our supported 1.x version, as Android Auto does for older head units. Head units that
|
||||||
|
* offer a newer major version (LIVI asks for 6.1, a RAV4 and the DHU for 4.2) still accept a 1.x
|
||||||
|
* answer, so only a major version of 0 is refused.
|
||||||
|
*/
|
||||||
|
internal object ProtocolVersion {
|
||||||
|
data class Response(val major: Int, val minor: Int, val supported: Boolean) {
|
||||||
|
val bytes: ByteArray get() = byteArrayOf(
|
||||||
|
(major ushr 8).toByte(), major.toByte(), (minor ushr 8).toByte(), minor.toByte(),
|
||||||
|
if (supported) 0 else -1, if (supported) 0 else -1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun respond(major: Int, minor: Int): Response = Response(1, 7, major >= 1 && minor in 0..0xffff)
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
@@ -219,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)
|
||||||
@@ -116,13 +116,26 @@ object ServiceDiscovery {
|
|||||||
*/
|
*/
|
||||||
fun findMediaStatusChannel(response: ByteArray): Int? = findChannelWith(response, 9)
|
fun findMediaStatusChannel(response: ByteArray): Int? = findChannelWith(response, 9)
|
||||||
|
|
||||||
/** The instrument cluster's turn-by-turn channel (NavigationStatusService, Service field 8). */
|
class NavStatusService(val serviceId: Int, val minimumIntervalMs: Int)
|
||||||
fun findNavStatusChannel(response: ByteArray): Int? = findChannelWith(response, 8)
|
|
||||||
|
/** The cluster advertises its minimum update interval in NavigationStatusService field 1. */
|
||||||
|
fun findNavStatusService(response: ByteArray): NavStatusService? =
|
||||||
|
Wire.allBytes(Wire.fields(response), 1).firstNotNullOfOrNull { channel ->
|
||||||
|
val service = Wire.fields(channel)
|
||||||
|
val id = serviceId(service) ?: return@firstNotNullOfOrNull null
|
||||||
|
val navigation = Wire.bytes(service, 8) ?: return@firstNotNullOfOrNull null
|
||||||
|
val interval = Wire.varint(Wire.fields(navigation), 1) ?: return@firstNotNullOfOrNull null
|
||||||
|
if (interval !in 0L..Int.MAX_VALUE.toLong()) return@firstNotNullOfOrNull null
|
||||||
|
NavStatusService(id, interval.toInt())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun serviceId(fields: List<Wire.Field>): Int? =
|
||||||
|
Wire.varint(fields, 1)?.takeIf { it in 1L..255L }?.toInt()
|
||||||
|
|
||||||
private fun findChannelWith(response: ByteArray, serviceField: Int): Int? =
|
private fun findChannelWith(response: ByteArray, serviceField: Int): Int? =
|
||||||
Wire.allBytes(Wire.fields(response), 1).firstNotNullOfOrNull { channel ->
|
Wire.allBytes(Wire.fields(response), 1).firstNotNullOfOrNull { channel ->
|
||||||
val service = Wire.fields(channel)
|
val service = Wire.fields(channel)
|
||||||
if (service.any { it.number == serviceField }) Wire.varint(service, 1)?.toInt() else null
|
if (Wire.bytes(service, serviceField) != null) serviceId(service) else null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -134,7 +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(
|
||||||
@@ -179,9 +192,7 @@ object ServiceDiscovery {
|
|||||||
val fields = Wire.fields(Wire.bytes(service, 6) ?: continue)
|
val fields = Wire.fields(Wire.bytes(service, 6) ?: continue)
|
||||||
val address = Wire.bytes(fields, 1)?.let { String(it, Charsets.UTF_8) } ?: continue
|
val address = Wire.bytes(fields, 1)?.let { String(it, Charsets.UTF_8) } ?: continue
|
||||||
// Packed as declared, though a car may send each method as its own varint.
|
// Packed as declared, though a car may send each method as its own varint.
|
||||||
val methods = fields.filter { it.number == 2 }.flatMap { f ->
|
val methods = Wire.repeatedInts(fields, 2)
|
||||||
f.bytes?.let(::packedVarints) ?: listOf(f.varint.toInt())
|
|
||||||
}
|
|
||||||
return BluetoothService(id, address, methods)
|
return BluetoothService(id, address, methods)
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
@@ -215,8 +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.
|
||||||
@@ -245,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.
|
||||||
@@ -289,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)
|
||||||
@@ -316,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()
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -42,6 +42,14 @@ class VideoSource(
|
|||||||
private set
|
private set
|
||||||
|
|
||||||
private var codec: MediaCodec? = null
|
private var codec: MediaCodec? = null
|
||||||
|
|
||||||
|
/** The bitrate the encoder started at, the most [setBitRate] ever goes back up to. */
|
||||||
|
var fullBitRate = 0
|
||||||
|
private set
|
||||||
|
|
||||||
|
/** What the encoder is asked for right now. */
|
||||||
|
@Volatile var bitRate = 0
|
||||||
|
private set
|
||||||
private var worker: Thread? = null
|
private var worker: Thread? = null
|
||||||
@Volatile private var running = false
|
@Volatile private var running = false
|
||||||
private var frameIndex = 0L
|
private var frameIndex = 0L
|
||||||
@@ -51,6 +59,8 @@ class VideoSource(
|
|||||||
val bitRate = defaultBitRate(width, height, frameRate, level).let {
|
val bitRate = defaultBitRate(width, height, frameRate, level).let {
|
||||||
if (wireless) (it * WIRELESS_SHARE).toInt().coerceAtMost(WIRELESS_MAX_BIT_RATE) else it
|
if (wireless) (it * WIRELESS_SHARE).toInt().coerceAtMost(WIRELESS_MAX_BIT_RATE) else it
|
||||||
}
|
}
|
||||||
|
fullBitRate = bitRate
|
||||||
|
this.bitRate = bitRate
|
||||||
fun format(tuned: Boolean) = MediaFormat.createVideoFormat(MIME, width, height).apply {
|
fun format(tuned: Boolean) = MediaFormat.createVideoFormat(MIME, width, height).apply {
|
||||||
setInteger(
|
setInteger(
|
||||||
MediaFormat.KEY_COLOR_FORMAT,
|
MediaFormat.KEY_COLOR_FORMAT,
|
||||||
@@ -128,9 +138,28 @@ class VideoSource(
|
|||||||
}.onFailure { log.w("requestSyncFrame failed: ${it.message}") }
|
}.onFailure { log.w("requestSyncFrame failed: ${it.message}") }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Changes the bitrate while streaming, without a new keyframe or a restart. Used to back off
|
||||||
|
* when the car (or the Wi-Fi to it) can't keep up, and to climb back once it can.
|
||||||
|
*/
|
||||||
|
fun setBitRate(bps: Int) {
|
||||||
|
val target = bps.coerceIn(fullBitRate / 4, fullBitRate)
|
||||||
|
if (target == bitRate) return
|
||||||
|
runCatching {
|
||||||
|
codec?.setParameters(Bundle().apply { putInt(MediaCodec.PARAMETER_KEY_VIDEO_BITRATE, target) })
|
||||||
|
bitRate = target
|
||||||
|
}.onFailure { log.w("setBitRate failed: ${it.message}") }
|
||||||
|
}
|
||||||
|
|
||||||
fun stop() {
|
fun stop() {
|
||||||
running = false
|
running = false
|
||||||
|
// Stop is called from threads that are themselves being shut down. An interrupt here
|
||||||
|
// used to escape as an uncaught exception and take the whole app down with the session.
|
||||||
|
try {
|
||||||
worker?.join(1_000)
|
worker?.join(1_000)
|
||||||
|
} catch (e: InterruptedException) {
|
||||||
|
Thread.currentThread().interrupt()
|
||||||
|
}
|
||||||
runCatching { inputSurface?.release() }
|
runCatching { inputSurface?.release() }
|
||||||
inputSurface = null
|
inputSurface = null
|
||||||
runCatching { codec?.stop() }
|
runCatching { codec?.stop() }
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import app.seb3thehacker.gearslip.GearslipLog
|
|||||||
import app.seb3thehacker.gearslip.Protobuf
|
import app.seb3thehacker.gearslip.Protobuf
|
||||||
import app.seb3thehacker.gearslip.ServiceDiscovery
|
import app.seb3thehacker.gearslip.ServiceDiscovery
|
||||||
import app.seb3thehacker.gearslip.Wire
|
import app.seb3thehacker.gearslip.Wire
|
||||||
|
import java.util.concurrent.CountDownLatch
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The phone end of the head unit's media audio sink, the audio twin of the video channel:
|
* The phone end of the head unit's media audio sink, the audio twin of the video channel:
|
||||||
@@ -25,7 +27,10 @@ class AudioLink(
|
|||||||
/** Log name: "audio" for the media sink, "guidance" for the directions sink. */
|
/** Log name: "audio" for the media sink, "guidance" for the directions sink. */
|
||||||
private val tag: String = "audio",
|
private val tag: String = "audio",
|
||||||
private val sessionId: Int = SESSION_ID,
|
private val sessionId: Int = SESSION_ID,
|
||||||
/** Media asks the car for focus; directions just start, and the car ducks music under them. */
|
/**
|
||||||
|
* Media asks the car for focus when it starts. Directions ask only around each announcement,
|
||||||
|
* through [askForSpeakers], and only when the music isn't already ours.
|
||||||
|
*/
|
||||||
private val asksFocus: Boolean = true,
|
private val asksFocus: Boolean = true,
|
||||||
private val onReady: (AudioLink) -> Unit = { CarAudio.attach(it) },
|
private val onReady: (AudioLink) -> Unit = { CarAudio.attach(it) },
|
||||||
private val onClose: (AudioLink) -> Unit = { CarAudio.detach(it) },
|
private val onClose: (AudioLink) -> Unit = { CarAudio.detach(it) },
|
||||||
@@ -58,6 +63,47 @@ 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("$tag: <- ChannelOpenResponse: status=$status")
|
GearslipLog.i("$tag: <- ChannelOpenResponse: status=$status")
|
||||||
if (status != 0) {
|
if (status != 0) {
|
||||||
@@ -161,6 +207,12 @@ class AudioLink(
|
|||||||
fun onFocus(state: Int) {
|
fun onFocus(state: Int) {
|
||||||
val name = FOCUS_STATES[state] ?: "unknown($state)"
|
val name = FOCUS_STATES[state] ?: "unknown($state)"
|
||||||
GearslipLog.i("$tag: <- AudioFocusResponse state=$name")
|
GearslipLog.i("$tag: <- AudioFocusResponse state=$name")
|
||||||
|
if (!asksFocus) {
|
||||||
|
// Directions speak whatever the answer: a lost turn is worse than a loud one.
|
||||||
|
answerDueBy = 0L
|
||||||
|
focusAnswer?.countDown()
|
||||||
|
return
|
||||||
|
}
|
||||||
val had = focusGranted
|
val had = focusGranted
|
||||||
focusGranted = state == FOCUS_STATE_GAIN || state == FOCUS_STATE_GAIN_TRANSIENT ||
|
focusGranted = state == FOCUS_STATE_GAIN || state == FOCUS_STATE_GAIN_TRANSIENT ||
|
||||||
state == FOCUS_STATE_GAIN_MEDIA_ONLY
|
state == FOCUS_STATE_GAIN_MEDIA_ONLY
|
||||||
@@ -181,7 +233,19 @@ class AudioLink(
|
|||||||
startStream()
|
startStream()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guards starting and stopping. [start] starts the stream at once for head units that never
|
||||||
|
* answer the focus request, and the car's answer arrives on another thread and starts it
|
||||||
|
* too: without this both got through, so the car got Start twice and two AAC encoders, and a
|
||||||
|
* 2014 head unit dropped the connection on the first audio.
|
||||||
|
*/
|
||||||
|
private val startLock = Any()
|
||||||
|
|
||||||
private fun startStream() {
|
private fun startStream() {
|
||||||
|
synchronized(startLock) { startStreamLocked() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startStreamLocked() {
|
||||||
if (!ready || started) return
|
if (!ready || started) return
|
||||||
sendOnChannel(
|
sendOnChannel(
|
||||||
MSG_START,
|
MSG_START,
|
||||||
@@ -200,6 +264,10 @@ class AudioLink(
|
|||||||
}
|
}
|
||||||
|
|
||||||
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() }
|
||||||
@@ -286,6 +354,7 @@ class AudioLink(
|
|||||||
configTimer?.interrupt()
|
configTimer?.interrupt()
|
||||||
configTimer = null
|
configTimer = null
|
||||||
stop()
|
stop()
|
||||||
|
handBackSpeakers()
|
||||||
ready = false
|
ready = false
|
||||||
onClose(this)
|
onClose(this)
|
||||||
}
|
}
|
||||||
@@ -303,7 +372,9 @@ class AudioLink(
|
|||||||
private const val MSG_SETUP_ANSWER = 255
|
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_GAIN_MEDIA_ONLY = 6
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.Row
|
|||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -34,8 +35,11 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.layout.boundsInWindow
|
||||||
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import app.seb3thehacker.gearslip.GearslipLog
|
import app.seb3thehacker.gearslip.GearslipLog
|
||||||
import app.seb3thehacker.gearslip.host.CarAppConnection
|
import app.seb3thehacker.gearslip.host.CarAppConnection
|
||||||
@@ -59,13 +63,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.
|
||||||
|
androidx.compose.runtime.CompositionLocalProvider(LocalChromeCover provides cover, LocalCarAppConnection provides connection) {
|
||||||
if (status.surfaceRequested) {
|
if (status.surfaceRequested) {
|
||||||
AppSurface(connection, frame, template, Modifier.fillMaxSize())
|
AppSurface(connection, frame, template, Modifier.fillMaxSize())
|
||||||
}
|
}
|
||||||
TemplateChrome(template, Modifier.fillMaxSize().padding(top = if (embedded) 0.dp else CHROME_BAR))
|
TemplateChrome(
|
||||||
|
template,
|
||||||
|
Modifier.fillMaxSize().padding(top = if (embedded) 0.dp else CHROME_BAR, bottom = navBar),
|
||||||
|
)
|
||||||
|
BottomCover(navBar, Modifier.align(Alignment.BottomCenter))
|
||||||
|
}
|
||||||
if (!embedded) ChromeBar(status, onDisconnect, Modifier.align(Alignment.TopCenter))
|
if (!embedded) ChromeBar(status, onDisconnect, Modifier.align(Alignment.TopCenter))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -84,14 +97,25 @@ 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) }
|
||||||
|
// The card column moves the map's centre but isn't taken out of the stable area: apps size
|
||||||
|
// their own overlays from it, and Vela's speedometer shrank to fit the strip right of the cards.
|
||||||
|
val measuredStable = bounds?.takeIf { width > 0 && height > 0 }?.let { cover?.visibleWithin(it, width, height, skip = CoverEdge.START) }
|
||||||
|
LaunchedEffect(template, size, measured, measuredStable) {
|
||||||
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)
|
val stable = if (!isContentTemplate(template)) {
|
||||||
|
visibleAreaFor(template, width, height, measuredStable)
|
||||||
|
} else {
|
||||||
|
visible
|
||||||
|
}
|
||||||
|
connection.reportAreas(visible = visible, stable = stable)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,7 +125,9 @@ private fun AppSurface(
|
|||||||
// new one on it before releasing the old: the old display's disconnect then takes the new one's
|
// new one on it before releasing the old: the old display's disconnect then takes the new one's
|
||||||
// connection with it and the map freezes for good. The nav bar hiding for the search keyboard
|
// connection with it and the map freezes for good. The nav bar hiding for the search keyboard
|
||||||
// and the lyrics pane opening both resize this area.
|
// and the lyrics pane opening both resize this area.
|
||||||
androidx.compose.foundation.layout.BoxWithConstraints(modifier) {
|
androidx.compose.foundation.layout.BoxWithConstraints(
|
||||||
|
modifier.onGloballyPositioned { bounds = it.boundsInWindow() },
|
||||||
|
) {
|
||||||
val epoch by connection.surfaceEpoch.collectAsState()
|
val epoch by connection.surfaceEpoch.collectAsState()
|
||||||
androidx.compose.runtime.key(constraints.maxWidth, constraints.maxHeight, epoch) {
|
androidx.compose.runtime.key(constraints.maxWidth, constraints.maxHeight, epoch) {
|
||||||
SurfaceHost(connection, frame, onSize = { size = it }, Modifier.fillMaxSize())
|
SurfaceHost(connection, frame, onSize = { size = it }, Modifier.fillMaxSize())
|
||||||
@@ -189,28 +215,31 @@ private fun ChromeBar(
|
|||||||
onDisconnect: () -> Unit,
|
onDisconnect: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
// The app's name and a way out, nothing else: which template is showing, or whether the
|
||||||
|
// app has drawn yet, is for the log, not the driver.
|
||||||
Surface(
|
Surface(
|
||||||
color = Color.Black.copy(alpha = 0.55f),
|
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
contentColor = Color.White,
|
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
modifier = modifier.fillMaxWidth(),
|
modifier = modifier.fillMaxWidth().height(CHROME_BAR),
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
Modifier.padding(horizontal = 16.dp, vertical = 6.dp),
|
Modifier.padding(horizontal = 16.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Column(Modifier.weight(1f)) {
|
|
||||||
Text(
|
Text(
|
||||||
status.app ?: "Car app",
|
status.app ?: "Car app",
|
||||||
style = MaterialTheme.typography.titleSmall,
|
style = MaterialTheme.typography.titleSmall,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
GsIconButton(
|
||||||
|
androidx.compose.material.icons.Icons.Filled.Close,
|
||||||
|
"Close ${status.app ?: "the app"}",
|
||||||
|
onDisconnect,
|
||||||
|
size = 36.dp,
|
||||||
|
iconSize = 20.dp,
|
||||||
|
shape = androidx.compose.foundation.shape.CircleShape,
|
||||||
)
|
)
|
||||||
val detail = buildList {
|
|
||||||
status.template?.let { add(it) }
|
|
||||||
add(if (status.surfaceAttached) "drawing" else "waiting for the app")
|
|
||||||
}.joinToString(" - ")
|
|
||||||
Text(detail, style = MaterialTheme.typography.bodySmall)
|
|
||||||
}
|
|
||||||
GsButton(onClick = onDisconnect, tone = GsTone.Tonal) { Text("Stop") }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -220,3 +249,6 @@ private val CHROME_BAR = 44.dp
|
|||||||
|
|
||||||
/** Slower than this when the finger lifts and it was a drag that stopped, not a fling. */
|
/** Slower than this when the finger lifts and it was a drag that stopped, not a fling. */
|
||||||
private const val FLING_MIN_PX_S = 400f
|
private const val FLING_MIN_PX_S = 400f
|
||||||
|
|
||||||
|
/** The app connection a template on screen belongs to: the map app's, or the one opened from the launcher. */
|
||||||
|
internal val LocalCarAppConnection = androidx.compose.runtime.staticCompositionLocalOf { CarServices.nav }
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -93,13 +93,14 @@ fun CarHome() {
|
|||||||
if (onHome) seenHome[0] = true
|
if (onHome) seenHome[0] = true
|
||||||
}
|
}
|
||||||
val splitScreen = phase == CarMedia.Phase.READY && now.isActive && panel != SidePanel.NONE
|
val splitScreen = phase == CarMedia.Phase.READY && now.isActive && panel != SidePanel.NONE
|
||||||
|
val playerLeft by CarSettings.playerLeft.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
// 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(
|
val side = Modifier.weight(1f).fillMaxHeight().padding(bottom = LocalNavBarInset.current)
|
||||||
Modifier.fillMaxSize(),
|
if (splitScreen && playerLeft) {
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
if (panel == SidePanel.LYRICS) SideLyrics(now, side) else DockedPlayer(now, side)
|
||||||
) {
|
}
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.weight(if (splitScreen) 2.1f else 1f)
|
.weight(if (splitScreen) 2.1f else 1f)
|
||||||
@@ -107,8 +108,7 @@ fun CarHome() {
|
|||||||
) {
|
) {
|
||||||
MapPane(navStatus, frame)
|
MapPane(navStatus, frame)
|
||||||
}
|
}
|
||||||
if (splitScreen) {
|
if (splitScreen && !playerLeft) {
|
||||||
val side = Modifier.weight(1f).fillMaxHeight()
|
|
||||||
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)
|
||||||
|
|||||||
@@ -119,35 +119,61 @@ internal object LauncherCache {
|
|||||||
context.packageManager.getApplicationIcon(pkg).toBitmap(128, 128).asImageBitmap()
|
context.packageManager.getApplicationIcon(pkg).toBitmap(128, 128).asImageBitmap()
|
||||||
}.getOrNull()
|
}.getOrNull()
|
||||||
|
|
||||||
val navApps = CarAppCatalog.installed(context)
|
val carApps = CarAppCatalog.installed(context).associateBy { it.component.packageName }
|
||||||
val mediaApps = MediaCatalog.installed(context)
|
val mediaApps = MediaCatalog.installed(context).associateBy { it.component.packageName }
|
||||||
// Some apps (Spotify among them) ship both a Car App Library template and a legacy
|
val messagingApps = MessagingCatalog.installed(context).associateBy { it.packageName }
|
||||||
// MediaBrowserService, which otherwise land as two identically-labeled tiles with no
|
|
||||||
// way to tell them apart. They're genuinely different screens - browsing versus Now
|
|
||||||
// Playing - so only the label needs disambiguating, not the tile itself.
|
|
||||||
val mediaPackages = mediaApps.map { it.component.packageName }.toSet()
|
|
||||||
|
|
||||||
val messagingApps = MessagingCatalog.installed(context)
|
// One decision per app over everything it offers a car - a car screen, a player, car
|
||||||
// Google Messages ships its own car app, which won't start under Gearslip. Gearslip's
|
// notifications - rather than a filter per kind that has to know about the others.
|
||||||
// own Messages screen does the same job, so a messenger gets only that tile.
|
val tiles = (carApps.keys + mediaApps.keys + messagingApps.keys).flatMap { pkg ->
|
||||||
val messagingPackages = messagingApps.map { it.packageName }.toSet()
|
val icon by lazy { iconOf(pkg) }
|
||||||
|
tilesFor(carApps[pkg], mediaApps[pkg], messagingApps[pkg]).map { tile ->
|
||||||
val nav = navApps
|
when (tile) {
|
||||||
.filter { it.component.packageName !in messagingPackages }
|
is AppTile.Screen -> Entry(tile.label, icon, template = tile.app)
|
||||||
// An app whose template screen is rejected but whose player works through Gearslip
|
is AppTile.Player -> Entry(tile.app.label, icon, media = tile.app)
|
||||||
// (Spotify) shows only its player tile - a "· Browse" tile beside it would just report
|
is AppTile.Messages -> Entry(tile.app.label, icon, messaging = tile.app)
|
||||||
// that the car screen isn't supported.
|
|
||||||
.filter { !(KnownApps.screenBroken(it.component.packageName) && KnownApps.playerWorks(it.component.packageName)) }
|
|
||||||
.map {
|
|
||||||
val label = if (it.component.packageName in mediaPackages) "${it.label} · Browse" else it.label
|
|
||||||
Entry(label, iconOf(it.component.packageName), template = it)
|
|
||||||
}
|
}
|
||||||
val media = mediaApps.map { Entry(it.label, iconOf(it.component.packageName), media = it) }
|
}
|
||||||
// A messenger that is also a car media app keeps only its player tile.
|
}
|
||||||
val messaging = messagingApps
|
return tiles.sortedBy { it.label.lowercase() }.also { flow.value = it }
|
||||||
.filter { it.packageName !in mediaPackages }
|
}
|
||||||
.map { Entry(it.label, iconOf(it.packageName), messaging = it) }
|
}
|
||||||
return (nav + media + messaging).sortedBy { it.label.lowercase() }.also { flow.value = it }
|
|
||||||
|
/** What one app gets on the launcher. */
|
||||||
|
private sealed interface AppTile {
|
||||||
|
class Screen(val app: TemplateApp, val label: String) : AppTile
|
||||||
|
class Player(val app: app.seb3thehacker.gearslip.media.MediaApp) : AppTile
|
||||||
|
class Messages(val app: app.seb3thehacker.gearslip.notify.MessagingApp) : AppTile
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tiles for one app, from what it offers.
|
||||||
|
*
|
||||||
|
* - A car screen that is something other than media or messaging (RF Sentinel's POI screen) is
|
||||||
|
* the app: its media service only makes sound, and its notifications are alerts, so it gets the
|
||||||
|
* one tile under its own name.
|
||||||
|
* - A media app with both a car screen and a player (Spotify) gets both, the screen labelled
|
||||||
|
* "· Browse", since they really are different screens - unless the screen is known to refuse
|
||||||
|
* Gearslip while the player works, when the Browse tile would only report that.
|
||||||
|
* - A messenger's own car screen (Google Messages) won't start under Gearslip, and Gearslip's
|
||||||
|
* Messages screen does the job, so it gets only that - and a messenger that is also a media
|
||||||
|
* app keeps only its player.
|
||||||
|
*/
|
||||||
|
private fun tilesFor(
|
||||||
|
car: TemplateApp?,
|
||||||
|
media: app.seb3thehacker.gearslip.media.MediaApp?,
|
||||||
|
messaging: app.seb3thehacker.gearslip.notify.MessagingApp?,
|
||||||
|
): List<AppTile> {
|
||||||
|
if (car != null && !car.isMediaScreen && car.kind != "messaging") return listOf(AppTile.Screen(car, car.label))
|
||||||
|
val pkg = car?.component?.packageName ?: media?.component?.packageName ?: messaging?.packageName ?: return emptyList()
|
||||||
|
return buildList {
|
||||||
|
val screenRefused = KnownApps.screenBroken(pkg) && KnownApps.playerWorks(pkg)
|
||||||
|
val messengerScreen = messaging != null && (car?.kind == "messaging" || car?.kind == "app")
|
||||||
|
if (car != null && !messengerScreen && !(media != null && screenRefused)) {
|
||||||
|
add(AppTile.Screen(car, if (media != null) "${car.label} · Browse" else car.label))
|
||||||
|
}
|
||||||
|
if (media != null) add(AppTile.Player(media))
|
||||||
|
else if (messaging != null) add(AppTile.Messages(messaging))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,6 +265,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()
|
||||||
|
|
||||||
@@ -254,7 +281,7 @@ fun CarLauncher() {
|
|||||||
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,
|
||||||
broken = pkg != null && (KnownApps.isBroken(pkg) || (entry.template != null && KnownApps.screenBroken(pkg))),
|
broken = pkg != null && (KnownApps.isBroken(context, pkg) || (entry.template != null && KnownApps.screenBroken(pkg))),
|
||||||
onLongClick = id?.let { { navigator.showAppMenu(it) } },
|
onLongClick = id?.let { { navigator.showAppMenu(it) } },
|
||||||
) { openApp(entry, id?.let { running[it] }, navigator, frame) }
|
) { openApp(entry, id?.let { running[it] }, navigator, frame) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,664 @@
|
|||||||
|
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.clickable
|
||||||
|
import androidx.compose.foundation.LocalIndication
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
|
import androidx.compose.ui.graphics.lerp
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.foundation.layout.BoxScope
|
||||||
|
import androidx.car.app.model.CarIcon
|
||||||
|
import androidx.car.app.navigation.model.NavigationTemplate
|
||||||
|
import androidx.car.app.navigation.model.RoutingInfo
|
||||||
|
import app.seb3thehacker.gearslip.host.CarAppConnection
|
||||||
|
import app.seb3thehacker.gearslip.host.display
|
||||||
|
import app.seb3thehacker.gearslip.host.text
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.dynamicDarkColorScheme
|
||||||
|
import androidx.compose.material3.dynamicLightColorScheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.ui.hapticfeedback.HapticFeedback
|
||||||
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
|
import androidx.compose.ui.platform.LocalView
|
||||||
|
import androidx.compose.runtime.State
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.animation.animateContentSize
|
||||||
|
import android.media.session.PlaybackState
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.res.vectorResource
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import app.seb3thehacker.gearslip.R
|
||||||
|
import app.seb3thehacker.gearslip.car.theme.GsColors
|
||||||
|
import app.seb3thehacker.gearslip.car.theme.withTintedSurfaces
|
||||||
|
import app.seb3thehacker.gearslip.car.theme.GsIconBox
|
||||||
|
import app.seb3thehacker.gearslip.car.theme.GsIconButton
|
||||||
|
import app.seb3thehacker.gearslip.car.theme.GsButton
|
||||||
|
import app.seb3thehacker.gearslip.car.theme.GsTone
|
||||||
|
import app.seb3thehacker.gearslip.car.theme.gsColors
|
||||||
|
import app.seb3thehacker.gearslip.car.theme.GsThemes
|
||||||
|
import app.seb3thehacker.gearslip.car.theme.LocalGsTheme
|
||||||
|
import app.seb3thehacker.gearslip.media.CarMedia
|
||||||
|
import app.seb3thehacker.gearslip.media.MediaArt
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.Density
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import app.seb3thehacker.gearslip.notify.CarNotifications
|
||||||
|
import androidx.core.graphics.drawable.toBitmap
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.text.DateFormat
|
||||||
|
import java.util.Date
|
||||||
|
|
||||||
|
// The nav bar along the bottom of the car screen: home, apps, the assistant, running apps,
|
||||||
|
// the next turn or a call, the player, and the clock. Split out of CarUi.kt.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Home > Apps > Settings", like a file manager's address bar: hidden at Home itself (nothing to
|
||||||
|
* show yet), and grows by one crumb for every screen pushed from there. The back arrow undoes one
|
||||||
|
* step; a crumb jumps straight to it, dropping everything after - this is the only on-screen way
|
||||||
|
* back at all, since a car touchscreen has no hardware button for it.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun BreadcrumbBar(navigator: CarNavigator) {
|
||||||
|
val trail = navigator.trail
|
||||||
|
if (trail.size <= 1) return
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth().height(40.dp).padding(start = 2.dp, end = 10.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
GsIconButton(Icons.AutoMirrored.Filled.ArrowBack, "Back", navigator::back, size = 32.dp, iconSize = 20.dp)
|
||||||
|
Spacer(Modifier.width(2.dp))
|
||||||
|
trail.forEachIndexed { index, crumb ->
|
||||||
|
val isLast = index == trail.lastIndex
|
||||||
|
Text(
|
||||||
|
crumb.label,
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
fontWeight = if (isLast) FontWeight.SemiBold else FontWeight.Normal,
|
||||||
|
color = if (isLast) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = if (isLast) Modifier else Modifier
|
||||||
|
.clip(RoundedCornerShape(6.dp))
|
||||||
|
.clickable { navigator.jumpTo(index) }
|
||||||
|
.padding(horizontal = 4.dp, vertical = 2.dp),
|
||||||
|
)
|
||||||
|
if (!isLast) {
|
||||||
|
Text(
|
||||||
|
"›",
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(horizontal = 2.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apps and open/pinned shortcuts on the left; now-playing pill against them; clock (tap for
|
||||||
|
* calendar and weather), phone battery and the darkness signal on the right. Icon-only: a driver
|
||||||
|
* reads a glyph faster than a label, and it keeps the bar short enough to leave the app underneath
|
||||||
|
* more room.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun CarNavBar(navigator: CarNavigator) {
|
||||||
|
val battery by CarEnvironment.battery.collectAsState()
|
||||||
|
val now by rememberNow()
|
||||||
|
val weather by Weather.state.collectAsState()
|
||||||
|
val weatherData = (weather as? WeatherState.Ready)?.data
|
||||||
|
|
||||||
|
val pinnedIds by CarSettings.pinnedApps.collectAsState()
|
||||||
|
val entries = rememberAllEntries()
|
||||||
|
val pinnedEntries = remember(entries, pinnedIds) {
|
||||||
|
pinnedIds.mapNotNull { id -> entries.find { it.componentId == id } }
|
||||||
|
}
|
||||||
|
val frame by CarEnvironment.frame.collectAsState()
|
||||||
|
val running = rememberRunningApps()
|
||||||
|
|
||||||
|
Surface(color = MaterialTheme.colorScheme.surfaceContainer) {
|
||||||
|
BoxWithConstraints(Modifier.fillMaxWidth().height(NAV_BAR_HEIGHT).padding(horizontal = 10.dp)) {
|
||||||
|
Row(Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
// Home and Apps are two different places now, not a toggle on one button - the
|
||||||
|
// breadcrumb trail is how you retrace your steps, but reaching for either of
|
||||||
|
// these two from three screens deep in a media app shouldn't mean reading it
|
||||||
|
// first.
|
||||||
|
NavHomeButton(navigator, entries, running)
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
NavItem(MediaIcons.Apps, navigator.current == CarScreen.Apps) { navigator.apps() }
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
NavAssistantIcon()
|
||||||
|
|
||||||
|
// Pinned shortcuts, then whatever else is open right now. A pinned app that's also
|
||||||
|
// open stays in its pinned spot, marked with a dot, instead of showing up twice.
|
||||||
|
// The map app lives on the Home button, so it never gets a second icon here.
|
||||||
|
val runningEntries = running.keys
|
||||||
|
.filter { it !in pinnedIds && running[it] != RunningSlot.NAV }
|
||||||
|
.mapNotNull { id -> entries.find { it.componentId == id } }
|
||||||
|
val shownPins = pinnedEntries.filter { running[it.componentId] != RunningSlot.NAV }
|
||||||
|
if (shownPins.isNotEmpty() || runningEntries.isNotEmpty()) NavDivider()
|
||||||
|
shownPins.forEach { entry -> NavEntryIcon(entry, running, navigator, frame) }
|
||||||
|
if (shownPins.isNotEmpty() && runningEntries.isNotEmpty()) NavDivider()
|
||||||
|
runningEntries.forEach { entry -> NavEntryIcon(entry, running, navigator, frame) }
|
||||||
|
|
||||||
|
// Next turn first, off the map only; the player shares the space when it fits,
|
||||||
|
// and gives way to the turn when it doesn't.
|
||||||
|
// A call takes the player's place until it ends.
|
||||||
|
val turn = rememberNextTurn().takeIf { navigator.current != CarScreen.Home }
|
||||||
|
val call = CarCalls.state.collectAsState().value as? CallUiState.Active
|
||||||
|
BoxWithConstraints(Modifier.weight(1f).padding(horizontal = 16.dp), contentAlignment = Alignment.Center) {
|
||||||
|
val roomForBoth = maxWidth >= TURN_AND_PLAYER_MIN_WIDTH
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
|
if (call == null || roomForBoth) turn?.let { NavTurnPill(it) { navigator.home() } }
|
||||||
|
if (call != null) {
|
||||||
|
NavCallPill(call, Modifier.weight(1f, fill = false))
|
||||||
|
} else if (turn == null || roomForBoth) {
|
||||||
|
NavNowPlaying(navigator, selected = navigator.current == CarScreen.Media, modifier = Modifier.weight(1f, fill = false))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Weather no longer has its own chip - the temperature alone (no icon, no room
|
||||||
|
// for one) sits where "Night"/"Day" used to, freeing a whole chip's width for the
|
||||||
|
// Home button above.
|
||||||
|
// The clock is also the notifications button: the dashboard lists them under the
|
||||||
|
// calendar and weather, and a dot here marks anything unread.
|
||||||
|
val unread by CarNotifications.unread.collectAsState()
|
||||||
|
NavChip(
|
||||||
|
navigator.current == CarScreen.Dashboard || navigator.current is CarScreen.Notifications,
|
||||||
|
{ navigator.dashboard() },
|
||||||
|
) {
|
||||||
|
if (unread > 0) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.TopEnd)
|
||||||
|
.offset(x = 8.dp, y = (-2).dp)
|
||||||
|
.size(8.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(MaterialTheme.colorScheme.primary),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Column(horizontalAlignment = Alignment.End) {
|
||||||
|
Text(
|
||||||
|
DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(now)),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||||
|
if (battery.percent >= 0) {
|
||||||
|
Text(
|
||||||
|
"${battery.percent}%",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
if (battery.charging) {
|
||||||
|
ChargingGlyph(Modifier.size(13.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
weatherData?.let { w ->
|
||||||
|
Text(
|
||||||
|
" · ${w.temp}°",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Home: the map, with the player or lyrics beside it. While a map app runs, the button wears that
|
||||||
|
* app's icon and a long press offers to close it, so the app needs no icon of its own on the bar.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun NavHomeButton(navigator: CarNavigator, entries: List<Entry>, running: Map<String, RunningSlot>) {
|
||||||
|
val selected = navigator.current == CarScreen.Home
|
||||||
|
val navId = running.entries.firstOrNull { it.value == RunningSlot.NAV }?.key
|
||||||
|
val navEntry = navId?.let { id -> entries.find { it.componentId == id } }
|
||||||
|
GsIconBox(
|
||||||
|
{ navigator.home() },
|
||||||
|
colors = navColors(selected),
|
||||||
|
latched = selected,
|
||||||
|
onLongClick = navId?.let { id -> { navigator.showAppMenu(id) } },
|
||||||
|
) {
|
||||||
|
val icon = navEntry?.icon
|
||||||
|
if (icon != null) {
|
||||||
|
Box(Modifier.padding(NAV_ITEM_PADDING).size(28.dp), contentAlignment = Alignment.Center) {
|
||||||
|
Image(icon, navEntry.label, Modifier.size(28.dp).clip(RoundedCornerShape(7.dp)))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Icon(
|
||||||
|
ImageVector.vectorResource(R.drawable.navigation_24), contentDescription = "Home",
|
||||||
|
modifier = Modifier.padding(NAV_ITEM_PADDING).size(28.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Splits the nav bar's sections: Home and Apps, pinned apps, open apps. */
|
||||||
|
@Composable
|
||||||
|
private fun NavDivider() {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.padding(horizontal = 10.dp)
|
||||||
|
.width(1.dp)
|
||||||
|
.height(28.dp)
|
||||||
|
.background(MaterialTheme.colorScheme.outlineVariant),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A pinned or open app on the nav bar. Tapping brings it back without reconnecting if it's
|
||||||
|
* already open; a long press opens its menu (pin, unpin, close).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun NavEntryIcon(
|
||||||
|
entry: Entry,
|
||||||
|
running: Map<String, RunningSlot>,
|
||||||
|
navigator: CarNavigator,
|
||||||
|
frame: CarEnvironment.Frame,
|
||||||
|
) {
|
||||||
|
val id = entry.componentId ?: return
|
||||||
|
val slot = running[id]
|
||||||
|
Box(Modifier.padding(horizontal = 3.dp)) {
|
||||||
|
NavAppIcon(
|
||||||
|
entry,
|
||||||
|
open = slot != null,
|
||||||
|
showing = slot != null && slot.isShowing(navigator.current),
|
||||||
|
onLongClick = { navigator.showAppMenu(id) },
|
||||||
|
) { openApp(entry, slot, navigator, frame) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One open or pinned app's icon on the nav bar - the same fixed size and shape either way. */
|
||||||
|
@Composable
|
||||||
|
private fun NavAppIcon(
|
||||||
|
entry: Entry,
|
||||||
|
open: Boolean,
|
||||||
|
showing: Boolean,
|
||||||
|
onLongClick: () -> Unit,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
val scheme = MaterialTheme.colorScheme
|
||||||
|
GsIconBox(
|
||||||
|
onClick,
|
||||||
|
Modifier.size(44.dp),
|
||||||
|
colors = if (showing) GsColors(scheme.secondaryContainer, scheme.onSecondaryContainer)
|
||||||
|
else GsColors(scheme.surfaceVariant, scheme.onSurfaceVariant),
|
||||||
|
latched = showing,
|
||||||
|
onLongClick = onLongClick,
|
||||||
|
) {
|
||||||
|
entry.icon?.let { Image(it, entry.label, Modifier.size(30.dp).clip(RoundedCornerShape(8.dp))) }
|
||||||
|
entry.builtIn?.let { Icon(it.glyph, entry.label, Modifier.size(26.dp)) }
|
||||||
|
// Only running apps get the line along the bottom edge, the usual taskbar mark for it.
|
||||||
|
if (open) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
|
.padding(bottom = 2.dp)
|
||||||
|
.size(width = 12.dp, height = 3.dp)
|
||||||
|
.clip(RoundedCornerShape(50))
|
||||||
|
.background(scheme.primary),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A tappable group in the bar that stays highlighted while its screen is open, like [NavItem]. */
|
||||||
|
@Composable
|
||||||
|
private fun NavChip(selected: Boolean, onClick: () -> Unit, content: @Composable BoxScope.() -> Unit) {
|
||||||
|
GsIconBox(onClick, colors = navColors(selected), latched = selected) {
|
||||||
|
Box(Modifier.padding(horizontal = 10.dp, vertical = 2.dp)) { content() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every nav bar item is raised, so it reads as pressable; the current screen's takes the accent colour. */
|
||||||
|
@Composable
|
||||||
|
private fun navColors(selected: Boolean): GsColors {
|
||||||
|
val s = MaterialTheme.colorScheme
|
||||||
|
return if (selected) GsColors(s.secondaryContainer, s.onSecondaryContainer)
|
||||||
|
else GsColors(s.surfaceContainerHighest, s.onSurface)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Between Apps and whatever's actually open, not with the clock/battery group on the right - a
|
||||||
|
* driver reaches for it right after deciding "not a tap for this", same gesture family as the
|
||||||
|
* two buttons beside it.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun NavAssistantIcon() {
|
||||||
|
val state by CarAssistant.state.collectAsState()
|
||||||
|
val active = state.phase != CarAssistant.Phase.IDLE
|
||||||
|
GsIconBox({ CarAssistant.start() }, colors = navColors(active), latched = active) {
|
||||||
|
MicGlyph(Modifier.padding(NAV_ITEM_PADDING).size(28.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inner padding of every icon on the nav bar, leaving each button room inside the 56dp bar. */
|
||||||
|
private val NAV_ITEM_PADDING = 8.dp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun NavItem(icon: ImageVector, selected: Boolean, onClick: () -> Unit) {
|
||||||
|
GsIconBox(onClick, colors = navColors(selected), latched = selected) {
|
||||||
|
Icon(icon, contentDescription = null, modifier = Modifier.padding(NAV_ITEM_PADDING).size(28.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wall-clock time, ticking on the minute: no reason to redraw (and re-encode) more often. */
|
||||||
|
@Composable
|
||||||
|
internal fun rememberNow(): State<Long> = produceState(System.currentTimeMillis()) {
|
||||||
|
while (true) {
|
||||||
|
value = System.currentTimeMillis()
|
||||||
|
delay(60_000 - value % 60_000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How long the pill shows a new song's title before settling to just the controls. */
|
||||||
|
private const val TITLE_SHOWN_MS = 3_000L
|
||||||
|
|
||||||
|
/** Below this, the nav bar's middle only has room for the turn, not the player beside it. */
|
||||||
|
private val TURN_AND_PLAYER_MIN_WIDTH = 520.dp
|
||||||
|
|
||||||
|
/** The next manoeuvre, as the running nav app last described it. */
|
||||||
|
private class NextTurn(val icon: CarIcon?, val distance: String, val road: String)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read straight off the nav app's own template: a navigation app keeps sending its routing card
|
||||||
|
* while it's running, whether or not the map is the screen being shown.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun rememberNextTurn(): NextTurn? {
|
||||||
|
val status by CarServices.nav.status.collectAsState()
|
||||||
|
val template by CarServices.nav.template.collectAsState()
|
||||||
|
// Read so a change of units redraws the distance straight away, not at the next update.
|
||||||
|
CarSettings.units.collectAsState().value
|
||||||
|
if (status.phase != CarAppConnection.Phase.RUNNING) return null
|
||||||
|
val routing = (template as? NavigationTemplate)?.navigationInfo as? RoutingInfo ?: return null
|
||||||
|
if (routing.isLoading) return null
|
||||||
|
val step = routing.currentStep ?: return null
|
||||||
|
return NextTurn(
|
||||||
|
icon = step.maneuver?.icon,
|
||||||
|
distance = routing.currentDistance.display(),
|
||||||
|
road = step.road.text().ifEmpty { step.cue.text() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The next turn in the nav bar: arrow, how far, onto what. Tapping goes back to the map. */
|
||||||
|
@Composable
|
||||||
|
private fun NavTurnPill(turn: NextTurn, onClick: () -> Unit) {
|
||||||
|
// Same finish as every other button on the bar, in the accent colour; the text runs a step
|
||||||
|
// bigger than anything else there, since it's the one thing worth a glance mid-drive.
|
||||||
|
GsIconBox(
|
||||||
|
onClick,
|
||||||
|
Modifier.widthIn(max = 340.dp).height(44.dp),
|
||||||
|
colors = gsColors(GsTone.Primary),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
Modifier.padding(NAV_ITEM_PADDING),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
CarGlyph(turn.icon, Modifier.size(28.dp))
|
||||||
|
if (turn.distance.isNotEmpty()) {
|
||||||
|
Text(turn.distance, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, maxLines = 1)
|
||||||
|
}
|
||||||
|
if (turn.road.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
turn.road,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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. */
|
||||||
|
private fun nowPlayingLabel(title: String, artist: String): String =
|
||||||
|
listOf(title.trim(), artist.trim()).filter { it.isNotEmpty() }.joinToString(" - ")
|
||||||
|
.ifEmpty { "Nothing playing" }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The player in the nav bar, on every screen. Art and title open the full media screen;
|
||||||
|
* play/pause and skip work right here. The dock button moves the player into the column beside
|
||||||
|
* the map on Home (see [CarHome]), and the pill steps aside there while it's docked - unless the
|
||||||
|
* column is showing lyrics instead, when the pill stays and its dock button swaps them back.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun NavNowPlaying(navigator: CarNavigator, selected: Boolean, modifier: Modifier = Modifier) {
|
||||||
|
val media = CarServices.media
|
||||||
|
val phase by media.phase.collectAsState()
|
||||||
|
val now by media.now.collectAsState()
|
||||||
|
if (phase != CarMedia.Phase.READY || !now.isActive) return
|
||||||
|
// Docked beside the map, the player is already on screen - the pill steps aside for it.
|
||||||
|
if (navigator.sidePanel == SidePanel.CONTROLS && navigator.current == CarScreen.Home) return
|
||||||
|
// The media screen is the full player already; the pill would only repeat its controls.
|
||||||
|
if (navigator.current == CarScreen.Media) return
|
||||||
|
val context = LocalContext.current
|
||||||
|
val art by produceState(now.art, now.art, now.artUri) { value = now.art ?: MediaArt.load(context, now.artUri) }
|
||||||
|
val position by produceState(now.currentPosition(), now) {
|
||||||
|
while (true) {
|
||||||
|
value = now.currentPosition()
|
||||||
|
if (!now.playing) break
|
||||||
|
delay(1000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A new song announces itself: title, play/pause and dock for a few seconds, then the title
|
||||||
|
// goes and the pill settles to just the controls - unless the driver keeps the title up.
|
||||||
|
val keepTitle by CarSettings.playerTitle.collectAsState()
|
||||||
|
var announcing by remember { mutableStateOf(true) }
|
||||||
|
LaunchedEffect(now.title, now.artist) {
|
||||||
|
announcing = true
|
||||||
|
delay(TITLE_SHOWN_MS)
|
||||||
|
announcing = false
|
||||||
|
}
|
||||||
|
val showTitle = keepTitle || announcing
|
||||||
|
|
||||||
|
val scheme = MaterialTheme.colorScheme
|
||||||
|
val onPill = if (selected) scheme.onPrimaryContainer else scheme.onSecondaryContainer
|
||||||
|
Surface(
|
||||||
|
modifier = modifier
|
||||||
|
.animateContentSize()
|
||||||
|
// With the title up, all the room the bar has, so as much of it shows as can.
|
||||||
|
.then(if (showTitle) Modifier.fillMaxWidth() else Modifier)
|
||||||
|
.height(44.dp),
|
||||||
|
shape = RoundedCornerShape(14.dp),
|
||||||
|
color = if (selected) scheme.primaryContainer else scheme.secondaryContainer,
|
||||||
|
contentColor = onPill,
|
||||||
|
) {
|
||||||
|
Box {
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxHeight().padding(horizontal = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
) {
|
||||||
|
// Art (and the title while it shows) opens the full media screen.
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.then(if (showTitle) Modifier.weight(1f) else Modifier)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.clip(RoundedCornerShape(10.dp))
|
||||||
|
.clickable { navigator.media() },
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Box(Modifier.size(34.dp).clip(RoundedCornerShape(8.dp)).background(scheme.surfaceVariant)) {
|
||||||
|
art?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) }
|
||||||
|
}
|
||||||
|
if (showTitle) {
|
||||||
|
Text(
|
||||||
|
nowPlayingLabel(now.title, now.artist),
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!showTitle) {
|
||||||
|
GsIconButton(
|
||||||
|
MediaIcons.Previous, "Previous", media::previous,
|
||||||
|
enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_PREVIOUS), size = 36.dp, iconSize = 22.dp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
GsIconButton(
|
||||||
|
if (now.playing) MediaIcons.Pause else Icons.Filled.PlayArrow,
|
||||||
|
if (now.playing) "Pause" else "Play",
|
||||||
|
media::togglePlay,
|
||||||
|
tone = GsTone.Primary, size = 36.dp, iconSize = 22.dp,
|
||||||
|
)
|
||||||
|
if (!showTitle) {
|
||||||
|
GsIconButton(
|
||||||
|
MediaIcons.Next, "Next", media::next,
|
||||||
|
enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_NEXT), size = 36.dp, iconSize = 22.dp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
GsIconButton(
|
||||||
|
MediaIcons.DockRight, "Move the player beside the map", navigator::togglePlayerDock,
|
||||||
|
size = 36.dp, iconSize = 22.dp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (now.durationMs > 0) {
|
||||||
|
// matchParentSize, so the line follows the pill's width rather than widening it.
|
||||||
|
Box(Modifier.matchParentSize(), contentAlignment = Alignment.BottomStart) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth((position.toFloat() / now.durationMs).coerceIn(0f, 1f))
|
||||||
|
.height(2.dp)
|
||||||
|
.background(onPill.copy(alpha = 0.7f)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A filled lightning bolt, next to the battery percentage instead of the word "charging" - the
|
||||||
|
* core Material icon set this project ships (no extended pack, to keep the app small) has no
|
||||||
|
* bolt, so this is drawn rather than a vector asset.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun ChargingGlyph(modifier: Modifier = Modifier, tint: Color = MaterialTheme.colorScheme.onSurfaceVariant) {
|
||||||
|
androidx.compose.foundation.Canvas(modifier) {
|
||||||
|
val w = size.width
|
||||||
|
val h = size.height
|
||||||
|
val bolt = androidx.compose.ui.graphics.Path().apply {
|
||||||
|
moveTo(w * 0.58f, 0f)
|
||||||
|
lineTo(w * 0.12f, h * 0.58f)
|
||||||
|
lineTo(w * 0.46f, h * 0.58f)
|
||||||
|
lineTo(w * 0.42f, h)
|
||||||
|
lineTo(w * 0.88f, h * 0.40f)
|
||||||
|
lineTo(w * 0.54f, h * 0.40f)
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
drawPath(bolt, tint)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -60,6 +60,13 @@ object CarServices {
|
|||||||
private var navStopped = false
|
private var navStopped = false
|
||||||
private var autoplayPending = false
|
private var autoplayPending = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The app that was playing when the driver opened another one. Opening an app is only a look
|
||||||
|
* at its library: until something plays there, the music carries on in this one, so leaving
|
||||||
|
* the new app's screen goes back to it, as Android Auto does.
|
||||||
|
*/
|
||||||
|
private var stillPlaying: MediaApp? = null
|
||||||
|
|
||||||
fun init(appContext: Context) {
|
fun init(appContext: Context) {
|
||||||
context = appContext.applicationContext
|
context = appContext.applicationContext
|
||||||
app.seb3thehacker.gearslip.notify.CarMotion.start(appContext)
|
app.seb3thehacker.gearslip.notify.CarMotion.start(appContext)
|
||||||
@@ -130,6 +137,8 @@ object CarServices {
|
|||||||
val live = phase == CarMedia.Phase.READY || phase == CarMedia.Phase.CONNECTING
|
val live = phase == CarMedia.Phase.READY || phase == CarMedia.Phase.CONNECTING
|
||||||
if (_mediaApp.value?.component == app.component && live) return
|
if (_mediaApp.value?.component == app.component && live) return
|
||||||
|
|
||||||
|
val previous = _mediaApp.value
|
||||||
|
stillPlaying = previous?.takeIf { it.component != app.component && media.now.value.playing && !autoplay }
|
||||||
_mediaApp.value = app
|
_mediaApp.value = app
|
||||||
autoplayPending = autoplay
|
autoplayPending = autoplay
|
||||||
media.connect(app)
|
media.connect(app)
|
||||||
@@ -151,12 +160,26 @@ object CarServices {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The media screen closed. If nothing started in the app it showed, go back to the one that
|
||||||
|
* kept playing meanwhile, so the player and its buttons follow the music you can hear.
|
||||||
|
*/
|
||||||
|
fun mediaScreenClosed() {
|
||||||
|
val back = stillPlaying ?: return
|
||||||
|
stillPlaying = null
|
||||||
|
// A track loaded here, even one paused since, means the driver used this app.
|
||||||
|
if (media.now.value.playing || media.now.value.title.isNotEmpty()) return
|
||||||
|
app.seb3thehacker.gearslip.GearslipLog.i("media: nothing played in ${_mediaApp.value?.label}, back to ${back.label}")
|
||||||
|
openMedia(back)
|
||||||
|
}
|
||||||
|
|
||||||
/** Closes the media app from the car: stops what it's playing and drops the connection. */
|
/** Closes the media app from the car: stops what it's playing and drops the connection. */
|
||||||
fun closeMedia() {
|
fun closeMedia() {
|
||||||
if (media.now.value.playing) media.togglePlay()
|
if (media.now.value.playing) media.togglePlay()
|
||||||
media.disconnect()
|
media.disconnect()
|
||||||
_mediaApp.value = null
|
_mediaApp.value = null
|
||||||
autoplayPending = false
|
autoplayPending = false
|
||||||
|
stillPlaying = null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Called once the media app is connected: starts playback if that was asked for. */
|
/** Called once the media app is connected: starts playback if that was asked for. */
|
||||||
@@ -178,7 +201,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
|
||||||
|
|||||||
@@ -18,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.
|
||||||
@@ -36,6 +63,7 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
|
|||||||
private const val KEY_LAST_MEDIA = "car_last_media"
|
private const val KEY_LAST_MEDIA = "car_last_media"
|
||||||
private const val KEY_AUTOPLAY = "car_autoplay"
|
private const val KEY_AUTOPLAY = "car_autoplay"
|
||||||
private const val KEY_PLAYER_TITLE = "car_player_title"
|
private const val KEY_PLAYER_TITLE = "car_player_title"
|
||||||
|
private const val KEY_PLAYER_LEFT = "car_player_left"
|
||||||
private const val KEY_THEME = "car_app_theme"
|
private const val KEY_THEME = "car_app_theme"
|
||||||
private const val KEY_UI_THEME = "car_ui_theme"
|
private const val KEY_UI_THEME = "car_ui_theme"
|
||||||
private const val KEY_PINNED = "car_pinned_apps"
|
private const val KEY_PINNED = "car_pinned_apps"
|
||||||
@@ -43,8 +71,15 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
|
|||||||
private const val KEY_EXPERIMENTAL_FEATURES = "car_experimental_features"
|
private const val KEY_EXPERIMENTAL_FEATURES = "car_experimental_features"
|
||||||
private const val KEY_SYSTEM_SPEECH = "car_system_speech"
|
private const val KEY_SYSTEM_SPEECH = "car_system_speech"
|
||||||
private const val KEY_CAR_SENSORS = "car_sensors_enabled"
|
private const val KEY_CAR_SENSORS = "car_sensors_enabled"
|
||||||
|
private const val KEY_ADAPTIVE_VIDEO = "adaptive_video_enabled"
|
||||||
|
private const val KEY_DEBUG_BADGE = "debug_badge"
|
||||||
private const val KEY_SEEN_SAFETY_WARNING = "car_seen_safety_warning"
|
private const val KEY_SEEN_SAFETY_WARNING = "car_seen_safety_warning"
|
||||||
private const val KEY_UNITS = "car_units"
|
private const val KEY_UNITS = "car_units"
|
||||||
|
private const val KEY_NAV_BAR = "car_nav_bar"
|
||||||
|
private const val KEY_SCREEN_SIZE = "car_screen_size"
|
||||||
|
private const val KEY_SCREEN_FIT = "car_screen_fit"
|
||||||
|
private const val KEY_PREVIEW_SIZE = "preview_size"
|
||||||
|
private const val KEY_PREVIEW_CUSTOM = "preview_custom_size"
|
||||||
private const val KEY_WHATS_NEW = "car_whats_new_after_updates"
|
private const val KEY_WHATS_NEW = "car_whats_new_after_updates"
|
||||||
private const val KEY_WHATS_NEW_SEEN = "car_whats_new_seen_code"
|
private const val KEY_WHATS_NEW_SEEN = "car_whats_new_seen_code"
|
||||||
|
|
||||||
@@ -68,6 +103,7 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
|
|||||||
private val lastMediaFlow = MutableStateFlow<String?>(null)
|
private val lastMediaFlow = MutableStateFlow<String?>(null)
|
||||||
private val autoplayFlow = MutableStateFlow(false)
|
private val autoplayFlow = MutableStateFlow(false)
|
||||||
private val playerTitleFlow = MutableStateFlow(false)
|
private val playerTitleFlow = MutableStateFlow(false)
|
||||||
|
private val playerLeftFlow = MutableStateFlow(false)
|
||||||
private val themeFlow = MutableStateFlow(AppTheme.PHONE)
|
private val themeFlow = MutableStateFlow(AppTheme.PHONE)
|
||||||
val appTheme: StateFlow<AppTheme> = themeFlow
|
val appTheme: StateFlow<AppTheme> = themeFlow
|
||||||
private val uiThemeFlow = MutableStateFlow(GsThemes.default.id)
|
private val uiThemeFlow = MutableStateFlow(GsThemes.default.id)
|
||||||
@@ -79,11 +115,50 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
|
|||||||
private val experimentalFeaturesFlow = MutableStateFlow(false)
|
private val experimentalFeaturesFlow = MutableStateFlow(false)
|
||||||
private val systemSpeechFlow = MutableStateFlow(false)
|
private val systemSpeechFlow = MutableStateFlow(false)
|
||||||
private val carSensorsFlow = MutableStateFlow(false)
|
private val carSensorsFlow = MutableStateFlow(false)
|
||||||
|
private val adaptiveVideoFlow = MutableStateFlow(false)
|
||||||
|
private val debugBadgeFlow = MutableStateFlow(true)
|
||||||
private val seenSafetyWarningFlow = MutableStateFlow(false)
|
private val seenSafetyWarningFlow = MutableStateFlow(false)
|
||||||
private val unitsFlow = MutableStateFlow(Units.AUTO)
|
private val unitsFlow = MutableStateFlow(Units.AUTO)
|
||||||
|
|
||||||
val units: StateFlow<Units> = unitsFlow
|
val units: StateFlow<Units> = unitsFlow
|
||||||
|
|
||||||
|
private val screenSizeFlow = MutableStateFlow<ScreenSize?>(null)
|
||||||
|
private val screenFitFlow = MutableStateFlow(ScreenFit.CROP)
|
||||||
|
private val previewSizeFlow = MutableStateFlow(PreviewSize.LAST_CAR)
|
||||||
|
private val previewCustomFlow = MutableStateFlow<ScreenSize?>(null)
|
||||||
|
|
||||||
|
/** The car screen's real size, when the head unit gets it wrong; null trusts the head unit. */
|
||||||
|
val screenSize: StateFlow<ScreenSize?> = screenSizeFlow
|
||||||
|
val screenFit: StateFlow<ScreenFit> = screenFitFlow
|
||||||
|
val previewSize: StateFlow<PreviewSize> = previewSizeFlow
|
||||||
|
val previewCustom: StateFlow<ScreenSize?> = previewCustomFlow
|
||||||
|
|
||||||
|
/** Takes effect the next time the car connects: the video size is agreed at the start. */
|
||||||
|
fun setScreenSize(size: ScreenSize?) {
|
||||||
|
screenSizeFlow.value = size
|
||||||
|
AppSettings.putString(app, KEY_SCREEN_SIZE, size?.toString().orEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setScreenFit(fit: ScreenFit) {
|
||||||
|
screenFitFlow.value = fit
|
||||||
|
AppSettings.putString(app, KEY_SCREEN_FIT, fit.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setPreviewSize(size: PreviewSize, custom: ScreenSize? = previewCustomFlow.value) {
|
||||||
|
previewSizeFlow.value = size
|
||||||
|
previewCustomFlow.value = custom
|
||||||
|
AppSettings.putString(app, KEY_PREVIEW_SIZE, size.name)
|
||||||
|
AppSettings.putString(app, KEY_PREVIEW_CUSTOM, custom?.toString().orEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
private val navBarFlow = MutableStateFlow(NavBarMode.SHOW)
|
||||||
|
val navBarMode: StateFlow<NavBarMode> = navBarFlow
|
||||||
|
|
||||||
|
fun setNavBarMode(mode: NavBarMode) {
|
||||||
|
navBarFlow.value = mode
|
||||||
|
AppSettings.putString(app, KEY_NAV_BAR, mode.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
private val whatsNewFlow = MutableStateFlow(true)
|
private val whatsNewFlow = MutableStateFlow(true)
|
||||||
private val whatsNewSeenFlow = MutableStateFlow(0)
|
private val whatsNewSeenFlow = MutableStateFlow(0)
|
||||||
@@ -146,6 +221,9 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
|
|||||||
*/
|
*/
|
||||||
val playerTitle: StateFlow<Boolean> = playerTitleFlow
|
val playerTitle: StateFlow<Boolean> = playerTitleFlow
|
||||||
|
|
||||||
|
/** The home screen's player sits left of the map instead of right of it. */
|
||||||
|
val playerLeft: StateFlow<Boolean> = playerLeftFlow
|
||||||
|
|
||||||
val scale: StateFlow<Float> = scaleFlow
|
val scale: StateFlow<Float> = scaleFlow
|
||||||
val openOnConnect: StateFlow<String> = openFlow
|
val openOnConnect: StateFlow<String> = openFlow
|
||||||
val nightMode: StateFlow<NightMode> = nightFlow
|
val nightMode: StateFlow<NightMode> = nightFlow
|
||||||
@@ -198,6 +276,20 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
|
|||||||
*/
|
*/
|
||||||
val carSensorsEnabled: StateFlow<Boolean> = carSensorsFlow
|
val carSensorsEnabled: StateFlow<Boolean> = carSensorsFlow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Video bitrate that backs off when the car, or the Wi-Fi to it, falls behind. Takes effect
|
||||||
|
* only while [experimentalFeaturesEnabled] is on too; untested in a car so far.
|
||||||
|
*/
|
||||||
|
val adaptiveVideoEnabled: StateFlow<Boolean> = adaptiveVideoFlow
|
||||||
|
|
||||||
|
/** Debug builds only: the car screen's DEBUG BUILD badge, which screenshots for the README leave out. */
|
||||||
|
val debugBadge: StateFlow<Boolean> = debugBadgeFlow
|
||||||
|
|
||||||
|
fun setDebugBadge(on: Boolean) {
|
||||||
|
debugBadgeFlow.value = on
|
||||||
|
AppSettings.putString(app, KEY_DEBUG_BADGE, on.toString())
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the car screen's own "drive safely" warning has already been shown and dismissed
|
* Whether the car screen's own "drive safely" warning has already been shown and dismissed
|
||||||
* once, from this same install. Separate from the phone side's own compat/safety dialogs -
|
* once, from this same install. Separate from the phone side's own compat/safety dialogs -
|
||||||
@@ -223,6 +315,7 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
|
|||||||
uiThemeFlow.value = AppSettings.getString(app, KEY_UI_THEME, GsThemes.default.id)
|
uiThemeFlow.value = AppSettings.getString(app, KEY_UI_THEME, GsThemes.default.id)
|
||||||
autoplayFlow.value = AppSettings.getString(app, KEY_AUTOPLAY, "false") == "true"
|
autoplayFlow.value = AppSettings.getString(app, KEY_AUTOPLAY, "false") == "true"
|
||||||
playerTitleFlow.value = AppSettings.getString(app, KEY_PLAYER_TITLE, "false") == "true"
|
playerTitleFlow.value = AppSettings.getString(app, KEY_PLAYER_TITLE, "false") == "true"
|
||||||
|
playerLeftFlow.value = AppSettings.getString(app, KEY_PLAYER_LEFT, "false") == "true"
|
||||||
pipeAudioFlow.value = AppSettings.getString(app, KEY_PIPE_AUDIO, "true") == "true"
|
pipeAudioFlow.value = AppSettings.getString(app, KEY_PIPE_AUDIO, "true") == "true"
|
||||||
// Until the driver sets it, on for USB audio and off for Bluetooth, where the map app's
|
// Until the driver sets it, on for USB audio and off for Bluetooth, where the map app's
|
||||||
// own voice already reaches the car.
|
// own voice already reaches the car.
|
||||||
@@ -239,10 +332,23 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
|
|||||||
experimentalFeaturesFlow.value = AppSettings.getString(app, KEY_EXPERIMENTAL_FEATURES, "false") == "true"
|
experimentalFeaturesFlow.value = AppSettings.getString(app, KEY_EXPERIMENTAL_FEATURES, "false") == "true"
|
||||||
systemSpeechFlow.value = AppSettings.getString(app, KEY_SYSTEM_SPEECH, "false") == "true"
|
systemSpeechFlow.value = AppSettings.getString(app, KEY_SYSTEM_SPEECH, "false") == "true"
|
||||||
carSensorsFlow.value = AppSettings.getString(app, KEY_CAR_SENSORS, "false") == "true"
|
carSensorsFlow.value = AppSettings.getString(app, KEY_CAR_SENSORS, "false") == "true"
|
||||||
|
adaptiveVideoFlow.value = AppSettings.getString(app, KEY_ADAPTIVE_VIDEO, "false") == "true"
|
||||||
|
debugBadgeFlow.value = AppSettings.getString(app, KEY_DEBUG_BADGE, "true") == "true"
|
||||||
seenSafetyWarningFlow.value = AppSettings.getString(app, KEY_SEEN_SAFETY_WARNING, "false") == "true"
|
seenSafetyWarningFlow.value = AppSettings.getString(app, KEY_SEEN_SAFETY_WARNING, "false") == "true"
|
||||||
unitsFlow.value = runCatching {
|
unitsFlow.value = runCatching {
|
||||||
Units.valueOf(AppSettings.getString(app, KEY_UNITS, Units.AUTO.name))
|
Units.valueOf(AppSettings.getString(app, KEY_UNITS, Units.AUTO.name))
|
||||||
}.getOrDefault(Units.AUTO)
|
}.getOrDefault(Units.AUTO)
|
||||||
|
screenSizeFlow.value = ScreenSize.parse(AppSettings.getString(app, KEY_SCREEN_SIZE, ""))
|
||||||
|
screenFitFlow.value = runCatching {
|
||||||
|
ScreenFit.valueOf(AppSettings.getString(app, KEY_SCREEN_FIT, ScreenFit.CROP.name))
|
||||||
|
}.getOrDefault(ScreenFit.CROP)
|
||||||
|
previewSizeFlow.value = runCatching {
|
||||||
|
PreviewSize.valueOf(AppSettings.getString(app, KEY_PREVIEW_SIZE, PreviewSize.LAST_CAR.name))
|
||||||
|
}.getOrDefault(PreviewSize.LAST_CAR)
|
||||||
|
previewCustomFlow.value = ScreenSize.parse(AppSettings.getString(app, KEY_PREVIEW_CUSTOM, ""))
|
||||||
|
navBarFlow.value = runCatching {
|
||||||
|
NavBarMode.valueOf(AppSettings.getString(app, KEY_NAV_BAR, NavBarMode.SHOW.name))
|
||||||
|
}.getOrDefault(NavBarMode.SHOW)
|
||||||
whatsNewFlow.value = AppSettings.getString(app, KEY_WHATS_NEW, "true") == "true"
|
whatsNewFlow.value = AppSettings.getString(app, KEY_WHATS_NEW, "true") == "true"
|
||||||
whatsNewSeenFlow.value = AppSettings.getString(app, KEY_WHATS_NEW_SEEN, "0").toIntOrNull() ?: 0
|
whatsNewSeenFlow.value = AppSettings.getString(app, KEY_WHATS_NEW_SEEN, "0").toIntOrNull() ?: 0
|
||||||
}
|
}
|
||||||
@@ -282,6 +388,11 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
|
|||||||
AppSettings.putString(app, KEY_PLAYER_TITLE, on.toString())
|
AppSettings.putString(app, KEY_PLAYER_TITLE, on.toString())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setPlayerLeft(on: Boolean) {
|
||||||
|
playerLeftFlow.value = on
|
||||||
|
AppSettings.putString(app, KEY_PLAYER_LEFT, on.toString())
|
||||||
|
}
|
||||||
|
|
||||||
fun setAutoplay(on: Boolean) {
|
fun setAutoplay(on: Boolean) {
|
||||||
autoplayFlow.value = on
|
autoplayFlow.value = on
|
||||||
AppSettings.putString(app, KEY_AUTOPLAY, on.toString())
|
AppSettings.putString(app, KEY_AUTOPLAY, on.toString())
|
||||||
@@ -325,6 +436,11 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
|
|||||||
AppSettings.putString(app, KEY_SYSTEM_SPEECH, on.toString())
|
AppSettings.putString(app, KEY_SYSTEM_SPEECH, on.toString())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setAdaptiveVideoEnabled(on: Boolean) {
|
||||||
|
adaptiveVideoFlow.value = on
|
||||||
|
AppSettings.putString(app, KEY_ADAPTIVE_VIDEO, on.toString())
|
||||||
|
}
|
||||||
|
|
||||||
fun setCarSensorsEnabled(on: Boolean) {
|
fun setCarSensorsEnabled(on: Boolean) {
|
||||||
carSensorsFlow.value = on
|
carSensorsFlow.value = on
|
||||||
AppSettings.putString(app, KEY_CAR_SENSORS, on.toString())
|
AppSettings.putString(app, KEY_CAR_SENSORS, on.toString())
|
||||||
|
|||||||
@@ -5,12 +5,15 @@ 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
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
@@ -51,6 +54,9 @@ 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 speakDirections by CarSettings.speakDirections.collectAsState()
|
||||||
@@ -58,10 +64,12 @@ fun CarSettingsScreen() {
|
|||||||
val locationWarning by CarSettings.locationWarning.collectAsState()
|
val locationWarning by CarSettings.locationWarning.collectAsState()
|
||||||
val autoplay by CarSettings.autoplay.collectAsState()
|
val autoplay by CarSettings.autoplay.collectAsState()
|
||||||
val playerTitle by CarSettings.playerTitle.collectAsState()
|
val playerTitle by CarSettings.playerTitle.collectAsState()
|
||||||
|
val playerLeft by CarSettings.playerLeft.collectAsState()
|
||||||
val 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 carSensors by CarSettings.carSensorsEnabled.collectAsState()
|
val carSensors by CarSettings.carSensorsEnabled.collectAsState()
|
||||||
|
val adaptiveVideo by CarSettings.adaptiveVideoEnabled.collectAsState()
|
||||||
val systemSpeech by CarSettings.systemSpeech.collectAsState()
|
val systemSpeech by CarSettings.systemSpeech.collectAsState()
|
||||||
val display by CarEnvironment.display.collectAsState()
|
val display by CarEnvironment.display.collectAsState()
|
||||||
val vehicle by CarEnvironment.vehicle.collectAsState()
|
val vehicle by CarEnvironment.vehicle.collectAsState()
|
||||||
@@ -70,68 +78,134 @@ 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),
|
||||||
) {
|
) {
|
||||||
|
item {
|
||||||
ScreenHeader("Settings")
|
ScreenHeader("Settings")
|
||||||
|
}
|
||||||
|
|
||||||
|
item { Group("Screen") }
|
||||||
|
|
||||||
|
item {
|
||||||
ChoiceRow(
|
ChoiceRow(
|
||||||
"Open on connect",
|
"Open on connect",
|
||||||
listOf(CarSettings.HOME to "Home") + CarApps.all.filter { it.id != "settings" }.map { it.id to it.label },
|
listOf(CarSettings.HOME to "Home") + CarApps.all.filter { it.id != "settings" }.map { it.id to it.label },
|
||||||
open,
|
open,
|
||||||
) { CarSettings.setOpenOnConnect(it) }
|
) { CarSettings.setOpenOnConnect(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
ChoiceRow(
|
ChoiceRow(
|
||||||
"Size",
|
"Size",
|
||||||
listOf(1f to "Normal", 1.25f to "Large", 1.5f to "Extra large"),
|
listOf(1f to "Normal", 1.25f to "Large", 1.5f to "Extra large"),
|
||||||
scale,
|
scale,
|
||||||
) { CarSettings.setScale(it) }
|
) { CarSettings.setScale(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
ChoiceRow(
|
ChoiceRow(
|
||||||
"App theme",
|
"App theme",
|
||||||
listOf(AppTheme.PHONE to "Follow phone", AppTheme.LIGHT to "Light", AppTheme.DARK to "Dark"),
|
listOf(AppTheme.PHONE to "Follow phone", AppTheme.LIGHT to "Light", AppTheme.DARK to "Dark"),
|
||||||
theme,
|
theme,
|
||||||
) { CarSettings.setAppTheme(it) }
|
) { CarSettings.setAppTheme(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
ChoiceRow(
|
ChoiceRow(
|
||||||
"Theme",
|
"Theme",
|
||||||
GsThemes.builtIn.map { it.id to it.name },
|
GsThemes.builtIn.map { it.id to it.name },
|
||||||
uiTheme,
|
uiTheme,
|
||||||
) { CarSettings.setUiTheme(it) }
|
) { CarSettings.setUiTheme(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
ChoiceRow(
|
ChoiceRow(
|
||||||
"Map day/night",
|
"Map day/night",
|
||||||
listOf(NightMode.AUTO to "Auto", NightMode.DAY to "Day", NightMode.NIGHT to "Night"),
|
listOf(NightMode.AUTO to "Auto", NightMode.DAY to "Day", NightMode.NIGHT to "Night"),
|
||||||
night,
|
night,
|
||||||
) { CarSettings.setNightMode(it) }
|
) { CarSettings.setNightMode(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
ChoiceRow(
|
ChoiceRow(
|
||||||
"Units",
|
"Units",
|
||||||
listOf(Units.AUTO to "Auto", Units.IMPERIAL to "Imperial (mi, ft, °F)", Units.METRIC to "Metric (km, m, °C)"),
|
listOf(Units.AUTO to "Auto", Units.IMPERIAL to "Imperial (mi, ft, °F)", Units.METRIC to "Metric (km, m, °C)"),
|
||||||
units,
|
units,
|
||||||
) { CarSettings.setUnits(it) }
|
) { CarSettings.setUnits(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// For a head unit that reports the wrong size and stretches the picture (issue #12).
|
||||||
|
item {
|
||||||
|
ChoiceRow(
|
||||||
|
"Screen size (next connection)",
|
||||||
|
listOf<ScreenSize?>(null).plus(SCREEN_PRESETS).plus(screenSize).distinct()
|
||||||
|
.map { it to (it?.toString() ?: "From the car") },
|
||||||
|
screenSize,
|
||||||
|
) { CarSettings.setScreenSize(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (screenSize != null) item {
|
||||||
|
ChoiceRow(
|
||||||
|
"Fit to the screen",
|
||||||
|
listOf(ScreenFit.CROP to "Crop the edges", ScreenFit.SQUASH to "Squash to fit"),
|
||||||
|
screenFit,
|
||||||
|
) { CarSettings.setScreenFit(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
item { Group("Nav bar and player") }
|
||||||
|
|
||||||
|
item {
|
||||||
|
ChoiceRow(
|
||||||
|
"Nav bar",
|
||||||
|
listOf(
|
||||||
|
NavBarMode.SHOW to "Always show",
|
||||||
|
NavBarMode.HIDE_NAVIGATING to "Hide while navigating",
|
||||||
|
NavBarMode.HIDE to "Hide until tapped",
|
||||||
|
),
|
||||||
|
navBar,
|
||||||
|
) { CarSettings.setNavBarMode(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
ChoiceRow(
|
ChoiceRow(
|
||||||
"Player in the nav bar",
|
"Player in the nav bar",
|
||||||
listOf(false to "Skip buttons", true to "Song title"),
|
listOf(false to "Skip buttons", true to "Song title"),
|
||||||
playerTitle,
|
playerTitle,
|
||||||
) { CarSettings.setPlayerTitle(it) }
|
) { CarSettings.setPlayerTitle(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
ChoiceRow(
|
||||||
|
"Player beside the map",
|
||||||
|
listOf(false to "Right", true to "Left"),
|
||||||
|
playerLeft,
|
||||||
|
) { CarSettings.setPlayerLeft(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
ChoiceRow(
|
ChoiceRow(
|
||||||
"Autoplay on connect",
|
"Autoplay on connect",
|
||||||
listOf(false to "Off", true to "On"),
|
listOf(false to "Off", true to "On"),
|
||||||
autoplay,
|
autoplay,
|
||||||
) { CarSettings.setAutoplay(it) }
|
) { CarSettings.setAutoplay(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
item { Group("Sound and voice") }
|
||||||
|
|
||||||
|
item {
|
||||||
ChoiceRow(
|
ChoiceRow(
|
||||||
"Car audio",
|
"Car audio",
|
||||||
listOf(false to "Bluetooth", true to "USB"),
|
listOf(false to "Bluetooth", true to "USB"),
|
||||||
pipeAudio,
|
pipeAudio,
|
||||||
) { CarSettings.setPipeAudio(it) }
|
) { CarSettings.setPipeAudio(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
Text(
|
Text(
|
||||||
"USB sends the phone's sound through Gearslip. Android asks to share your screen each " +
|
"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 " +
|
"drive, and a few apps block it. Bluetooth plays as the phone normally would, and " +
|
||||||
@@ -139,31 +213,44 @@ fun CarSettingsScreen() {
|
|||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
if (!pipeAudio) {
|
}
|
||||||
|
|
||||||
|
item { Group("Warnings") }
|
||||||
|
|
||||||
|
if (!pipeAudio) item {
|
||||||
ChoiceRow(
|
ChoiceRow(
|
||||||
"Warn when Bluetooth isn't connected",
|
"Warn when Bluetooth isn't connected",
|
||||||
listOf(false to "Off", true to "On"),
|
listOf(false to "Off", true to "On"),
|
||||||
bluetoothWarning,
|
bluetoothWarning,
|
||||||
) { CarSettings.setBluetoothWarning(it) }
|
) { CarSettings.setBluetoothWarning(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
ChoiceRow(
|
ChoiceRow(
|
||||||
"Warn when location is off",
|
"Warn when location is off",
|
||||||
listOf(false to "Off", true to "On"),
|
listOf(false to "Off", true to "On"),
|
||||||
locationWarning,
|
locationWarning,
|
||||||
) { CarSettings.setLocationWarning(it) }
|
) { CarSettings.setLocationWarning(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
ChoiceRow(
|
ChoiceRow(
|
||||||
"Spoken directions",
|
"Spoken directions",
|
||||||
listOf(false to "Off", true to "On"),
|
listOf(false to "Off", true to "On"),
|
||||||
speakDirections,
|
speakDirections,
|
||||||
) { CarSettings.setSpeakDirections(it) }
|
) { CarSettings.setSpeakDirections(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
Text(
|
Text(
|
||||||
"Gearslip reads each turn aloud through the car's speakers, and the music dips while it " +
|
"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.",
|
"speaks. Turn it off if your map app's own voice already plays in the car.",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
ExperimentalFeatureRow(
|
ExperimentalFeatureRow(
|
||||||
title = "Use the phone's speech recognition",
|
title = "Use the phone's speech recognition",
|
||||||
description = "Listens through the phone's own speech service instead of Gearslip's. On " +
|
description = "Listens through the phone's own speech service instead of Gearslip's. On " +
|
||||||
@@ -171,20 +258,31 @@ fun CarSettingsScreen() {
|
|||||||
"none, so Gearslip keeps using its own.",
|
"none, so Gearslip keeps using its own.",
|
||||||
checked = systemSpeech,
|
checked = systemSpeech,
|
||||||
) { CarSettings.setSystemSpeech(it) }
|
) { CarSettings.setSystemSpeech(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
item { Group("More") }
|
||||||
|
|
||||||
|
item {
|
||||||
WebPageSetting()
|
WebPageSetting()
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
ScreenFit(vehicle)
|
ScreenFit(vehicle)
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
ChoiceRow(
|
ChoiceRow(
|
||||||
"Experimental features",
|
"Experimental features",
|
||||||
listOf(false to "Hide", true to "Show"),
|
listOf(false to "Hide", true to "Show"),
|
||||||
experimentalFeatures,
|
experimentalFeatures,
|
||||||
) { CarSettings.setExperimentalFeaturesEnabled(it) }
|
) { CarSettings.setExperimentalFeaturesEnabled(it) }
|
||||||
if (experimentalFeatures) {
|
|
||||||
ExperimentalFeaturesList(carSensors)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (experimentalFeatures) item {
|
||||||
|
ExperimentalFeaturesList(carSensors, adaptiveVideo)
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
Text("Connection", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
|
Text("Connection", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
|
||||||
Info("Phone", "${Build.MANUFACTURER} ${Build.MODEL}")
|
Info("Phone", "${Build.MANUFACTURER} ${Build.MODEL}")
|
||||||
@@ -192,7 +290,9 @@ fun CarSettingsScreen() {
|
|||||||
Info("Video", display.ifEmpty { "-" })
|
Info("Video", display.ifEmpty { "-" })
|
||||||
Info("Certificate", cert?.headline ?: "Checking…")
|
Info("Certificate", cert?.headline ?: "Checking…")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
Text("About", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
|
Text("About", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
|
||||||
Info("Version", app.seb3thehacker.gearslip.BuildConfig.VERSION_NAME)
|
Info("Version", app.seb3thehacker.gearslip.BuildConfig.VERSION_NAME)
|
||||||
@@ -208,11 +308,41 @@ fun CarSettingsScreen() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun <T> ChoiceRow(title: String, options: List<Pair<T, String>>, selected: T, onSelect: (T) -> Unit) {
|
private fun <T> ChoiceRow(title: String, options: List<Pair<T, String>>, selected: T, onSelect: (T) -> Unit) {
|
||||||
|
// On a wide screen the name sits beside its choices, which halves how far there is to scroll.
|
||||||
|
androidx.compose.foundation.layout.BoxWithConstraints(Modifier.fillMaxWidth()) {
|
||||||
|
if (maxWidth >= 720.dp && options.size <= 4) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||||
|
Text(title, style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(0.3f))
|
||||||
|
Box(Modifier.weight(0.7f)) { ChoiceButtons(options, selected, onSelect) }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
Text(title, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
|
Text(title, style = MaterialTheme.typography.titleMedium)
|
||||||
|
ChoiceButtons(options, selected, onSelect)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A group of settings: a heading, so a long list reads as a few short ones. */
|
||||||
|
@Composable
|
||||||
|
private fun Group(title: String) {
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(top = 12.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun <T> ChoiceButtons(options: List<Pair<T, String>>, selected: T, onSelect: (T) -> Unit) {
|
||||||
|
run {
|
||||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
options.forEach { (value, label) ->
|
options.forEach { (value, label) ->
|
||||||
val on = value == selected
|
val on = value == selected
|
||||||
@@ -239,7 +369,7 @@ private fun <T> ChoiceRow(title: String, options: List<Pair<T, String>>, selecte
|
|||||||
* so the list reads fine whether there's one entry or several.
|
* so the list reads fine whether there's one entry or several.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun ExperimentalFeaturesList(carSensors: Boolean) {
|
private fun ExperimentalFeaturesList(carSensors: Boolean, adaptiveVideo: Boolean) {
|
||||||
Column(
|
Column(
|
||||||
Modifier.fillMaxWidth(),
|
Modifier.fillMaxWidth(),
|
||||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
@@ -252,6 +382,12 @@ private fun ExperimentalFeaturesList(carSensors: Boolean) {
|
|||||||
"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 = "Adaptive video quality",
|
||||||
|
description = "Lowers the picture's quality when the car, or its Wi-Fi, can't keep " +
|
||||||
|
"up, and raises it again once it can. Takes effect on the next connection.",
|
||||||
|
checked = adaptiveVideo,
|
||||||
|
) { CarSettings.setAdaptiveVideoEnabled(it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -406,3 +542,8 @@ private fun StepButton(text: String, modifier: Modifier, onClick: () -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Common car screen sizes; Settings on the phone takes any other. */
|
||||||
|
private val SCREEN_PRESETS = listOf(
|
||||||
|
ScreenSize(800, 480), ScreenSize(1024, 600), ScreenSize(1280, 480), ScreenSize(1280, 720),
|
||||||
|
ScreenSize(1440, 540), ScreenSize(1920, 720), ScreenSize(1920, 1080),
|
||||||
|
)
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
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.LocalIndication
|
||||||
@@ -72,6 +75,7 @@ import androidx.compose.ui.res.vectorResource
|
|||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import app.seb3thehacker.gearslip.R
|
import app.seb3thehacker.gearslip.R
|
||||||
import app.seb3thehacker.gearslip.car.theme.GsColors
|
import app.seb3thehacker.gearslip.car.theme.GsColors
|
||||||
|
import app.seb3thehacker.gearslip.car.theme.withTintedSurfaces
|
||||||
import app.seb3thehacker.gearslip.car.theme.GsIconBox
|
import app.seb3thehacker.gearslip.car.theme.GsIconBox
|
||||||
import app.seb3thehacker.gearslip.car.theme.GsIconButton
|
import app.seb3thehacker.gearslip.car.theme.GsIconButton
|
||||||
import app.seb3thehacker.gearslip.car.theme.GsButton
|
import app.seb3thehacker.gearslip.car.theme.GsButton
|
||||||
@@ -114,7 +118,7 @@ fun CarUi() {
|
|||||||
AppTheme.LIGHT -> false
|
AppTheme.LIGHT -> false
|
||||||
AppTheme.DARK -> true
|
AppTheme.DARK -> true
|
||||||
}
|
}
|
||||||
val colors = if (dark) dynamicDarkColorScheme(appContext) else dynamicLightColorScheme(appContext)
|
val colors = (if (dark) dynamicDarkColorScheme(appContext) else dynamicLightColorScheme(appContext)).withTintedSurfaces(dark)
|
||||||
val base = LocalDensity.current
|
val base = LocalDensity.current
|
||||||
val insets by CarEnvironment.insets.collectAsState()
|
val insets by CarEnvironment.insets.collectAsState()
|
||||||
val navigator = remember { CarNavigator(CarSettings.openOnConnect.value) }
|
val navigator = remember { CarNavigator(CarSettings.openOnConnect.value) }
|
||||||
@@ -123,6 +127,7 @@ 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.
|
// The knob counts as a touch: the map's buttons can't be found while they're faded out.
|
||||||
@@ -193,10 +198,25 @@ fun CarUi() {
|
|||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
// Watches every touch without taking any: first in line, consumes nothing.
|
// Watches every touch without taking any: first in line, consumes nothing.
|
||||||
.pointerInput(controlsWake) {
|
.pointerInput(controlsWake) {
|
||||||
|
// In full screen, a swipe that starts at the top or bottom edge brings the
|
||||||
|
// nav bar back. Only from the very edge, so scrolling a page still scrolls.
|
||||||
|
val edge = 32.dp.toPx()
|
||||||
|
val slop = 24.dp.toPx()
|
||||||
|
var startY = -1f
|
||||||
awaitPointerEventScope {
|
awaitPointerEventScope {
|
||||||
while (true) {
|
while (true) {
|
||||||
awaitPointerEvent(PointerEventPass.Initial)
|
val event = awaitPointerEvent(PointerEventPass.Initial)
|
||||||
controlsWake.touch()
|
controlsWake.touch()
|
||||||
|
CarFullscreen.touch()
|
||||||
|
val change = event.changes.firstOrNull() ?: continue
|
||||||
|
if (!CarFullscreen.active) continue
|
||||||
|
if (change.pressed && !change.previousPressed) {
|
||||||
|
val y = change.position.y
|
||||||
|
startY = if (y < edge || y > size.height - edge) y else -1f
|
||||||
|
} else if (startY >= 0f && change.pressed && kotlin.math.abs(change.position.y - startY) > slop) {
|
||||||
|
CarFullscreen.reveal()
|
||||||
|
startY = -1f
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -211,6 +231,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) {
|
||||||
@@ -223,12 +263,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 { } },
|
||||||
) {
|
) {
|
||||||
@@ -247,7 +304,7 @@ fun CarUi() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
CarToastOverlay(Modifier.align(Alignment.BottomCenter))
|
CarToastOverlay(Modifier.align(Alignment.BottomCenter).padding(bottom = homeInset))
|
||||||
CallPromptOverlay()
|
CallPromptOverlay()
|
||||||
BluetoothPairOverlay()
|
BluetoothPairOverlay()
|
||||||
PopupTimer()
|
PopupTimer()
|
||||||
@@ -258,21 +315,39 @@ fun CarUi() {
|
|||||||
)
|
)
|
||||||
// Above the popup: a call is more urgent than any notification.
|
// Above the popup: a call is more urgent than any notification.
|
||||||
VoiceHelpOverlay(Modifier.align(Alignment.Center))
|
VoiceHelpOverlay(Modifier.align(Alignment.Center))
|
||||||
CallOverlay(Modifier.align(Alignment.TopCenter))
|
CallOverlay()
|
||||||
AssistantOverlay(Modifier.align(Alignment.BottomCenter))
|
AssistantOverlay(Modifier.align(Alignment.BottomCenter).padding(bottom = homeInset))
|
||||||
if (app.seb3thehacker.gearslip.BuildConfig.DEBUG) {
|
val badge by CarSettings.debugBadge.collectAsState()
|
||||||
|
if (app.seb3thehacker.gearslip.BuildConfig.DEBUG && badge) {
|
||||||
// Bottom corner, not top: a screen's own header almost always puts
|
// Bottom corner, not top: a screen's own header almost always puts
|
||||||
// something in the top-right (settings, search, a Liked Songs
|
// something in the top-right (settings, search, a Liked Songs
|
||||||
// shortcut) and this badge used to sit right on top of it, hiding
|
// shortcut) and this badge used to sit right on top of it, hiding
|
||||||
// whatever the screen drew there.
|
// whatever the screen drew there.
|
||||||
DebugBuildBadge(Modifier.align(Alignment.BottomEnd))
|
DebugBuildBadge(Modifier.align(Alignment.BottomEnd).padding(bottom = homeInset))
|
||||||
|
}
|
||||||
|
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())
|
||||||
@@ -284,6 +359,14 @@ fun CarUi() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal val NAV_BAR_HEIGHT = 56.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How much of the bottom of Home the floating nav bar covers right now: its height while it shows,
|
||||||
|
* zero while it's hidden or when it has a row of its own.
|
||||||
|
*/
|
||||||
|
internal val LocalNavBarInset = androidx.compose.runtime.compositionLocalOf { 0.dp }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A single blocking screen, shown once per install before anything else the car screen can show -
|
* A single blocking screen, shown once per install before anything else the car screen can show -
|
||||||
* Home included. The phone's own setup guide carries the same warning, but a driver who plugs in
|
* Home included. The phone's own setup guide carries the same warning, but a driver who plugs in
|
||||||
@@ -398,512 +481,6 @@ private fun DebugBuildBadge(modifier: Modifier = Modifier) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* "Home > Apps > Settings", like a file manager's address bar: hidden at Home itself (nothing to
|
|
||||||
* show yet), and grows by one crumb for every screen pushed from there. The back arrow undoes one
|
|
||||||
* step; a crumb jumps straight to it, dropping everything after - this is the only on-screen way
|
|
||||||
* back at all, since a car touchscreen has no hardware button for it.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun BreadcrumbBar(navigator: CarNavigator) {
|
|
||||||
val trail = navigator.trail
|
|
||||||
if (trail.size <= 1) return
|
|
||||||
Row(
|
|
||||||
Modifier.fillMaxWidth().height(40.dp).padding(start = 2.dp, end = 10.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
GsIconButton(Icons.AutoMirrored.Filled.ArrowBack, "Back", navigator::back, size = 32.dp, iconSize = 20.dp)
|
|
||||||
Spacer(Modifier.width(2.dp))
|
|
||||||
trail.forEachIndexed { index, crumb ->
|
|
||||||
val isLast = index == trail.lastIndex
|
|
||||||
Text(
|
|
||||||
crumb.label,
|
|
||||||
style = MaterialTheme.typography.labelLarge,
|
|
||||||
fontWeight = if (isLast) FontWeight.SemiBold else FontWeight.Normal,
|
|
||||||
color = if (isLast) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
modifier = if (isLast) Modifier else Modifier
|
|
||||||
.clip(RoundedCornerShape(6.dp))
|
|
||||||
.clickable { navigator.jumpTo(index) }
|
|
||||||
.padding(horizontal = 4.dp, vertical = 2.dp),
|
|
||||||
)
|
|
||||||
if (!isLast) {
|
|
||||||
Text(
|
|
||||||
"›",
|
|
||||||
style = MaterialTheme.typography.labelLarge,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.padding(horizontal = 2.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Apps and open/pinned shortcuts on the left; now-playing pill against them; clock (tap for
|
|
||||||
* calendar and weather), phone battery and the darkness signal on the right. Icon-only: a driver
|
|
||||||
* reads a glyph faster than a label, and it keeps the bar short enough to leave the app underneath
|
|
||||||
* more room.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun CarNavBar(navigator: CarNavigator) {
|
|
||||||
val battery by CarEnvironment.battery.collectAsState()
|
|
||||||
val now by rememberNow()
|
|
||||||
val weather by Weather.state.collectAsState()
|
|
||||||
val weatherData = (weather as? WeatherState.Ready)?.data
|
|
||||||
|
|
||||||
val pinnedIds by CarSettings.pinnedApps.collectAsState()
|
|
||||||
val entries = rememberAllEntries()
|
|
||||||
val pinnedEntries = remember(entries, pinnedIds) {
|
|
||||||
pinnedIds.mapNotNull { id -> entries.find { it.componentId == id } }
|
|
||||||
}
|
|
||||||
val frame by CarEnvironment.frame.collectAsState()
|
|
||||||
val running = rememberRunningApps()
|
|
||||||
|
|
||||||
Surface(color = MaterialTheme.colorScheme.surfaceContainer) {
|
|
||||||
BoxWithConstraints(Modifier.fillMaxWidth().height(56.dp).padding(horizontal = 10.dp)) {
|
|
||||||
Row(Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
// Home and Apps are two different places now, not a toggle on one button - the
|
|
||||||
// breadcrumb trail is how you retrace your steps, but reaching for either of
|
|
||||||
// these two from three screens deep in a media app shouldn't mean reading it
|
|
||||||
// first.
|
|
||||||
NavHomeButton(navigator, entries, running)
|
|
||||||
Spacer(Modifier.width(6.dp))
|
|
||||||
NavItem(MediaIcons.Apps, navigator.current == CarScreen.Apps) { navigator.apps() }
|
|
||||||
Spacer(Modifier.width(6.dp))
|
|
||||||
NavAssistantIcon()
|
|
||||||
|
|
||||||
// Pinned shortcuts, then whatever else is open right now. A pinned app that's also
|
|
||||||
// open stays in its pinned spot, marked with a dot, instead of showing up twice.
|
|
||||||
// The map app lives on the Home button, so it never gets a second icon here.
|
|
||||||
val runningEntries = running.keys
|
|
||||||
.filter { it !in pinnedIds && running[it] != RunningSlot.NAV }
|
|
||||||
.mapNotNull { id -> entries.find { it.componentId == id } }
|
|
||||||
val shownPins = pinnedEntries.filter { running[it.componentId] != RunningSlot.NAV }
|
|
||||||
if (shownPins.isNotEmpty() || runningEntries.isNotEmpty()) NavDivider()
|
|
||||||
shownPins.forEach { entry -> NavEntryIcon(entry, running, navigator, frame) }
|
|
||||||
if (shownPins.isNotEmpty() && runningEntries.isNotEmpty()) NavDivider()
|
|
||||||
runningEntries.forEach { entry -> NavEntryIcon(entry, running, navigator, frame) }
|
|
||||||
|
|
||||||
// Next turn first, off the map only; the player shares the space when it fits,
|
|
||||||
// and gives way to the turn when it doesn't.
|
|
||||||
val turn = rememberNextTurn().takeIf { navigator.current != CarScreen.Home }
|
|
||||||
BoxWithConstraints(Modifier.weight(1f).padding(horizontal = 16.dp), contentAlignment = Alignment.Center) {
|
|
||||||
val roomForBoth = maxWidth >= TURN_AND_PLAYER_MIN_WIDTH
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
|
||||||
turn?.let { NavTurnPill(it) { navigator.home() } }
|
|
||||||
if (turn == null || roomForBoth) {
|
|
||||||
NavNowPlaying(navigator, selected = navigator.current == CarScreen.Media, modifier = Modifier.weight(1f, fill = false))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Weather no longer has its own chip - the temperature alone (no icon, no room
|
|
||||||
// for one) sits where "Night"/"Day" used to, freeing a whole chip's width for the
|
|
||||||
// Home button above.
|
|
||||||
// The clock is also the notifications button: the dashboard lists them under the
|
|
||||||
// calendar and weather, and a dot here marks anything unread.
|
|
||||||
val unread by CarNotifications.unread.collectAsState()
|
|
||||||
NavChip(
|
|
||||||
navigator.current == CarScreen.Dashboard || navigator.current is CarScreen.Notifications,
|
|
||||||
{ navigator.dashboard() },
|
|
||||||
) {
|
|
||||||
if (unread > 0) {
|
|
||||||
Box(
|
|
||||||
Modifier
|
|
||||||
.align(Alignment.TopEnd)
|
|
||||||
.offset(x = 8.dp, y = (-2).dp)
|
|
||||||
.size(8.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(MaterialTheme.colorScheme.primary),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Column(horizontalAlignment = Alignment.End) {
|
|
||||||
Text(
|
|
||||||
DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(now)),
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
)
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(3.dp)) {
|
|
||||||
if (battery.percent >= 0) {
|
|
||||||
Text(
|
|
||||||
"${battery.percent}%",
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
if (battery.charging) {
|
|
||||||
ChargingGlyph(Modifier.size(13.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
weatherData?.let { w ->
|
|
||||||
Text(
|
|
||||||
" · ${w.temp}°",
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Home: the map, with the player or lyrics beside it. While a map app runs, the button wears that
|
|
||||||
* app's icon and a long press offers to close it, so the app needs no icon of its own on the bar.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun NavHomeButton(navigator: CarNavigator, entries: List<Entry>, running: Map<String, RunningSlot>) {
|
|
||||||
val selected = navigator.current == CarScreen.Home
|
|
||||||
val navId = running.entries.firstOrNull { it.value == RunningSlot.NAV }?.key
|
|
||||||
val navEntry = navId?.let { id -> entries.find { it.componentId == id } }
|
|
||||||
GsIconBox(
|
|
||||||
{ navigator.home() },
|
|
||||||
colors = navColors(selected),
|
|
||||||
latched = selected,
|
|
||||||
onLongClick = navId?.let { id -> { navigator.showAppMenu(id) } },
|
|
||||||
) {
|
|
||||||
val icon = navEntry?.icon
|
|
||||||
if (icon != null) {
|
|
||||||
Box(Modifier.padding(NAV_ITEM_PADDING).size(28.dp), contentAlignment = Alignment.Center) {
|
|
||||||
Image(icon, navEntry.label, Modifier.size(28.dp).clip(RoundedCornerShape(7.dp)))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Icon(
|
|
||||||
ImageVector.vectorResource(R.drawable.navigation_24), contentDescription = "Home",
|
|
||||||
modifier = Modifier.padding(NAV_ITEM_PADDING).size(28.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Splits the nav bar's sections: Home and Apps, pinned apps, open apps. */
|
|
||||||
@Composable
|
|
||||||
private fun NavDivider() {
|
|
||||||
Box(
|
|
||||||
Modifier
|
|
||||||
.padding(horizontal = 10.dp)
|
|
||||||
.width(1.dp)
|
|
||||||
.height(28.dp)
|
|
||||||
.background(MaterialTheme.colorScheme.outlineVariant),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A pinned or open app on the nav bar. Tapping brings it back without reconnecting if it's
|
|
||||||
* already open; a long press opens its menu (pin, unpin, close).
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun NavEntryIcon(
|
|
||||||
entry: Entry,
|
|
||||||
running: Map<String, RunningSlot>,
|
|
||||||
navigator: CarNavigator,
|
|
||||||
frame: CarEnvironment.Frame,
|
|
||||||
) {
|
|
||||||
val id = entry.componentId ?: return
|
|
||||||
val slot = running[id]
|
|
||||||
Box(Modifier.padding(horizontal = 3.dp)) {
|
|
||||||
NavAppIcon(
|
|
||||||
entry,
|
|
||||||
open = slot != null,
|
|
||||||
showing = slot != null && slot.isShowing(navigator.current),
|
|
||||||
onLongClick = { navigator.showAppMenu(id) },
|
|
||||||
) { openApp(entry, slot, navigator, frame) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One open or pinned app's icon on the nav bar - the same fixed size and shape either way. */
|
|
||||||
@Composable
|
|
||||||
private fun NavAppIcon(
|
|
||||||
entry: Entry,
|
|
||||||
open: Boolean,
|
|
||||||
showing: Boolean,
|
|
||||||
onLongClick: () -> Unit,
|
|
||||||
onClick: () -> Unit,
|
|
||||||
) {
|
|
||||||
val scheme = MaterialTheme.colorScheme
|
|
||||||
GsIconBox(
|
|
||||||
onClick,
|
|
||||||
Modifier.size(44.dp),
|
|
||||||
colors = if (showing) GsColors(scheme.secondaryContainer, scheme.onSecondaryContainer)
|
|
||||||
else GsColors(scheme.surfaceVariant, scheme.onSurfaceVariant),
|
|
||||||
latched = showing,
|
|
||||||
onLongClick = onLongClick,
|
|
||||||
) {
|
|
||||||
entry.icon?.let { Image(it, entry.label, Modifier.size(30.dp).clip(RoundedCornerShape(8.dp))) }
|
|
||||||
entry.builtIn?.let { Icon(it.glyph, entry.label, Modifier.size(26.dp)) }
|
|
||||||
// Only running apps get the line along the bottom edge, the usual taskbar mark for it.
|
|
||||||
if (open) {
|
|
||||||
Box(
|
|
||||||
Modifier
|
|
||||||
.align(Alignment.BottomCenter)
|
|
||||||
.padding(bottom = 2.dp)
|
|
||||||
.size(width = 12.dp, height = 3.dp)
|
|
||||||
.clip(RoundedCornerShape(50))
|
|
||||||
.background(scheme.primary),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A tappable group in the bar that stays highlighted while its screen is open, like [NavItem]. */
|
|
||||||
@Composable
|
|
||||||
private fun NavChip(selected: Boolean, onClick: () -> Unit, content: @Composable BoxScope.() -> Unit) {
|
|
||||||
GsIconBox(onClick, colors = navColors(selected), latched = selected) {
|
|
||||||
Box(Modifier.padding(horizontal = 10.dp, vertical = 2.dp)) { content() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Every nav bar item is raised, so it reads as pressable; the current screen's takes the accent colour. */
|
|
||||||
@Composable
|
|
||||||
private fun navColors(selected: Boolean): GsColors {
|
|
||||||
val s = MaterialTheme.colorScheme
|
|
||||||
return if (selected) GsColors(s.secondaryContainer, s.onSecondaryContainer)
|
|
||||||
else GsColors(s.surfaceContainerHighest, s.onSurface)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Between Apps and whatever's actually open, not with the clock/battery group on the right - a
|
|
||||||
* driver reaches for it right after deciding "not a tap for this", same gesture family as the
|
|
||||||
* two buttons beside it.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun NavAssistantIcon() {
|
|
||||||
val state by CarAssistant.state.collectAsState()
|
|
||||||
val active = state.phase != CarAssistant.Phase.IDLE
|
|
||||||
GsIconBox({ CarAssistant.start() }, colors = navColors(active), latched = active) {
|
|
||||||
MicGlyph(Modifier.padding(NAV_ITEM_PADDING).size(28.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Inner padding of every icon on the nav bar, leaving each button room inside the 56dp bar. */
|
|
||||||
private val NAV_ITEM_PADDING = 8.dp
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun NavItem(icon: ImageVector, selected: Boolean, onClick: () -> Unit) {
|
|
||||||
GsIconBox(onClick, colors = navColors(selected), latched = selected) {
|
|
||||||
Icon(icon, contentDescription = null, modifier = Modifier.padding(NAV_ITEM_PADDING).size(28.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Wall-clock time, ticking on the minute: no reason to redraw (and re-encode) more often. */
|
|
||||||
@Composable
|
|
||||||
internal fun rememberNow(): State<Long> = produceState(System.currentTimeMillis()) {
|
|
||||||
while (true) {
|
|
||||||
value = System.currentTimeMillis()
|
|
||||||
delay(60_000 - value % 60_000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** How long the pill shows a new song's title before settling to just the controls. */
|
|
||||||
private const val TITLE_SHOWN_MS = 3_000L
|
|
||||||
|
|
||||||
/** Below this, the nav bar's middle only has room for the turn, not the player beside it. */
|
|
||||||
private val TURN_AND_PLAYER_MIN_WIDTH = 520.dp
|
|
||||||
|
|
||||||
/** The next manoeuvre, as the running nav app last described it. */
|
|
||||||
private class NextTurn(val icon: CarIcon?, val distance: String, val road: String)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read straight off the nav app's own template: a navigation app keeps sending its routing card
|
|
||||||
* while it's running, whether or not the map is the screen being shown.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun rememberNextTurn(): NextTurn? {
|
|
||||||
val status by CarServices.nav.status.collectAsState()
|
|
||||||
val template by CarServices.nav.template.collectAsState()
|
|
||||||
// Read so a change of units redraws the distance straight away, not at the next update.
|
|
||||||
CarSettings.units.collectAsState().value
|
|
||||||
if (status.phase != CarAppConnection.Phase.RUNNING) return null
|
|
||||||
val routing = (template as? NavigationTemplate)?.navigationInfo as? RoutingInfo ?: return null
|
|
||||||
if (routing.isLoading) return null
|
|
||||||
val step = routing.currentStep ?: return null
|
|
||||||
return NextTurn(
|
|
||||||
icon = step.maneuver?.icon,
|
|
||||||
distance = routing.currentDistance.display(),
|
|
||||||
road = step.road.text().ifEmpty { step.cue.text() },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The next turn in the nav bar: arrow, how far, onto what. Tapping goes back to the map. */
|
|
||||||
@Composable
|
|
||||||
private fun NavTurnPill(turn: NextTurn, onClick: () -> Unit) {
|
|
||||||
// Same finish as every other button on the bar, in the accent colour; the text runs a step
|
|
||||||
// bigger than anything else there, since it's the one thing worth a glance mid-drive.
|
|
||||||
GsIconBox(
|
|
||||||
onClick,
|
|
||||||
Modifier.widthIn(max = 340.dp).height(44.dp),
|
|
||||||
colors = gsColors(GsTone.Primary),
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
Modifier.padding(NAV_ITEM_PADDING),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
) {
|
|
||||||
CarGlyph(turn.icon, Modifier.size(28.dp))
|
|
||||||
if (turn.distance.isNotEmpty()) {
|
|
||||||
Text(turn.distance, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, maxLines = 1)
|
|
||||||
}
|
|
||||||
if (turn.road.isNotEmpty()) {
|
|
||||||
Text(
|
|
||||||
turn.road,
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** "Title - Artist" on one line. The text itself cuts it off, and only where the pill runs out of room. */
|
|
||||||
private fun nowPlayingLabel(title: String, artist: String): String =
|
|
||||||
listOf(title.trim(), artist.trim()).filter { it.isNotEmpty() }.joinToString(" - ")
|
|
||||||
.ifEmpty { "Nothing playing" }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The player in the nav bar, on every screen. Art and title open the full media screen;
|
|
||||||
* play/pause and skip work right here. The dock button moves the player into the column beside
|
|
||||||
* the map on Home (see [CarHome]), and the pill steps aside there while it's docked - unless the
|
|
||||||
* column is showing lyrics instead, when the pill stays and its dock button swaps them back.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun NavNowPlaying(navigator: CarNavigator, selected: Boolean, modifier: Modifier = Modifier) {
|
|
||||||
val media = CarServices.media
|
|
||||||
val phase by media.phase.collectAsState()
|
|
||||||
val now by media.now.collectAsState()
|
|
||||||
if (phase != CarMedia.Phase.READY || !now.isActive) return
|
|
||||||
// Docked beside the map, the player is already on screen - the pill steps aside for it.
|
|
||||||
if (navigator.sidePanel == SidePanel.CONTROLS && navigator.current == CarScreen.Home) return
|
|
||||||
// The media screen is the full player already; the pill would only repeat its controls.
|
|
||||||
if (navigator.current == CarScreen.Media) return
|
|
||||||
val context = LocalContext.current
|
|
||||||
val art by produceState(now.art, now.art, now.artUri) { value = now.art ?: MediaArt.load(context, now.artUri) }
|
|
||||||
val position by produceState(now.currentPosition(), now) {
|
|
||||||
while (true) {
|
|
||||||
value = now.currentPosition()
|
|
||||||
if (!now.playing) break
|
|
||||||
delay(1000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A new song announces itself: title, play/pause and dock for a few seconds, then the title
|
|
||||||
// goes and the pill settles to just the controls - unless the driver keeps the title up.
|
|
||||||
val keepTitle by CarSettings.playerTitle.collectAsState()
|
|
||||||
var announcing by remember { mutableStateOf(true) }
|
|
||||||
LaunchedEffect(now.title, now.artist) {
|
|
||||||
announcing = true
|
|
||||||
delay(TITLE_SHOWN_MS)
|
|
||||||
announcing = false
|
|
||||||
}
|
|
||||||
val showTitle = keepTitle || announcing
|
|
||||||
|
|
||||||
val scheme = MaterialTheme.colorScheme
|
|
||||||
val onPill = if (selected) scheme.onPrimaryContainer else scheme.onSecondaryContainer
|
|
||||||
Surface(
|
|
||||||
modifier = modifier
|
|
||||||
.animateContentSize()
|
|
||||||
// With the title up, all the room the bar has, so as much of it shows as can.
|
|
||||||
.then(if (showTitle) Modifier.fillMaxWidth() else Modifier)
|
|
||||||
.height(44.dp),
|
|
||||||
shape = RoundedCornerShape(14.dp),
|
|
||||||
color = if (selected) scheme.primaryContainer else scheme.secondaryContainer,
|
|
||||||
contentColor = onPill,
|
|
||||||
) {
|
|
||||||
Box {
|
|
||||||
Row(
|
|
||||||
Modifier.fillMaxHeight().padding(horizontal = 4.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
|
||||||
) {
|
|
||||||
// Art (and the title while it shows) opens the full media screen.
|
|
||||||
Row(
|
|
||||||
Modifier
|
|
||||||
.then(if (showTitle) Modifier.weight(1f) else Modifier)
|
|
||||||
.fillMaxHeight()
|
|
||||||
.clip(RoundedCornerShape(10.dp))
|
|
||||||
.clickable { navigator.media() },
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
) {
|
|
||||||
Box(Modifier.size(34.dp).clip(RoundedCornerShape(8.dp)).background(scheme.surfaceVariant)) {
|
|
||||||
art?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) }
|
|
||||||
}
|
|
||||||
if (showTitle) {
|
|
||||||
Text(
|
|
||||||
nowPlayingLabel(now.title, now.artist),
|
|
||||||
style = MaterialTheme.typography.titleSmall,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!showTitle) {
|
|
||||||
GsIconButton(
|
|
||||||
MediaIcons.Previous, "Previous", media::previous,
|
|
||||||
enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_PREVIOUS), size = 36.dp, iconSize = 22.dp,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
GsIconButton(
|
|
||||||
if (now.playing) MediaIcons.Pause else Icons.Filled.PlayArrow,
|
|
||||||
if (now.playing) "Pause" else "Play",
|
|
||||||
media::togglePlay,
|
|
||||||
tone = GsTone.Primary, size = 36.dp, iconSize = 22.dp,
|
|
||||||
)
|
|
||||||
if (!showTitle) {
|
|
||||||
GsIconButton(
|
|
||||||
MediaIcons.Next, "Next", media::next,
|
|
||||||
enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_NEXT), size = 36.dp, iconSize = 22.dp,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
GsIconButton(
|
|
||||||
MediaIcons.DockRight, "Move the player beside the map", navigator::togglePlayerDock,
|
|
||||||
size = 36.dp, iconSize = 22.dp,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (now.durationMs > 0) {
|
|
||||||
// matchParentSize, so the line follows the pill's width rather than widening it.
|
|
||||||
Box(Modifier.matchParentSize(), contentAlignment = Alignment.BottomStart) {
|
|
||||||
Box(
|
|
||||||
Modifier
|
|
||||||
.fillMaxWidth((position.toFloat() / now.durationMs).coerceIn(0f, 1f))
|
|
||||||
.height(2.dp)
|
|
||||||
.background(onPill.copy(alpha = 0.7f)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A filled lightning bolt, next to the battery percentage instead of the word "charging" - the
|
|
||||||
* core Material icon set this project ships (no extended pack, to keep the app small) has no
|
|
||||||
* bolt, so this is drawn rather than a vector asset.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun ChargingGlyph(modifier: Modifier = Modifier, tint: Color = MaterialTheme.colorScheme.onSurfaceVariant) {
|
|
||||||
androidx.compose.foundation.Canvas(modifier) {
|
|
||||||
val w = size.width
|
|
||||||
val h = size.height
|
|
||||||
val bolt = androidx.compose.ui.graphics.Path().apply {
|
|
||||||
moveTo(w * 0.58f, 0f)
|
|
||||||
lineTo(w * 0.12f, h * 0.58f)
|
|
||||||
lineTo(w * 0.46f, h * 0.58f)
|
|
||||||
lineTo(w * 0.42f, h)
|
|
||||||
lineTo(w * 0.88f, h * 0.40f)
|
|
||||||
lineTo(w * 0.54f, h * 0.40f)
|
|
||||||
close()
|
|
||||||
}
|
|
||||||
drawPath(bolt, tint)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Stands in for the phone's vibration motor on the car screen: does nothing. */
|
/** Stands in for the phone's vibration motor on the car screen: does nothing. */
|
||||||
private object NoHaptics : HapticFeedback {
|
private object NoHaptics : HapticFeedback {
|
||||||
override fun performHapticFeedback(hapticFeedbackType: HapticFeedbackType) {}
|
override fun performHapticFeedback(hapticFeedbackType: HapticFeedbackType) {}
|
||||||
|
|||||||
@@ -24,23 +24,36 @@ import app.seb3thehacker.gearslip.Protobuf
|
|||||||
*/
|
*/
|
||||||
class ClusterNav(
|
class ClusterNav(
|
||||||
val channelId: Int,
|
val channelId: Int,
|
||||||
|
minimumIntervalMs: Long = 0,
|
||||||
private val sendOnChannel: (messageId: Int, body: ByteArray) -> Unit,
|
private val sendOnChannel: (messageId: Int, body: ByteArray) -> Unit,
|
||||||
) {
|
) {
|
||||||
|
private val updateIntervalMs = maxOf(TICK_MS, minimumIntervalMs)
|
||||||
@Volatile private var running = false
|
@Volatile private var running = false
|
||||||
|
private var worker: Thread? = null
|
||||||
|
private var updatesRequested = true
|
||||||
private var sentStatus = -1
|
private var sentStatus = -1
|
||||||
private var sentTurn: String? = null
|
private var sentTurn: String? = null
|
||||||
private var sentDistance: String? = null
|
private var sentDistance: String? = null
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
fun onMessage(messageId: Int, body: ByteArray) {
|
fun onMessage(messageId: Int, body: ByteArray) {
|
||||||
when (messageId) {
|
when (messageId) {
|
||||||
MSG_CHANNEL_OPEN_RESPONSE -> {
|
MSG_CHANNEL_OPEN_RESPONSE -> {
|
||||||
val status = Protobuf.readInt32Field(body, 1) ?: 0
|
val status = Protobuf.readInt32Field(body, 1)
|
||||||
GearslipLog.i("cluster: <- ChannelOpenResponse: status=$status")
|
GearslipLog.i("cluster: <- ChannelOpenResponse: status=$status")
|
||||||
if (status == 0) start()
|
if (status == 0) start()
|
||||||
}
|
}
|
||||||
// The car saying when it wants turns. Sending all along is harmless, so these are only logged.
|
MSG_START -> {
|
||||||
MSG_START -> GearslipLog.i("cluster: <- start")
|
updatesRequested = true
|
||||||
MSG_STOP -> GearslipLog.i("cluster: <- stop")
|
sentStatus = -1
|
||||||
|
sentTurn = null
|
||||||
|
sentDistance = null
|
||||||
|
GearslipLog.i("cluster: <- start")
|
||||||
|
}
|
||||||
|
MSG_STOP -> {
|
||||||
|
updatesRequested = false
|
||||||
|
GearslipLog.i("cluster: <- stop")
|
||||||
|
}
|
||||||
else -> GearslipLog.i("cluster: <- unhandled message id=$messageId (${body.size} bytes)")
|
else -> GearslipLog.i("cluster: <- unhandled message id=$messageId (${body.size} bytes)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -48,19 +61,28 @@ class ClusterNav(
|
|||||||
private fun start() {
|
private fun start() {
|
||||||
if (running) return
|
if (running) return
|
||||||
running = true
|
running = true
|
||||||
Thread({
|
worker = Thread({
|
||||||
while (running) {
|
while (running) {
|
||||||
runCatching { tick() }.onFailure { GearslipLog.w("cluster: ${it.message}") }
|
runCatching { tick() }.onFailure { GearslipLog.w("cluster: ${it.message}") }
|
||||||
Thread.sleep(TICK_MS)
|
try {
|
||||||
|
Thread.sleep(updateIntervalMs)
|
||||||
|
} catch (_: InterruptedException) {
|
||||||
|
return@Thread
|
||||||
}
|
}
|
||||||
}, "gearslip-cluster").start()
|
}
|
||||||
|
}, "gearslip-cluster").also { it.start() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
fun stop() {
|
fun stop() {
|
||||||
running = false
|
running = false
|
||||||
|
worker?.interrupt()
|
||||||
|
worker = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
private fun tick() {
|
private fun tick() {
|
||||||
|
if (!running || !updatesRequested) return
|
||||||
val turn = NavTurn.current()
|
val turn = NavTurn.current()
|
||||||
val status = when {
|
val status = when {
|
||||||
turn == null -> STATUS_INACTIVE
|
turn == null -> STATUS_INACTIVE
|
||||||
@@ -82,7 +104,7 @@ class ClusterNav(
|
|||||||
val maneuver = step.maneuver
|
val maneuver = step.maneuver
|
||||||
val (event, side) = maneuver?.let { eventFor(it.type) } ?: (EVENT_UNKNOWN to SIDE_UNSPECIFIED)
|
val (event, side) = maneuver?.let { eventFor(it.type) } ?: (EVENT_UNKNOWN to SIDE_UNSPECIFIED)
|
||||||
val road = step.road.text().ifBlank { step.cue.text() }
|
val road = step.road.text().ifBlank { step.cue.text() }
|
||||||
val key = "$road|$event|$side|${maneuver?.roundaboutExitNumber}"
|
val key = "$road|$event|$side|${maneuver?.roundaboutExitNumber}|${maneuver?.roundaboutExitAngle}"
|
||||||
if (key == sentTurn) return
|
if (key == sentTurn) return
|
||||||
sentTurn = key
|
sentTurn = key
|
||||||
var body = Protobuf.stringField(1, road) +
|
var body = Protobuf.stringField(1, road) +
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ import kotlin.math.roundToInt
|
|||||||
* same turn the instrument cluster gets ([NavTurn]) and says it itself: once when the turn comes
|
* same turn the instrument cluster gets ([NavTurn]) and says it itself: once when the turn comes
|
||||||
* up, again a kilometre or so out on a long stretch, and once more just before it. The words are
|
* up, again a kilometre or so out on a long stretch, and once more just before it. The words are
|
||||||
* rendered with the phone's text-to-speech to a file, never played on the phone, and streamed to
|
* rendered with the phone's text-to-speech to a file, never played on the phone, and streamed to
|
||||||
* the car, which ducks the music under them the way it does for real Android Auto.
|
* the car, which ducks the music under them the way it does for real Android Auto. When the music
|
||||||
|
* isn't Gearslip's, each announcement first asks the car for its speakers, as Android Auto does.
|
||||||
*
|
*
|
||||||
* Nothing spoken is logged: road names are where the driver is going.
|
* Nothing spoken is logged: road names are where the driver is going.
|
||||||
*/
|
*/
|
||||||
@@ -42,7 +43,10 @@ object DirectionsVoice {
|
|||||||
|
|
||||||
@Volatile private var link: AudioLink? = null
|
@Volatile private var link: AudioLink? = null
|
||||||
@Volatile private var running = false
|
@Volatile private var running = false
|
||||||
private val speaker = Executors.newSingleThreadExecutor { Thread(it, "gearslip-directions").apply { isDaemon = true } }
|
@Volatile private var musicIsOurs: () -> Boolean = { false }
|
||||||
|
private val speaker = Executors.newSingleThreadScheduledExecutor { Thread(it, "gearslip-directions").apply { isDaemon = true } }
|
||||||
|
/** Counts announcements, so a hand-back scheduled after one is dropped if another has begun. */
|
||||||
|
private var spoken = 0
|
||||||
@Volatile private var latest = 0
|
@Volatile private var latest = 0
|
||||||
|
|
||||||
private var turnKey: String? = null
|
private var turnKey: String? = null
|
||||||
@@ -62,8 +66,9 @@ object DirectionsVoice {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The car opened its directions sink: start following the map app's turns. */
|
/** The car opened its directions sink: start following the map app's turns. */
|
||||||
fun attach(link: AudioLink) {
|
fun attach(link: AudioLink, musicIsOurs: () -> Boolean) {
|
||||||
this.link = link
|
this.link = link
|
||||||
|
this.musicIsOurs = musicIsOurs
|
||||||
GearslipLog.i("guidance: the car offered a directions sink at ${link.sampleRate}Hz x${link.channels}")
|
GearslipLog.i("guidance: the car offered a directions sink at ${link.sampleRate}Hz x${link.channels}")
|
||||||
if (running) return
|
if (running) return
|
||||||
running = true
|
running = true
|
||||||
@@ -82,16 +87,26 @@ object DirectionsVoice {
|
|||||||
running = false
|
running = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Why the last turn wasn't spoken, so the log says it once rather than every tick. */
|
||||||
|
private var silentBecause: String? = null
|
||||||
|
|
||||||
|
private fun quiet(reason: String) {
|
||||||
|
if (silentBecause == reason) return
|
||||||
|
silentBecause = reason
|
||||||
|
GearslipLog.i("guidance: not speaking - $reason")
|
||||||
|
}
|
||||||
|
|
||||||
private fun tick() {
|
private fun tick() {
|
||||||
if (!CarSettings.speakDirections.value) return
|
|
||||||
val turn = NavTurn.current()
|
val turn = NavTurn.current()
|
||||||
if (turn == null) {
|
if (turn == null) {
|
||||||
turnKey = null
|
turnKey = null
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (turn.rerouting) return
|
if (!CarSettings.speakDirections.value) return quiet("Spoken directions is off in the car's Settings")
|
||||||
|
if (turn.rerouting) return quiet("the map app is rerouting")
|
||||||
val step = turn.step
|
val step = turn.step
|
||||||
val instruction = instruction(step) ?: return
|
val instruction = instruction(step) ?: return quiet("the map app gave this turn no words")
|
||||||
|
silentBecause = null
|
||||||
val meters = turn.distance?.let(NavTurn::toMeters)
|
val meters = turn.distance?.let(NavTurn::toMeters)
|
||||||
val close = (turn.seconds != null && turn.seconds <= NEAR_SECONDS) || (meters != null && meters <= NEAR_METERS)
|
val close = (turn.seconds != null && turn.seconds <= NEAR_SECONDS) || (meters != null && meters <= NEAR_METERS)
|
||||||
|
|
||||||
@@ -223,7 +238,16 @@ object DirectionsVoice {
|
|||||||
GearslipLog.w("guidance: text-to-speech gave audio Gearslip can't read")
|
GearslipLog.w("guidance: text-to-speech gave audio Gearslip can't read")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 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)
|
stream(sink, pcm)
|
||||||
|
} finally {
|
||||||
|
speaker.schedule({ if (turn == spoken) sink.handBackSpeakers() }, HOLD_MS, TimeUnit.MILLISECONDS)
|
||||||
|
}
|
||||||
GearslipLog.i("guidance: spoke a turn (${pcm.size / (2 * sink.channels) * 1000 / sink.sampleRate} ms)")
|
GearslipLog.i("guidance: spoke a turn (${pcm.size / (2 * sink.channels) * 1000 / sink.sampleRate} ms)")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,6 +345,7 @@ object DirectionsVoice {
|
|||||||
private const val PREPARE_FROM_METERS = 2_500
|
private const val PREPARE_FROM_METERS = 2_500
|
||||||
private const val PREPARE_AT_METERS = 1_000
|
private const val PREPARE_AT_METERS = 1_000
|
||||||
private const val LEAD_MS = 100L
|
private const val LEAD_MS = 100L
|
||||||
|
private const val HOLD_MS = 2_000L
|
||||||
private const val SYNTH_TIMEOUT_S = 10L
|
private const val SYNTH_TIMEOUT_S = 10L
|
||||||
private val ORDINALS = listOf("first", "second", "third", "fourth", "fifth", "sixth", "seventh", "eighth")
|
private val ORDINALS = listOf("first", "second", "third", "fourth", "fifth", "sixth", "seventh", "eighth")
|
||||||
}
|
}
|
||||||
@@ -11,27 +11,35 @@ import android.os.SystemClock
|
|||||||
*
|
*
|
||||||
* They fade out after [IDLE_MS] with no touch anywhere on the car screen, to leave the map clear,
|
* They fade out after [IDLE_MS] with no touch anywhere on the car screen, to leave the map clear,
|
||||||
* and any touch brings them back. [touch] is called for every pointer event and is cheap: it only
|
* and any touch brings them back. [touch] is called for every pointer event and is cheap: it only
|
||||||
* writes state when the buttons were actually hidden.
|
* writes state when the buttons were actually hidden. The nav bar, when set to hide, goes the
|
||||||
|
* same way but waits longer ([NAV_BAR_IDLE_MS]).
|
||||||
*/
|
*/
|
||||||
class MapControlsWake {
|
class MapControlsWake {
|
||||||
|
|
||||||
var awake by mutableStateOf(true)
|
var awake by mutableStateOf(true)
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
var navBarAwake by mutableStateOf(true)
|
||||||
|
private set
|
||||||
|
|
||||||
private var lastTouch = SystemClock.uptimeMillis()
|
private var lastTouch = SystemClock.uptimeMillis()
|
||||||
|
|
||||||
fun touch() {
|
fun touch() {
|
||||||
lastTouch = SystemClock.uptimeMillis()
|
lastTouch = SystemClock.uptimeMillis()
|
||||||
if (!awake) awake = true
|
if (!awake) awake = true
|
||||||
|
if (!navBarAwake) navBarAwake = true
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Called on a timer; hides the buttons once nothing has touched the screen for [IDLE_MS]. */
|
/** Called on a timer; hides the buttons once nothing has touched the screen for [IDLE_MS]. */
|
||||||
fun tick() {
|
fun tick() {
|
||||||
if (awake && SystemClock.uptimeMillis() - lastTouch >= IDLE_MS) awake = false
|
val idle = SystemClock.uptimeMillis() - lastTouch
|
||||||
|
if (awake && idle >= IDLE_MS) awake = false
|
||||||
|
if (navBarAwake && idle >= NAV_BAR_IDLE_MS) navBarAwake = false
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val IDLE_MS = 4_000L
|
const val IDLE_MS = 4_000L
|
||||||
|
const val NAV_BAR_IDLE_MS = 8_000L
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,232 @@
|
|||||||
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import app.seb3thehacker.gearslip.car.theme.*
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import android.provider.Settings
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||||
|
import androidx.compose.material.icons.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableLongStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.PathFillType
|
||||||
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.graphics.vector.addPathNodes
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import app.seb3thehacker.gearslip.media.CarMedia
|
||||||
|
import app.seb3thehacker.gearslip.media.MediaApp
|
||||||
|
import app.seb3thehacker.gearslip.media.MediaArt
|
||||||
|
import app.seb3thehacker.gearslip.media.LyricsResult
|
||||||
|
import app.seb3thehacker.gearslip.media.Lyrics
|
||||||
|
import app.seb3thehacker.gearslip.media.MediaEntry
|
||||||
|
import app.seb3thehacker.gearslip.media.NowPlaying
|
||||||
|
import app.seb3thehacker.gearslip.media.QueueTrack
|
||||||
|
import android.media.session.PlaybackState
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
|
||||||
|
// The media screen's browse list, up-next queue, rows and icons. Split out of MediaScreen.kt.
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun BrowsePanel(browse: app.seb3thehacker.gearslip.media.BrowseState, media: CarMedia, appLabel: String, modifier: Modifier) {
|
||||||
|
Column(modifier) {
|
||||||
|
// At the library root there's no trail and nowhere to go up to, so there's nothing this
|
||||||
|
// row could say that the app's name in the top-left corner hasn't already - showing it
|
||||||
|
// again here (as "Browse", or as the app's own name) was just the same label twice.
|
||||||
|
if (media.canGoUp || browse.trail.isNotEmpty()) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
if (media.canGoUp) {
|
||||||
|
GsIconButton(Icons.Filled.ArrowBack, "Up", media::up)
|
||||||
|
}
|
||||||
|
browse.trail.lastOrNull()?.let {
|
||||||
|
Text(
|
||||||
|
it,
|
||||||
|
style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold,
|
||||||
|
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
when {
|
||||||
|
browse.loading -> Notice("Loading…", null)
|
||||||
|
browse.unavailable -> Notice(
|
||||||
|
"$appLabel keeps its library to itself.",
|
||||||
|
"Start music in the app on your phone. Play, pause, skip and seek work from here.",
|
||||||
|
)
|
||||||
|
browse.failed -> Notice("Could not load this list.", null)
|
||||||
|
browse.entries.isEmpty() -> Notice("Nothing here.", null)
|
||||||
|
else -> LazyColumn(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
items(browse.entries, key = { it.id }) { entry -> EntryRow(entry, media) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- up next -----------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whatever the app has queued up, starting right after the track that's playing now - not the
|
||||||
|
* whole queue from the top, which would just repeat what the player beside it already shows. Apps
|
||||||
|
* that never publish a queue (many don't) show an empty notice rather than a permanently blank tab.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun QueuePanel(queue: List<QueueTrack>, now: NowPlaying, media: CarMedia, modifier: Modifier) {
|
||||||
|
val upNext = remember(queue, now.activeQueueItemId) {
|
||||||
|
val current = queue.indexOfFirst { it.queueId == now.activeQueueItemId }
|
||||||
|
if (current >= 0) queue.drop(current + 1) else queue
|
||||||
|
}
|
||||||
|
Column(modifier) {
|
||||||
|
if (upNext.isEmpty()) {
|
||||||
|
Notice(
|
||||||
|
"Nothing queued.",
|
||||||
|
"${if (now.title.isEmpty()) "Nothing playing" else "Only what's already playing"} - " +
|
||||||
|
"not every app tells Gearslip what comes next.",
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
LazyColumn(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
items(upNext, key = { it.queueId }) { track -> QueueRow(track, media) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun QueueRow(track: QueueTrack, media: CarMedia) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val icon by produceState(track.iconBitmap, track.iconUri) {
|
||||||
|
value = track.iconBitmap ?: MediaArt.load(context, track.iconUri)
|
||||||
|
}
|
||||||
|
Surface(
|
||||||
|
onClick = { media.playQueueItem(track) },
|
||||||
|
shape = MaterialTheme.shapes.large,
|
||||||
|
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Row(Modifier.padding(10.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
RowArt(icon, MediaIcons.MusicNote)
|
||||||
|
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
|
||||||
|
Text(track.title, style = MaterialTheme.typography.titleSmall, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
if (track.subtitle.isNotEmpty()) {
|
||||||
|
Text(track.subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Icon(Icons.Filled.PlayArrow, "Play", tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun EntryRow(entry: MediaEntry, media: CarMedia, onClick: () -> Unit = { media.select(entry) }) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val icon by produceState(entry.iconBitmap, entry.iconUri) {
|
||||||
|
value = entry.iconBitmap ?: MediaArt.load(context, entry.iconUri)
|
||||||
|
}
|
||||||
|
Surface(
|
||||||
|
onClick = onClick,
|
||||||
|
shape = MaterialTheme.shapes.large,
|
||||||
|
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Row(Modifier.padding(10.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
RowArt(icon, if (entry.browsable) MediaIcons.Folder else MediaIcons.MusicNote)
|
||||||
|
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
|
||||||
|
Text(entry.title, style = MaterialTheme.typography.titleSmall, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
if (entry.subtitle.isNotEmpty()) {
|
||||||
|
Text(entry.subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (entry.browsable && entry.playable) {
|
||||||
|
GsIconButton(Icons.Filled.PlayArrow, "Play", { media.playAll(entry) })
|
||||||
|
}
|
||||||
|
if (entry.browsable) Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A row's artwork, or a folder or note in its place when the app sends none. */
|
||||||
|
@Composable
|
||||||
|
private fun RowArt(icon: android.graphics.Bitmap?, fallback: ImageVector) {
|
||||||
|
Box(
|
||||||
|
Modifier.size(62.dp).clip(MaterialTheme.shapes.medium).background(MaterialTheme.colorScheme.surfaceContainerHigh),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
if (icon != null) Image(icon.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
|
||||||
|
else Icon(fallback, null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(30.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Material's transport icons, from their published path data (they live in the extended set). */
|
||||||
|
internal object MediaIcons {
|
||||||
|
val MusicNote = icon("MusicNote", "M12,3v10.55c-0.59,-0.34 -1.27,-0.55 -2,-0.55 -2.21,0 -4,1.79 -4,4s1.79,4 4,4 4,-1.79 4,-4V7h4V3h-6z")
|
||||||
|
val Folder = icon("Folder", "M10,4H4c-1.1,0 -1.99,0.9 -1.99,2L2,18c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2V8c0,-1.1 -0.9,-2 -2,-2h-8l-2,-2z")
|
||||||
|
val Pause = icon("Pause", "M6,19h4L10,5L6,5v14zM14,5v14h4L18,5h-4z")
|
||||||
|
val Next = icon("SkipNext", "M6,18l8.5,-6L6,6v12zM16,6v12h2V6h-2z")
|
||||||
|
/** The 3x3 app grid; not in material-icons-core. */
|
||||||
|
val Apps = icon("Apps", "M4,8h4L8,4L4,4v4zM10,20h4v-4h-4v4zM4,20h4v-4L4,16v4zM4,14h4v-4L4,10v4zM10,14h4v-4h-4v4zM16,4v4h4L20,4h-4zM10,8h4L14,4h-4v4zM16,14h4v-4h-4v4zM16,20h4v-4h-4v4z")
|
||||||
|
val Previous = icon("SkipPrevious", "M6,6h2v12L6,18zM9.5,12l8.5,6V6z")
|
||||||
|
val Equalizer = icon("Equalizer", "M10,20h4L14,4h-4v16zM4,20h4v-8L4,12v8zM16,9v11h4L20,9h-4z")
|
||||||
|
val Shuffle = icon("Shuffle", "M10.59,9.17L5.41,4 4,5.41l5.17,5.17 1.42,-1.41zM14.5,4l2.04,2.04L4,18.59 5.41,20 17.96,7.46 20,9.5L20,4h-5.5zM14.83,13.41l-1.41,1.41 3.13,3.13L14.5,20L20,20v-5.5l-2.04,2.04 -3.13,-3.13z")
|
||||||
|
val Repeat = icon("Repeat", "M7,7h10v3l4,-4 -4,-4v3L5,5v6h2L7,7zM17,17L7,17v-3l-4,4 4,4v-3h12v-6h-2v4z")
|
||||||
|
val RepeatOne = icon("RepeatOne", "M7,7h10v3l4,-4 -4,-4v3L5,5v6h2L7,7zM17,17L7,17v-3l-4,4 4,4v-3h12v-6h-2v4zM13,15L13,9h-1l-2,1v1h1.5v4L13,15z")
|
||||||
|
/** A screen with a filled right-hand panel: "put the player beside the map". */
|
||||||
|
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(
|
||||||
|
name, 24.dp, 24.dp, 24f, 24f,
|
||||||
|
).addPath(addPathNodes(path), pathFillType = fillType, fill = SolidColor(androidx.compose.ui.graphics.Color.Black)).build()
|
||||||
|
}
|
||||||
@@ -93,6 +93,7 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) {
|
|||||||
|
|
||||||
// The connection outlives this screen: the home screen's player is the same one.
|
// The connection outlives this screen: the home screen's player is the same one.
|
||||||
LaunchedEffect(app) { CarServices.openMedia(app) }
|
LaunchedEffect(app) { CarServices.openMedia(app) }
|
||||||
|
androidx.compose.runtime.DisposableEffect(Unit) { onDispose { CarServices.mediaScreenClosed() } }
|
||||||
|
|
||||||
val art = rememberArt(now)
|
val art = rememberArt(now)
|
||||||
|
|
||||||
@@ -444,7 +445,7 @@ private sealed interface LyricsState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun Notice(title: String, detail: String?, action: Pair<String, () -> Unit>? = null) {
|
internal fun Notice(title: String, detail: String?, action: Pair<String, () -> Unit>? = null) {
|
||||||
Column(
|
Column(
|
||||||
Modifier.fillMaxSize().padding(horizontal = 24.dp),
|
Modifier.fillMaxSize().padding(horizontal = 24.dp),
|
||||||
verticalArrangement = Arrangement.Center,
|
verticalArrangement = Arrangement.Center,
|
||||||
@@ -491,7 +492,14 @@ private fun CoverArt(art: android.graphics.Bitmap?, modifier: Modifier) {
|
|||||||
shadowElevation = 6.dp,
|
shadowElevation = 6.dp,
|
||||||
modifier = Modifier.size(minOf(maxWidth, maxHeight)),
|
modifier = Modifier.size(minOf(maxWidth, maxHeight)),
|
||||||
) {
|
) {
|
||||||
art?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) }
|
if (art != null) {
|
||||||
|
Image(art.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
|
||||||
|
} else {
|
||||||
|
// No cover (or nothing playing): a note, so the square reads as a cover, not a hole.
|
||||||
|
Box(contentAlignment = Alignment.Center) {
|
||||||
|
Icon(MediaIcons.MusicNote, null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.fillMaxSize(0.35f))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -518,6 +526,12 @@ private fun NowPlayingControls(now: NowPlaying, media: CarMedia, modifier: Modif
|
|||||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
now.error?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error, maxLines = 2) }
|
now.error?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error, maxLines = 2) }
|
||||||
|
if (now.title.isEmpty() && now.error == null) {
|
||||||
|
Text(
|
||||||
|
"Press play to pick up where you left off, or find something in Browse.",
|
||||||
|
style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
PlayerProgress(now, media, Modifier.fillMaxWidth())
|
PlayerProgress(now, media, Modifier.fillMaxWidth())
|
||||||
TransportRow(now, media, skip = 60.dp, modifier = Modifier.fillMaxWidth())
|
TransportRow(now, media, skip = 60.dp, modifier = Modifier.fillMaxWidth())
|
||||||
@@ -555,141 +569,3 @@ private fun CompactPlayer(now: NowPlaying, media: CarMedia, modifier: Modifier)
|
|||||||
|
|
||||||
// --- browsing --------------------------------------------------------------------------------
|
// --- browsing --------------------------------------------------------------------------------
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun BrowsePanel(browse: app.seb3thehacker.gearslip.media.BrowseState, media: CarMedia, appLabel: String, modifier: Modifier) {
|
|
||||||
Column(modifier) {
|
|
||||||
// At the library root there's no trail and nowhere to go up to, so there's nothing this
|
|
||||||
// row could say that the app's name in the top-left corner hasn't already - showing it
|
|
||||||
// again here (as "Browse", or as the app's own name) was just the same label twice.
|
|
||||||
if (media.canGoUp || browse.trail.isNotEmpty()) {
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
if (media.canGoUp) {
|
|
||||||
GsIconButton(Icons.Filled.ArrowBack, "Up", media::up)
|
|
||||||
}
|
|
||||||
browse.trail.lastOrNull()?.let {
|
|
||||||
Text(
|
|
||||||
it,
|
|
||||||
style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold,
|
|
||||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
when {
|
|
||||||
browse.loading -> Notice("Loading…", null)
|
|
||||||
browse.unavailable -> Notice(
|
|
||||||
"$appLabel keeps its library to itself.",
|
|
||||||
"Start music in the app on your phone. Play, pause, skip and seek work from here.",
|
|
||||||
)
|
|
||||||
browse.failed -> Notice("Could not load this list.", null)
|
|
||||||
browse.entries.isEmpty() -> Notice("Nothing here.", null)
|
|
||||||
else -> LazyColumn(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
items(browse.entries, key = { it.id }) { entry -> EntryRow(entry, media) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- up next -----------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whatever the app has queued up, starting right after the track that's playing now - not the
|
|
||||||
* whole queue from the top, which would just repeat what the player beside it already shows. Apps
|
|
||||||
* that never publish a queue (many don't) show an empty notice rather than a permanently blank tab.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun QueuePanel(queue: List<QueueTrack>, now: NowPlaying, media: CarMedia, modifier: Modifier) {
|
|
||||||
val upNext = remember(queue, now.activeQueueItemId) {
|
|
||||||
val current = queue.indexOfFirst { it.queueId == now.activeQueueItemId }
|
|
||||||
if (current >= 0) queue.drop(current + 1) else queue
|
|
||||||
}
|
|
||||||
Column(modifier) {
|
|
||||||
if (upNext.isEmpty()) {
|
|
||||||
Notice(
|
|
||||||
"Nothing queued.",
|
|
||||||
"${if (now.title.isEmpty()) "Nothing playing" else "Only what's already playing"} - " +
|
|
||||||
"not every app tells Gearslip what comes next.",
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
LazyColumn(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
items(upNext, key = { it.queueId }) { track -> QueueRow(track, media) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun QueueRow(track: QueueTrack, media: CarMedia) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
val icon by produceState(track.iconBitmap, track.iconUri) {
|
|
||||||
value = track.iconBitmap ?: MediaArt.load(context, track.iconUri)
|
|
||||||
}
|
|
||||||
Surface(
|
|
||||||
onClick = { media.playQueueItem(track) },
|
|
||||||
shape = MaterialTheme.shapes.large,
|
|
||||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
Row(Modifier.padding(10.dp), verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Box(Modifier.size(62.dp).clip(MaterialTheme.shapes.medium).background(MaterialTheme.colorScheme.surface)) {
|
|
||||||
icon?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) }
|
|
||||||
}
|
|
||||||
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
|
|
||||||
Text(track.title, style = MaterialTheme.typography.titleSmall, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
|
||||||
if (track.subtitle.isNotEmpty()) {
|
|
||||||
Text(track.subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Icon(Icons.Filled.PlayArrow, "Play", tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun EntryRow(entry: MediaEntry, media: CarMedia, onClick: () -> Unit = { media.select(entry) }) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
val icon by produceState(entry.iconBitmap, entry.iconUri) {
|
|
||||||
value = entry.iconBitmap ?: MediaArt.load(context, entry.iconUri)
|
|
||||||
}
|
|
||||||
Surface(
|
|
||||||
onClick = onClick,
|
|
||||||
shape = MaterialTheme.shapes.large,
|
|
||||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
Row(Modifier.padding(10.dp), verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Box(Modifier.size(62.dp).clip(MaterialTheme.shapes.medium).background(MaterialTheme.colorScheme.surface)) {
|
|
||||||
icon?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) }
|
|
||||||
}
|
|
||||||
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
|
|
||||||
Text(entry.title, style = MaterialTheme.typography.titleSmall, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
|
||||||
if (entry.subtitle.isNotEmpty()) {
|
|
||||||
Text(entry.subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (entry.browsable && entry.playable) {
|
|
||||||
GsIconButton(Icons.Filled.PlayArrow, "Play", { media.playAll(entry) })
|
|
||||||
}
|
|
||||||
if (entry.browsable) Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Material's transport icons, from their published path data (they live in the extended set). */
|
|
||||||
internal object MediaIcons {
|
|
||||||
val Pause = icon("Pause", "M6,19h4L10,5L6,5v14zM14,5v14h4L18,5h-4z")
|
|
||||||
val Next = icon("SkipNext", "M6,18l8.5,-6L6,6v12zM16,6v12h2V6h-2z")
|
|
||||||
/** The 3x3 app grid; not in material-icons-core. */
|
|
||||||
val Apps = icon("Apps", "M4,8h4L8,4L4,4v4zM10,20h4v-4h-4v4zM4,20h4v-4L4,16v4zM4,14h4v-4L4,10v4zM10,14h4v-4h-4v4zM16,4v4h4L20,4h-4zM10,8h4L14,4h-4v4zM16,14h4v-4h-4v4zM16,20h4v-4h-4v4z")
|
|
||||||
val Previous = icon("SkipPrevious", "M6,6h2v12L6,18zM9.5,12l8.5,6V6z")
|
|
||||||
val Equalizer = icon("Equalizer", "M10,20h4L14,4h-4v16zM4,20h4v-8L4,12v8zM16,9v11h4L20,9h-4z")
|
|
||||||
val Shuffle = icon("Shuffle", "M10.59,9.17L5.41,4 4,5.41l5.17,5.17 1.42,-1.41zM14.5,4l2.04,2.04L4,18.59 5.41,20 17.96,7.46 20,9.5L20,4h-5.5zM14.83,13.41l-1.41,1.41 3.13,3.13L14.5,20L20,20v-5.5l-2.04,2.04 -3.13,-3.13z")
|
|
||||||
val Repeat = icon("Repeat", "M7,7h10v3l4,-4 -4,-4v3L5,5v6h2L7,7zM17,17L7,17v-3l-4,4 4,4v-3h12v-6h-2v4z")
|
|
||||||
val RepeatOne = icon("RepeatOne", "M7,7h10v3l4,-4 -4,-4v3L5,5v6h2L7,7zM17,17L7,17v-3l-4,4 4,4v-3h12v-6h-2v4zM13,15L13,9h-1l-2,1v1h1.5v4L13,15z")
|
|
||||||
/** A screen with a filled right-hand panel: "put the player beside the map". */
|
|
||||||
val DockRight = icon("DockRight", "M2,4h20v16H2zM4,6h10v12H4z", PathFillType.EvenOdd)
|
|
||||||
|
|
||||||
private fun icon(name: String, path: String, fillType: PathFillType = PathFillType.NonZero) = ImageVector.Builder(
|
|
||||||
name, 24.dp, 24.dp, 24f, 24f,
|
|
||||||
).addPath(addPathNodes(path), pathFillType = fillType, fill = SolidColor(androidx.compose.ui.graphics.Color.Black)).build()
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
@@ -80,14 +81,24 @@ 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. */
|
/** A call another car screen wants to place - a number on a place page - waiting on the driver. */
|
||||||
@@ -122,7 +133,7 @@ private const val TAB_KEYPAD = 1
|
|||||||
@Composable
|
@Composable
|
||||||
fun PhoneDialerScreen() {
|
fun PhoneDialerScreen() {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val hasPermissions = hasCallPermissions(context)
|
var hasPermissions by remember { mutableStateOf(hasCallPermissions(context)) }
|
||||||
var tab by remember { mutableStateOf(TAB_CONTACTS) }
|
var tab by remember { mutableStateOf(TAB_CONTACTS) }
|
||||||
|
|
||||||
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||||
@@ -137,7 +148,7 @@ fun PhoneDialerScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!hasPermissions) {
|
if (!hasPermissions) {
|
||||||
MissingPermission(context)
|
MissingPermission(context) { hasPermissions = true }
|
||||||
return@Column
|
return@Column
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,19 +159,23 @@ fun PhoneDialerScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun MissingPermission(context: Context) {
|
private fun MissingPermission(context: Context, onGranted: () -> Unit) {
|
||||||
|
// Android's dialog can't show on the car screen, so it opens on the phone; checking again
|
||||||
|
// every second picks the answer up without the driver having to come back in.
|
||||||
|
androidx.compose.runtime.LaunchedEffect(Unit) {
|
||||||
|
while (!hasCallPermissions(context)) kotlinx.coroutines.delay(1_000)
|
||||||
|
onGranted()
|
||||||
|
}
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
Text(
|
Text(
|
||||||
"Gearslip needs the Phone and Contacts permissions to dial from here. Grant them " +
|
"Gearslip needs the Phone and Contacts permissions to dial from here. Allow them on " +
|
||||||
"from the app's settings.",
|
"the phone when you're parked.",
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
GsButton(tone = GsTone.Tonal, onClick = {
|
GsButton(tone = GsTone.Tonal, onClick = {
|
||||||
context.startActivity(
|
app.seb3thehacker.gearslip.PermissionActivity.ask(context, CALL_PERMISSIONS)
|
||||||
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", context.packageName, null)),
|
}) { Text("Ask on the phone") }
|
||||||
)
|
|
||||||
}) { Text("Open app settings") }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,87 +94,203 @@ 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)
|
-> measured ?: Rect((width * CARD_COLUMN_FRACTION).toInt(), 0, width, height)
|
||||||
null -> Rect(0, 0, width, height)
|
null -> Rect(0, 0, width, height)
|
||||||
else -> Rect(0, 0, 0, 0)
|
else -> Rect(0, 0, 0, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The side of the map a piece of chrome sits against, and so the side it shrinks the visible area from. */
|
||||||
|
internal enum class CoverEdge { TOP, BOTTOM, START, END }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the chrome over the map actually landed, in window pixels. The map buttons count even
|
||||||
|
* while faded out, or whatever an app draws in a corner (Waze's speedometer) is under them the
|
||||||
|
* moment they come back.
|
||||||
|
*/
|
||||||
|
internal class ChromeCover {
|
||||||
|
private val pieces = mutableStateMapOf<String, Pair<CoverEdge, ComposeRect>>()
|
||||||
|
|
||||||
|
fun put(key: String, edge: CoverEdge, bounds: ComposeRect) {
|
||||||
|
val piece = edge to bounds
|
||||||
|
if (pieces[key] != piece) pieces[key] = piece
|
||||||
|
}
|
||||||
|
|
||||||
|
fun remove(key: String) {
|
||||||
|
pieces.remove(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What is left of a [width] x [height] surface at [surface] (window pixels), in surface pixels. */
|
||||||
|
fun visibleWithin(surface: ComposeRect, width: Int, height: Int, skip: CoverEdge? = null): Rect {
|
||||||
|
var left = 0f
|
||||||
|
var top = 0f
|
||||||
|
var right = width.toFloat()
|
||||||
|
var bottom = height.toFloat()
|
||||||
|
pieces.values.forEach { (edge, b) ->
|
||||||
|
if (edge == skip) return@forEach
|
||||||
|
when (edge) {
|
||||||
|
CoverEdge.TOP -> top = maxOf(top, b.bottom - surface.top)
|
||||||
|
CoverEdge.BOTTOM -> bottom = minOf(bottom, b.top - surface.top)
|
||||||
|
CoverEdge.START -> left = maxOf(left, b.right - surface.left)
|
||||||
|
CoverEdge.END -> right = minOf(right, b.left - surface.left)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Never hand an app nothing to draw in, however the cards end up.
|
||||||
|
if (right - left < width / 4f) { left = 0f; right = width.toFloat() }
|
||||||
|
if (bottom - top < height / 4f) { top = 0f; bottom = height.toFloat() }
|
||||||
|
return Rect(left.toInt(), top.toInt(), right.toInt(), bottom.toInt())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal val LocalChromeCover = compositionLocalOf<ChromeCover?> { null }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The 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 connection = LocalCarAppConnection.current
|
||||||
Box(modifier.fillMaxWidth()) {
|
val pan by connection.panMode.collectAsState()
|
||||||
if (!pan) (template.navigationInfo as? RoutingInfo)?.let {
|
// Pan mode clears the map for looking around: only the buttons stay.
|
||||||
RoutingCard(it, Modifier.align(Alignment.TopStart).padding(12.dp).width(320.dp))
|
val routing = (template.navigationInfo as? RoutingInfo)?.takeUnless { pan }
|
||||||
|
val estimate = template.destinationTravelEstimate?.takeUnless { pan }
|
||||||
|
NavigationBars(template, routing, estimate, modifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cards down the left, map on the right: the turn on top, the trip along the bottom, the map
|
||||||
|
* buttons at the right edge. The whole column counts as covered, so the app centres its map in
|
||||||
|
* what is left over and the car sits right of the cards rather than under them. Pan mode drops
|
||||||
|
* the cards and gives the map the whole width.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun NavigationBars(
|
||||||
|
template: NavigationTemplate,
|
||||||
|
routing: RoutingInfo?,
|
||||||
|
estimate: TravelEstimate?,
|
||||||
|
modifier: Modifier,
|
||||||
|
) {
|
||||||
|
val connection = LocalCarAppConnection.current
|
||||||
|
val pan by connection.panMode.collectAsState()
|
||||||
|
androidx.compose.foundation.layout.BoxWithConstraints(modifier.fillMaxSize()) {
|
||||||
|
val column = cardColumnWidth(maxWidth)
|
||||||
|
Row(Modifier.fillMaxSize()) {
|
||||||
|
if (routing != null || estimate != null) {
|
||||||
|
Column(
|
||||||
|
Modifier.width(column).fillMaxHeight().covers("nav-cards", CoverEdge.START).padding(12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
routing?.let { RoutingCard(it, Modifier.fillMaxWidth()) }
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
estimate?.let { TravelEstimateCard(it, Modifier.fillMaxWidth()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
RightEdgeControls(
|
RightEdgeControls(
|
||||||
if (pan) null else template.actionStrip,
|
if (pan) null else template.actionStrip,
|
||||||
template.mapActionStrip,
|
template.mapActionStrip,
|
||||||
Modifier.align(Alignment.TopEnd).padding(12.dp),
|
Modifier.padding(12.dp),
|
||||||
|
mapActionsModifier = Modifier.covers("nav-buttons", CoverEdge.END),
|
||||||
)
|
)
|
||||||
if (!pan) template.destinationTravelEstimate?.let {
|
|
||||||
TravelEstimateBar(it, Modifier.align(Alignment.BottomStart).padding(12.dp))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The manoeuvre card: what to do next, how far away it is, and onto what road. */
|
/**
|
||||||
|
* The manoeuvre, big enough to read at a glance: the arrow and the distance to it, what to do
|
||||||
|
* and onto which road, the lanes to be in, and the turn after it along the bottom.
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun RoutingCard(info: RoutingInfo, modifier: Modifier = Modifier) {
|
private fun RoutingCard(info: RoutingInfo, modifier: Modifier = Modifier) {
|
||||||
if (info.isLoading) {
|
if (info.isLoading) {
|
||||||
ChromeSurface(modifier) {
|
ChromeSurface(modifier) {
|
||||||
Text("Finding a route…", Modifier.padding(16.dp), style = ChromeType.title)
|
Text("Finding a route…", Modifier.padding(horizontal = 18.dp, vertical = 16.dp), style = ChromeType.title)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val step = info.currentStep ?: return
|
val step = info.currentStep ?: return
|
||||||
|
val scheme = MaterialTheme.colorScheme
|
||||||
ChromeSurface(modifier) {
|
ChromeSurface(modifier) {
|
||||||
Column(Modifier.padding(14.dp)) {
|
Column {
|
||||||
|
Column(
|
||||||
|
Modifier.fillMaxWidth().background(scheme.primaryContainer).padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
androidx.compose.runtime.CompositionLocalProvider(
|
||||||
|
androidx.compose.material3.LocalContentColor provides scheme.onPrimaryContainer,
|
||||||
|
) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
CarGlyph(step.maneuver?.icon, Modifier.size(44.dp))
|
CarGlyph(step.maneuver?.icon, Modifier.size(64.dp))
|
||||||
Spacer(Modifier.width(12.dp))
|
Spacer(Modifier.width(14.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)
|
Text(
|
||||||
|
distance,
|
||||||
|
style = MaterialTheme.typography.displaySmall.copy(fontWeight = androidx.compose.ui.text.font.FontWeight.Bold),
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
Text(step.cue.text(), style = ChromeType.title, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
|
||||||
}
|
}
|
||||||
|
val cue = step.cue.text()
|
||||||
|
if (cue.isNotEmpty()) {
|
||||||
|
Text(cue, style = ChromeType.title, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||||
}
|
}
|
||||||
val road = step.road.text()
|
val road = step.road.text()
|
||||||
if (road.isNotEmpty()) {
|
// "Turn left onto Queensberry Drive" already says the road; saying it twice is noise.
|
||||||
|
if (road.isNotEmpty() && !cue.contains(road, ignoreCase = true)) {
|
||||||
Text(
|
Text(
|
||||||
road,
|
road,
|
||||||
style = ChromeType.body,
|
style = ChromeType.body,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = scheme.onPrimaryContainer.copy(alpha = 0.8f),
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// Neither is sent by every app - most say only "turn left" and trust the driver to
|
if (step.lanesImage != null || step.lanes.orEmpty().isNotEmpty()) {
|
||||||
// find the lane themselves - so both are drawn only when there is something to draw.
|
LaneGuidance(step, Modifier.fillMaxWidth().heightIn(max = 56.dp))
|
||||||
info.junctionImage?.let { junction ->
|
}
|
||||||
Spacer(Modifier.height(10.dp))
|
// Rare, and only where an arrow can't explain the junction: worth the card growing for.
|
||||||
JunctionImage(junction)
|
info.junctionImage?.let { JunctionImage(it) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
LaneGuidance(step, Modifier.padding(top = 10.dp))
|
|
||||||
info.nextStep?.let { next ->
|
info.nextStep?.let { next ->
|
||||||
Spacer(Modifier.height(8.dp))
|
Row(
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
|
||||||
Text("then", style = ChromeType.label, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
Spacer(Modifier.width(8.dp))
|
) {
|
||||||
CarGlyph(next.maneuver?.icon, Modifier.size(20.dp))
|
Text("Then", style = ChromeType.label, color = scheme.onSurfaceVariant)
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(10.dp))
|
||||||
Text(next.cue.text(), style = ChromeType.body, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
CarGlyph(next.maneuver?.icon, Modifier.size(28.dp))
|
||||||
|
val nextText = next.cue.text().ifEmpty { next.road.text() }
|
||||||
|
if (nextText.isNotEmpty()) {
|
||||||
|
Spacer(Modifier.width(10.dp))
|
||||||
|
Text(nextText, style = ChromeType.body, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -243,63 +367,59 @@ private fun LaneDirection?.turnDegrees(): Float = when (this?.shape) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The trip at a glance: time and distance left, the clock time it ends at, and - beside them,
|
* The trip at a glance, stacked: time left on top, then the clock time it ends at and the
|
||||||
* where a thumb reaches - the app's own way to stop navigating. A floating card rather than a
|
* distance, with Gearslip's own stop button beside them. The close button is Gearslip's, not the
|
||||||
* full-width bar, so it reads as one thing beside the manoeuvre card above it instead of a strip
|
* app's: an actionStrip's actions carry no flag saying which one (if any) means "stop
|
||||||
* that happens to hold three numbers. The close button is Gearslip's own, not the app's: an
|
* navigating" - the first button turned out to be a mute toggle, not an exit - so this ends
|
||||||
* actionStrip's actions carry no flag saying which one (if any) means "stop navigating" - the
|
* navigation the way going Home already does rather than guess at the app's own controls.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun TravelEstimateBar(estimate: TravelEstimate, modifier: Modifier = Modifier) {
|
private fun TravelEstimateCard(estimate: TravelEstimate, modifier: Modifier = Modifier) {
|
||||||
|
val connection = LocalCarAppConnection.current
|
||||||
val time = remainingTime(estimate.remainingTimeSeconds)
|
val time = remainingTime(estimate.remainingTimeSeconds)
|
||||||
val distance = estimate.remainingDistance.display()
|
val distance = estimate.remainingDistance.display()
|
||||||
val arrival = estimate.arrivalTimeAtDestination.clockTime()
|
val arrival = estimate.arrivalTimeAtDestination.clockTime()
|
||||||
val trip = estimate.tripText.text()
|
val trip = estimate.tripText.text()
|
||||||
if (time.isEmpty() && distance.isEmpty() && arrival.isEmpty() && trip.isEmpty()) return
|
if (time.isEmpty() && distance.isEmpty() && arrival.isEmpty() && trip.isEmpty()) return
|
||||||
|
|
||||||
val detail = listOfNotNull(
|
ChromeSurface(modifier) {
|
||||||
arrival.takeIf { it.isNotEmpty() },
|
|
||||||
distance.takeIf { it.isNotEmpty() },
|
|
||||||
trip.takeIf { it.isNotEmpty() },
|
|
||||||
).joinToString(" • ")
|
|
||||||
|
|
||||||
// The same solid card the manoeuvre card above it uses - one surface for the whole map
|
|
||||||
// chrome, rather than this one spot styled on its own as a bar over the map.
|
|
||||||
ChromeSurface(modifier, shadowElevation = 8.dp) {
|
|
||||||
val dark = MaterialTheme.colorScheme.surface.luminanceIsDark()
|
val dark = MaterialTheme.colorScheme.surface.luminanceIsDark()
|
||||||
Row(
|
Row(
|
||||||
Modifier.padding(start = 8.dp, end = 18.dp, top = 10.dp, bottom = 10.dp),
|
Modifier.padding(start = 18.dp, end = 12.dp, top = 12.dp, bottom = 12.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
// Not a disconnect: the connection stays up and the app decides its own next screen.
|
Column(Modifier.weight(1f)) {
|
||||||
// Ending navigation is its own call, separate from a back press - a back press only
|
|
||||||
// pops the on-screen card, leaving the app still believing it is navigating (so a
|
|
||||||
// freshly picked destination silently does nothing); this is the call that actually
|
|
||||||
// clears that flag on the app's side.
|
|
||||||
GsIconButton(
|
|
||||||
Icons.Filled.Close,
|
|
||||||
"Stop navigating",
|
|
||||||
{ CarServices.nav.stopNavigating() },
|
|
||||||
colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.colorScheme.onSurface),
|
|
||||||
size = 40.dp,
|
|
||||||
iconSize = 20.dp,
|
|
||||||
shape = CircleShape,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.width(10.dp))
|
|
||||||
Column {
|
|
||||||
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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
val detail = listOfNotNull(
|
||||||
|
arrival.takeIf { it.isNotEmpty() }?.let { "Arrive $it" },
|
||||||
|
distance.takeIf { it.isNotEmpty() },
|
||||||
|
).joinToString(" • ")
|
||||||
if (detail.isNotEmpty()) {
|
if (detail.isNotEmpty()) {
|
||||||
Text(detail, style = ChromeType.body, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
Text(detail, style = ChromeType.body, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
}
|
||||||
|
if (trip.isNotEmpty()) {
|
||||||
|
Text(trip, style = ChromeType.body, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Not a disconnect: the connection stays up and the app decides its own next screen.
|
||||||
|
// Ending navigation is its own call, separate from a back press - a back press only
|
||||||
|
// pops the on-screen card, leaving the app still believing it is navigating.
|
||||||
|
GsIconButton(
|
||||||
|
Icons.Filled.Close,
|
||||||
|
"Stop navigating",
|
||||||
|
{ connection.stopNavigating() },
|
||||||
|
colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.colorScheme.onSurface),
|
||||||
|
size = 52.dp,
|
||||||
|
iconSize = 26.dp,
|
||||||
|
shape = CircleShape,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -321,16 +441,18 @@ private fun MapPaneLayout(
|
|||||||
fullHeight: Boolean = false,
|
fullHeight: Boolean = false,
|
||||||
pane: @Composable () -> Unit,
|
pane: @Composable () -> Unit,
|
||||||
) {
|
) {
|
||||||
|
val connection = LocalCarAppConnection.current
|
||||||
// Pan mode hands the whole screen to the map, leaving only its own controls.
|
// Pan mode hands the whole screen to the map, leaving only its own controls.
|
||||||
val pan by CarServices.nav.panMode.collectAsState()
|
val pan by connection.panMode.collectAsState()
|
||||||
val edgeActions = (actionStrip?.actions.orEmpty() + mapActionStrip?.actions.orEmpty()).map { it.identity() }.toSet()
|
val edgeActions = (actionStrip?.actions.orEmpty() + mapActionStrip?.actions.orEmpty()).map { it.identity() }.toSet()
|
||||||
androidx.compose.foundation.layout.BoxWithConstraints(modifier.fillMaxWidth()) {
|
androidx.compose.foundation.layout.BoxWithConstraints(modifier.fillMaxWidth()) {
|
||||||
if (!pan) {
|
if (!pan) {
|
||||||
ChromeSurface(
|
ChromeSurface(
|
||||||
Modifier
|
Modifier
|
||||||
.align(Alignment.TopStart)
|
.align(Alignment.TopStart)
|
||||||
|
.covers("map-pane", CoverEdge.START)
|
||||||
.padding(12.dp)
|
.padding(12.dp)
|
||||||
.width(maxOf(maxWidth * CONTENT_PANE_FRACTION - 12.dp, 232.dp))
|
.width(cardColumnWidth(maxWidth) - 24.dp)
|
||||||
.let { if (fullHeight) it.height(maxHeight - 24.dp) else it.heightIn(max = maxHeight - 24.dp) },
|
.let { if (fullHeight) it.height(maxHeight - 24.dp) else it.heightIn(max = maxHeight - 24.dp) },
|
||||||
shape = androidx.compose.foundation.shape.RoundedCornerShape(20.dp),
|
shape = androidx.compose.foundation.shape.RoundedCornerShape(20.dp),
|
||||||
shadowElevation = 8.dp,
|
shadowElevation = 8.dp,
|
||||||
@@ -451,11 +573,16 @@ private fun ListPane(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fraction of the surface the content pane covers beside a map. */
|
|
||||||
private const val CONTENT_PANE_FRACTION = 0.42f
|
|
||||||
|
|
||||||
/** Fraction of the surface the travel-estimate bar covers along the bottom. */
|
|
||||||
private const val ESTIMATE_FRACTION = 0.14f
|
/**
|
||||||
|
* The width of the cards beside the map, the same for a place card and for navigation so the
|
||||||
|
* map doesn't jump sideways when a route starts.
|
||||||
|
*/
|
||||||
|
internal fun cardColumnWidth(maxWidth: androidx.compose.ui.unit.Dp): androidx.compose.ui.unit.Dp =
|
||||||
|
(maxWidth * CARD_COLUMN_FRACTION).coerceIn(280.dp, 440.dp).coerceAtMost(maxWidth)
|
||||||
|
|
||||||
|
private const val CARD_COLUMN_FRACTION = 0.4f
|
||||||
|
|
||||||
/** Search, sign-in and the like are laid out for a whole column, so they keep one. */
|
/** 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() =
|
||||||
|
|||||||
@@ -517,7 +517,8 @@ private val sectionCache = android.util.LruCache<String, List<Item>>(64)
|
|||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun SectionedContent(template: SectionedItemTemplate, modifier: Modifier, showActionsInHeader: Boolean = true, tabId: String = "") {
|
private fun SectionedContent(template: SectionedItemTemplate, modifier: Modifier, showActionsInHeader: Boolean = true, tabId: String = "") {
|
||||||
val appPackage = CarServices.nav.appPackage.orEmpty()
|
val connection = LocalCarAppConnection.current
|
||||||
|
val appPackage = connection.appPackage.orEmpty()
|
||||||
ContentSurface(modifier) {
|
ContentSurface(modifier) {
|
||||||
// The app's own actions (Spotify's search and Liked Songs shortcut, for two) come from
|
// The app's own actions (Spotify's search and Liked Songs shortcut, for two) come from
|
||||||
// template.actions, not the header - but a bottom-of-content row is wrong for a driver
|
// template.actions, not the header - but a bottom-of-content row is wrong for a driver
|
||||||
|
|||||||
@@ -106,7 +106,8 @@ internal fun ChromeSurface(
|
|||||||
) {
|
) {
|
||||||
Surface(
|
Surface(
|
||||||
shadowElevation = shadowElevation,
|
shadowElevation = shadowElevation,
|
||||||
color = MaterialTheme.colorScheme.surface,
|
// The nav bar's own container colour, so cards over the map match the rest of the car UI.
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
shape = shape,
|
shape = shape,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
@@ -217,28 +218,34 @@ internal fun headerTitle(header: Header?, fallback: String): String =
|
|||||||
* actions in a row along the top, its map controls stacked under them. Keeping them in one
|
* actions in a row along the top, its map controls stacked under them. Keeping them in one
|
||||||
* column is what stops them colliding on a screen only 480px tall.
|
* column is what stops them colliding on a screen only 480px tall.
|
||||||
*/
|
*/
|
||||||
|
/** How visible the map buttons stay once they fade: enough to know they're there. */
|
||||||
|
private const val RESTING_ALPHA = 0.35f
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun RightEdgeControls(
|
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 connection = LocalCarAppConnection.current
|
||||||
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() }
|
||||||
if (actions.isEmpty() && mapActions.isEmpty()) return
|
if (actions.isEmpty() && mapActions.isEmpty()) return
|
||||||
// A template that stops offering Pan must not leave the screen stuck in pan mode.
|
// A template that stops offering Pan must not leave the screen stuck in pan mode.
|
||||||
val hasPan = (actions + mapActions).any { it.type == Action.TYPE_PAN }
|
val hasPan = (actions + mapActions).any { it.type == Action.TYPE_PAN }
|
||||||
androidx.compose.runtime.LaunchedEffect(hasPan) { if (!hasPan) CarServices.nav.setPanMode(false) }
|
androidx.compose.runtime.LaunchedEffect(hasPan) { if (!hasPan) connection.setPanMode(false) }
|
||||||
|
|
||||||
// Faded out when the screen has been left alone; then they are only a memory of buttons, so
|
// Faded out when the screen has been left alone; then they are only a memory of buttons, so
|
||||||
// the first touch on one wakes them rather than pressing it.
|
// 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 connection.panMode.collectAsState()
|
||||||
// Kept up while the knob is on one of them, or the ring would fade out along with the button.
|
// Kept up while the knob is on one of them, or the ring would fade out along with the button.
|
||||||
var holdsFocus by remember { mutableStateOf(false) }
|
var holdsFocus by remember { mutableStateOf(false) }
|
||||||
val visible = awake || pan || holdsFocus
|
val visible = awake || pan || holdsFocus
|
||||||
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 RESTING_ALPHA,
|
||||||
animationSpec = androidx.compose.animation.core.tween(if (visible) 150 else 500),
|
animationSpec = androidx.compose.animation.core.tween(if (visible) 150 else 500),
|
||||||
label = "mapControls",
|
label = "mapControls",
|
||||||
)
|
)
|
||||||
@@ -263,9 +270,17 @@ internal fun RightEdgeControls(
|
|||||||
actions.forEach { ActionButton(it) }
|
actions.forEach { ActionButton(it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (mapActions.isNotEmpty()) {
|
||||||
|
Column(
|
||||||
|
mapActionsModifier,
|
||||||
|
horizontalAlignment = Alignment.End,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
mapActions.forEach { ActionButton(it) }
|
mapActions.forEach { ActionButton(it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** [large] is the pane's main call to action: it shares the card's full width, taller, in bigger type. */
|
/** [large] is the pane's main call to action: it shares the card's full width, taller, in bigger type. */
|
||||||
@Composable
|
@Composable
|
||||||
@@ -278,7 +293,7 @@ internal fun ActionRow(actions: List<Action>, modifier: Modifier = Modifier, lar
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** A row with nothing to show: no words, no picture, nothing to tap. */
|
/** A row with nothing to show: no words, no picture, nothing to tap. */
|
||||||
private fun CarRow.isBlank(): Boolean =
|
internal fun CarRow.isBlank(): Boolean =
|
||||||
title.text().isBlank() && texts.orEmpty().all { it.text().isBlank() } && image == null &&
|
title.text().isBlank() && texts.orEmpty().all { it.text().isBlank() } && image == null &&
|
||||||
toggle == null && onClickDelegate == null && actions.orEmpty().isEmpty()
|
toggle == null && onClickDelegate == null && actions.orEmpty().isEmpty()
|
||||||
|
|
||||||
@@ -288,13 +303,14 @@ private fun CarRow.isBlank(): Boolean =
|
|||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
internal fun ActionButton(action: Action, large: Boolean = false, modifier: Modifier = Modifier) {
|
internal fun ActionButton(action: Action, large: Boolean = false, modifier: Modifier = Modifier) {
|
||||||
|
val connection = LocalCarAppConnection.current
|
||||||
if (action.type == Action.TYPE_APP_ICON) {
|
if (action.type == Action.TYPE_APP_ICON) {
|
||||||
AppIconBadge()
|
AppIconBadge()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val dark = MaterialTheme.colorScheme.surface.luminanceIsDark()
|
val dark = MaterialTheme.colorScheme.surface.luminanceIsDark()
|
||||||
val title = action.title.text()
|
val title = action.title.text()
|
||||||
val panOn by CarServices.nav.panMode.collectAsState()
|
val panOn by connection.panMode.collectAsState()
|
||||||
val active = action.type == Action.TYPE_PAN && panOn
|
val active = action.type == Action.TYPE_PAN && panOn
|
||||||
val scheme = MaterialTheme.colorScheme
|
val scheme = MaterialTheme.colorScheme
|
||||||
val custom = action.backgroundColor?.type?.let { it != androidx.car.app.model.CarColor.TYPE_DEFAULT } == true
|
val custom = action.backgroundColor?.type?.let { it != androidx.car.app.model.CarColor.TYPE_DEFAULT } == true
|
||||||
@@ -309,8 +325,8 @@ internal fun ActionButton(action: Action, large: Boolean = false, modifier: Modi
|
|||||||
|
|
||||||
GsIconBox(
|
GsIconBox(
|
||||||
onClick = {
|
onClick = {
|
||||||
if (action.type == Action.TYPE_BACK) CarServices.nav.backPressed()
|
if (action.type == Action.TYPE_BACK) connection.backPressed()
|
||||||
else if (action.type == Action.TYPE_PAN) CarServices.nav.setPanMode(!panOn)
|
else if (action.type == Action.TYPE_PAN) connection.setPanMode(!panOn)
|
||||||
else action.onClickDelegate?.click("action")
|
else action.onClickDelegate?.click("action")
|
||||||
},
|
},
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
@@ -421,8 +437,9 @@ private const val AUTO_START_MS = 5_000L
|
|||||||
/** The connected app's own launcher icon, which is what the app-icon action stands for. */
|
/** The connected app's own launcher icon, which is what the app-icon action stands for. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun AppIconBadge() {
|
private fun AppIconBadge() {
|
||||||
|
val connection = LocalCarAppConnection.current
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val pkg = CarServices.nav.appPackage
|
val pkg = connection.appPackage
|
||||||
val icon = remember(pkg) {
|
val icon = remember(pkg) {
|
||||||
pkg?.let {
|
pkg?.let {
|
||||||
runCatching {
|
runCatching {
|
||||||
@@ -497,315 +514,6 @@ internal fun CarGlyph(icon: CarIcon?, modifier: Modifier = Modifier, standard: I
|
|||||||
|
|
||||||
// --- lists, grids and panes ---------------------------------------------------------------------
|
// --- lists, grids and panes ---------------------------------------------------------------------
|
||||||
|
|
||||||
@Composable
|
|
||||||
internal fun PaneTitle(title: String) {
|
|
||||||
if (title.isEmpty()) return
|
|
||||||
Text(
|
|
||||||
title,
|
|
||||||
style = ChromeType.title,
|
|
||||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
internal fun ItemListColumn(
|
|
||||||
single: ItemList?,
|
|
||||||
sectioned: List<ItemList>? = null,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
val rows = buildList {
|
|
||||||
single?.items?.filterIsInstance<CarRow>()?.let(::addAll)
|
|
||||||
sectioned?.forEach { list -> addAll(list.items.filterIsInstance<CarRow>()) }
|
|
||||||
}
|
|
||||||
if (rows.isEmpty()) {
|
|
||||||
val message = single?.noItemsMessage.text()
|
|
||||||
if (message.isNotEmpty()) Text(message, Modifier.padding(14.dp), style = ChromeType.body)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// A list the app made selectable is a single-choice list: one option chosen, shown as a radio.
|
|
||||||
val onSelected = single?.onSelectedDelegate
|
|
||||||
val chosen = single?.selectedIndex ?: -1
|
|
||||||
LazyColumn(modifier, contentPadding = PaddingValues(bottom = 8.dp)) {
|
|
||||||
itemsIndexed(rows) { index, row ->
|
|
||||||
RowItem(row, onSelected?.let { RowSelection(index == chosen) { it.selected(index) } })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A grid of tappable tiles - what apps use for a home screen beside the map. Icons here are as
|
|
||||||
* often a small menu glyph (Organic Maps' Search, say) as real cover art, so the tile stays
|
|
||||||
* modest; [GridTile]'s own default is for the shelf case, where every tile is a photo. */
|
|
||||||
@Composable
|
|
||||||
internal fun GridItems(list: ItemList?, modifier: Modifier = Modifier) {
|
|
||||||
val items = list?.items?.filterIsInstance<GridItem>().orEmpty()
|
|
||||||
if (items.isEmpty()) return
|
|
||||||
LazyVerticalGrid(
|
|
||||||
columns = GridCells.Adaptive(84.dp),
|
|
||||||
modifier = modifier,
|
|
||||||
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
|
||||||
) {
|
|
||||||
items(items) { item -> GridTile(item, iconSize = 36.dp) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One cover-art tile: an app's own icon over a title and optional subtitle. Shared by
|
|
||||||
* [GridItems] (a whole screen of tiles, [iconSize] scaled down there to suit a plain menu glyph)
|
|
||||||
* and a section's horizontal shelf, which carries the same [GridItem] type but scrolls sideways
|
|
||||||
* instead of wrapping into a grid and wants its full-size cover art.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
internal fun GridTile(item: GridItem, modifier: Modifier = Modifier, iconSize: androidx.compose.ui.unit.Dp = 144.dp) {
|
|
||||||
Column(
|
|
||||||
modifier
|
|
||||||
.clickable { item.onClickDelegate.click("grid item") }
|
|
||||||
.padding(vertical = 8.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
) {
|
|
||||||
CarGlyph(item.image, Modifier.size(iconSize))
|
|
||||||
Spacer(Modifier.height(6.dp))
|
|
||||||
Text(
|
|
||||||
item.title.text(),
|
|
||||||
style = ChromeType.label,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
val subtitle = item.text.text()
|
|
||||||
if (subtitle.isNotEmpty()) {
|
|
||||||
Text(
|
|
||||||
subtitle,
|
|
||||||
style = ChromeType.label,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* [autoStart] is for a pane floating over a map - a place or a route the app is proposing - where
|
|
||||||
* a single action counts itself down rather than waiting to be pressed, on the theory that a
|
|
||||||
* driver glancing at the car screen has already decided. It never applies to more than one
|
|
||||||
* action: a pane offering a real choice (Start vs. something else) always waits to be tapped.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
internal fun PaneRows(pane: Pane?, modifier: Modifier = Modifier, autoStart: Boolean = false) {
|
|
||||||
// Some apps pad a pane with a row of blank strings; drawn, it is just a gap.
|
|
||||||
val rows = pane?.rows.orEmpty().filterNot { it.isBlank() }
|
|
||||||
val actions = pane?.actions.orEmpty().filter { it.isDrawable() }
|
|
||||||
val card = PlaceCard.of(rows)
|
|
||||||
Column(modifier) {
|
|
||||||
card.heading?.let { PlaceHeading(it) }
|
|
||||||
if (card.buttons.isNotEmpty()) RowButtons(card.buttons, Modifier.padding(horizontal = 14.dp, vertical = 6.dp))
|
|
||||||
if (autoStart && actions.size == 1) {
|
|
||||||
AutoStartButton(actions.single(), Modifier.padding(horizontal = 14.dp, vertical = 10.dp))
|
|
||||||
} else {
|
|
||||||
ActionRow(actions, Modifier.padding(horizontal = 14.dp, vertical = 10.dp), large = true)
|
|
||||||
}
|
|
||||||
if (card.heading == null && card.buttons.isEmpty()) {
|
|
||||||
card.details.forEach { RowItem(it, large = true) }
|
|
||||||
} else {
|
|
||||||
card.details.forEach { DetailRow(it, card.heading?.title?.text().orEmpty()) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A pane split the way a place page reads: a name on top, the things to do with it as buttons,
|
|
||||||
* then the facts. Apps send all three as plain rows, so the split is worked out from the rows'
|
|
||||||
* shape alone. A pane that doesn't fit the shape - all of it tappable, like a menu - gets no
|
|
||||||
* heading and no buttons, and draws exactly as a plain list of rows.
|
|
||||||
*/
|
|
||||||
private class PlaceCard(val heading: CarRow?, val buttons: List<CarRow>, val details: List<CarRow>) {
|
|
||||||
companion object {
|
|
||||||
fun of(rows: List<CarRow>): PlaceCard {
|
|
||||||
// A short tappable row with an icon and nothing under it is a button in all but name:
|
|
||||||
// MAPS.ME's "Let's go" and "Save".
|
|
||||||
val buttons = rows.filter {
|
|
||||||
it.onClickDelegate != null && it.toggle == null && it.image != null &&
|
|
||||||
it.texts.orEmpty().isEmpty() && it.title.text().length <= 24
|
|
||||||
}.takeIf { it.size in 1..4 && it.size < rows.size }.orEmpty()
|
|
||||||
// A first row that can't be tapped and has no icon is the place's name and kind.
|
|
||||||
val heading = rows.firstOrNull()?.takeIf {
|
|
||||||
buttons.isNotEmpty() && it.onClickDelegate == null && it.image == null && it.toggle == null
|
|
||||||
}
|
|
||||||
return PlaceCard(heading, buttons, rows - buttons.toSet() - listOfNotNull(heading).toSet())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun PlaceHeading(row: CarRow) {
|
|
||||||
Column(Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 6.dp)) {
|
|
||||||
Text(row.title.text(), style = ChromeType.title, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
|
||||||
row.texts.orEmpty().take(2).forEach {
|
|
||||||
Text(it.text(), style = ChromeType.body, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Button-shaped rows side by side, the first one filled as the thing most drivers came to do. */
|
|
||||||
@Composable
|
|
||||||
private fun RowButtons(rows: List<CarRow>, modifier: Modifier) {
|
|
||||||
val scheme = MaterialTheme.colorScheme
|
|
||||||
Row(modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
rows.forEachIndexed { i, row ->
|
|
||||||
GsIconBox(
|
|
||||||
onClick = { row.onClickDelegate.click("row") },
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
colors = if (i == 0) GsColors(scheme.primary, scheme.onPrimary) else GsColors(scheme.surfaceContainerHighest, scheme.onSurface),
|
|
||||||
enabled = row.isEnabled,
|
|
||||||
shape = RoundedCornerShape(24.dp),
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 14.dp),
|
|
||||||
horizontalArrangement = Arrangement.Center,
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
CarGlyph(row.image, Modifier.size(24.dp))
|
|
||||||
Spacer(Modifier.width(8.dp))
|
|
||||||
Text(row.title.text(), style = ChromeType.body, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One fact about a place, tighter than a list row: a small label over its value, or just the
|
|
||||||
* value when the row has no label. A phone number gets a Call button that asks first.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun DetailRow(row: CarRow, placeName: String) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
val texts = row.texts.orEmpty().map { it.text() }.filter { it.isNotBlank() }
|
|
||||||
val label = row.title.text().takeIf { texts.isNotEmpty() }
|
|
||||||
val values = texts.ifEmpty { listOf(row.title.text()) }
|
|
||||||
val number = values.firstOrNull { it.isPhoneNumber() }
|
|
||||||
val canCall = number != null &&
|
|
||||||
context.checkSelfPermission(android.Manifest.permission.CALL_PHONE) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
|
||||||
Row(
|
|
||||||
Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.let { if (row.onClickDelegate != null && row.isEnabled) it.clickable { row.onClickDelegate.click("row") } else it }
|
|
||||||
.padding(horizontal = 14.dp, vertical = 6.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
row.image?.let {
|
|
||||||
CarGlyph(it, Modifier.size(24.dp))
|
|
||||||
Spacer(Modifier.width(12.dp))
|
|
||||||
}
|
|
||||||
Column(Modifier.weight(1f)) {
|
|
||||||
label?.let {
|
|
||||||
Text(it, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1)
|
|
||||||
}
|
|
||||||
values.take(3).forEach {
|
|
||||||
Text(it, style = ChromeType.body, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (canCall) {
|
|
||||||
Spacer(Modifier.width(8.dp))
|
|
||||||
GsIconBox(
|
|
||||||
onClick = { CallPrompt.ask(placeName.ifBlank { number!! }, number!!) },
|
|
||||||
colors = GsColors(MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.onPrimary),
|
|
||||||
shape = RoundedCornerShape(24.dp),
|
|
||||||
) {
|
|
||||||
Row(Modifier.padding(horizontal = 16.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
androidx.compose.material3.Icon(Icons.Filled.Call, null, Modifier.size(20.dp))
|
|
||||||
Spacer(Modifier.width(6.dp))
|
|
||||||
Text("Call", style = ChromeType.body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ActionRow(row.actions.orEmpty())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A line that is only a phone number: digits with the usual separators, 7 to 15 digits long. */
|
|
||||||
private fun String.isPhoneNumber(): Boolean {
|
|
||||||
val t = trim()
|
|
||||||
if (!Regex("""^\+?[0-9 ().\-]+$""").matches(t)) return false
|
|
||||||
return t.count { it.isDigit() } in 7..15
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
internal fun RowItem(row: CarRow, selection: RowSelection? = null, large: Boolean = false) {
|
|
||||||
val toggle = row.toggle
|
|
||||||
// Shown at once and corrected when the app answers with its own template, so the switch
|
|
||||||
// never lags a tap; keyed on what the app last said so its answer wins.
|
|
||||||
var checked by remember(toggle?.isChecked) { mutableStateOf(toggle?.isChecked == true) }
|
|
||||||
val clickable = row.isEnabled && (toggle != null || selection != null || row.onClickDelegate != null)
|
|
||||||
Row(
|
|
||||||
Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.let {
|
|
||||||
if (!clickable) it else it.clickable {
|
|
||||||
when {
|
|
||||||
toggle != null -> {
|
|
||||||
checked = !checked
|
|
||||||
toggle.onCheckedChangeDelegate.checkedChanged(checked)
|
|
||||||
}
|
|
||||||
selection != null -> selection.onPick()
|
|
||||||
else -> row.onClickDelegate.click("row")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(horizontal = 14.dp, vertical = 10.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
if (selection != null) {
|
|
||||||
RadioButton(selected = selection.selected, onClick = null)
|
|
||||||
Spacer(Modifier.width(12.dp))
|
|
||||||
}
|
|
||||||
row.image?.let {
|
|
||||||
CarGlyph(it, Modifier.size(28.dp))
|
|
||||||
Spacer(Modifier.width(12.dp))
|
|
||||||
}
|
|
||||||
// large only changes how much room a row gets (more lines, a bigger icon above) - the
|
|
||||||
// type itself is the same scale as every other row, so a pane's one row and a plain
|
|
||||||
// list's tenth row read as the same kind of text.
|
|
||||||
Column(Modifier.weight(1f)) {
|
|
||||||
Text(
|
|
||||||
row.title.text(),
|
|
||||||
style = ChromeType.title,
|
|
||||||
maxLines = if (large) 2 else 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
row.texts.orEmpty().take(if (large) 3 else 2).forEach { line ->
|
|
||||||
Text(
|
|
||||||
line.text(),
|
|
||||||
style = ChromeType.body,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
maxLines = if (large) 2 else 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ActionRow(row.actions.orEmpty())
|
|
||||||
if (toggle != null) {
|
|
||||||
Spacer(Modifier.width(8.dp))
|
|
||||||
Switch(checked = checked, onCheckedChange = null, enabled = row.isEnabled)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A row's place in a single-choice list: whether it is the chosen one, and what picking it does. */
|
|
||||||
internal class RowSelection(val selected: Boolean, val onPick: () -> Unit)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether this colour stands out against the background that [content] was chosen for: at least
|
|
||||||
* 3:1, the WCAG minimum for icons. The background is taken as the opposite of the content colour.
|
|
||||||
*/
|
|
||||||
private fun Color.readableOn(content: Color): Boolean {
|
|
||||||
val background = if (content.luminanceIsDark()) Color.White else Color(0xFF121212)
|
|
||||||
val a = luminance() + 0.05f
|
|
||||||
val b = background.luminance() + 0.05f
|
|
||||||
return maxOf(a, b) / minOf(a, b) >= 3f
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun Color.luminanceIsDark(): Boolean =
|
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,391 @@
|
|||||||
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
|
import app.seb3thehacker.gearslip.car.theme.*
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
|
import androidx.compose.material3.LocalContentColor
|
||||||
|
import androidx.compose.ui.graphics.ColorFilter
|
||||||
|
import androidx.compose.ui.graphics.luminance
|
||||||
|
import androidx.compose.ui.graphics.ImageBitmap
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Call
|
||||||
|
import androidx.compose.material.icons.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.Menu
|
||||||
|
import androidx.compose.material.icons.filled.Warning
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import app.seb3thehacker.gearslip.host.selected
|
||||||
|
import app.seb3thehacker.gearslip.host.checkedChanged
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.material3.RadioButton
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.core.graphics.drawable.toBitmap
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.Shape
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.car.app.model.Action
|
||||||
|
import androidx.car.app.model.ActionStrip
|
||||||
|
import androidx.car.app.model.CarIcon
|
||||||
|
import androidx.car.app.model.GridItem
|
||||||
|
import androidx.car.app.model.Header
|
||||||
|
import androidx.car.app.model.ItemList
|
||||||
|
import androidx.car.app.model.Pane
|
||||||
|
import androidx.car.app.model.Row as CarRow
|
||||||
|
import androidx.car.app.model.Template
|
||||||
|
import app.seb3thehacker.gearslip.host.click
|
||||||
|
import app.seb3thehacker.gearslip.host.color
|
||||||
|
import app.seb3thehacker.gearslip.host.image
|
||||||
|
import app.seb3thehacker.gearslip.host.isDrawable
|
||||||
|
import app.seb3thehacker.gearslip.host.standardType
|
||||||
|
import app.seb3thehacker.gearslip.host.text
|
||||||
|
|
||||||
|
// Lists, grids, panes and rows of templated apps. Split out of TemplateParts.kt.
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun PaneTitle(title: String) {
|
||||||
|
if (title.isEmpty()) return
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style = ChromeType.title,
|
||||||
|
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun ItemListColumn(
|
||||||
|
single: ItemList?,
|
||||||
|
sectioned: List<ItemList>? = null,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val rows = buildList {
|
||||||
|
single?.items?.filterIsInstance<CarRow>()?.let(::addAll)
|
||||||
|
sectioned?.forEach { list -> addAll(list.items.filterIsInstance<CarRow>()) }
|
||||||
|
}
|
||||||
|
if (rows.isEmpty()) {
|
||||||
|
val message = single?.noItemsMessage.text()
|
||||||
|
if (message.isNotEmpty()) Text(message, Modifier.padding(14.dp), style = ChromeType.body)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// A list the app made selectable is a single-choice list: one option chosen, shown as a radio.
|
||||||
|
val onSelected = single?.onSelectedDelegate
|
||||||
|
val chosen = single?.selectedIndex ?: -1
|
||||||
|
LazyColumn(modifier, contentPadding = PaddingValues(bottom = 8.dp)) {
|
||||||
|
itemsIndexed(rows) { index, row ->
|
||||||
|
RowItem(row, onSelected?.let { RowSelection(index == chosen) { it.selected(index) } })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A grid of tappable tiles - what apps use for a home screen beside the map. Icons here are as
|
||||||
|
* often a small menu glyph (Organic Maps' Search, say) as real cover art, so the tile stays
|
||||||
|
* modest; [GridTile]'s own default is for the shelf case, where every tile is a photo. */
|
||||||
|
@Composable
|
||||||
|
internal fun GridItems(list: ItemList?, modifier: Modifier = Modifier) {
|
||||||
|
val items = list?.items?.filterIsInstance<GridItem>().orEmpty()
|
||||||
|
if (items.isEmpty()) return
|
||||||
|
LazyVerticalGrid(
|
||||||
|
columns = GridCells.Adaptive(84.dp),
|
||||||
|
modifier = modifier,
|
||||||
|
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
) {
|
||||||
|
items(items) { item -> GridTile(item, iconSize = 36.dp) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One cover-art tile: an app's own icon over a title and optional subtitle. Shared by
|
||||||
|
* [GridItems] (a whole screen of tiles, [iconSize] scaled down there to suit a plain menu glyph)
|
||||||
|
* and a section's horizontal shelf, which carries the same [GridItem] type but scrolls sideways
|
||||||
|
* instead of wrapping into a grid and wants its full-size cover art.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun GridTile(item: GridItem, modifier: Modifier = Modifier, iconSize: androidx.compose.ui.unit.Dp = 144.dp) {
|
||||||
|
Column(
|
||||||
|
modifier
|
||||||
|
.clickable { item.onClickDelegate.click("grid item") }
|
||||||
|
.padding(vertical = 8.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
CarGlyph(item.image, Modifier.size(iconSize))
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
Text(
|
||||||
|
item.title.text(),
|
||||||
|
style = ChromeType.label,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
val subtitle = item.text.text()
|
||||||
|
if (subtitle.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
subtitle,
|
||||||
|
style = ChromeType.label,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [autoStart] is for a pane floating over a map - a place or a route the app is proposing - where
|
||||||
|
* a single action counts itself down rather than waiting to be pressed, on the theory that a
|
||||||
|
* driver glancing at the car screen has already decided. It never applies to more than one
|
||||||
|
* action: a pane offering a real choice (Start vs. something else) always waits to be tapped.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun PaneRows(pane: Pane?, modifier: Modifier = Modifier, autoStart: Boolean = false) {
|
||||||
|
// Some apps pad a pane with a row of blank strings; drawn, it is just a gap.
|
||||||
|
val rows = pane?.rows.orEmpty().filterNot { it.isBlank() }
|
||||||
|
val actions = pane?.actions.orEmpty().filter { it.isDrawable() }
|
||||||
|
val card = PlaceCard.of(rows)
|
||||||
|
Column(modifier) {
|
||||||
|
card.heading?.let { PlaceHeading(it) }
|
||||||
|
if (card.buttons.isNotEmpty()) RowButtons(card.buttons, Modifier.padding(horizontal = 14.dp, vertical = 6.dp))
|
||||||
|
if (autoStart && actions.size == 1) {
|
||||||
|
AutoStartButton(actions.single(), Modifier.padding(horizontal = 14.dp, vertical = 10.dp))
|
||||||
|
} else {
|
||||||
|
ActionRow(actions, Modifier.padding(horizontal = 14.dp, vertical = 10.dp), large = true)
|
||||||
|
}
|
||||||
|
if (card.heading == null && card.buttons.isEmpty()) {
|
||||||
|
card.details.forEach { RowItem(it, large = true) }
|
||||||
|
} else {
|
||||||
|
card.details.forEach { DetailRow(it, card.heading?.title?.text().orEmpty()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A pane split the way a place page reads: a name on top, the things to do with it as buttons,
|
||||||
|
* then the facts. Apps send all three as plain rows, so the split is worked out from the rows'
|
||||||
|
* shape alone. A pane that doesn't fit the shape - all of it tappable, like a menu - gets no
|
||||||
|
* heading and no buttons, and draws exactly as a plain list of rows.
|
||||||
|
*/
|
||||||
|
private class PlaceCard(val heading: CarRow?, val buttons: List<CarRow>, val details: List<CarRow>) {
|
||||||
|
companion object {
|
||||||
|
fun of(rows: List<CarRow>): PlaceCard {
|
||||||
|
// A short tappable row with an icon and nothing under it is a button in all but name:
|
||||||
|
// MAPS.ME's "Let's go" and "Save".
|
||||||
|
val buttons = rows.filter {
|
||||||
|
it.onClickDelegate != null && it.toggle == null && it.image != null &&
|
||||||
|
it.texts.orEmpty().isEmpty() && it.title.text().length <= 24
|
||||||
|
}.takeIf { it.size in 1..4 && it.size < rows.size }.orEmpty()
|
||||||
|
// A first row that can't be tapped and has no icon is the place's name and kind.
|
||||||
|
val heading = rows.firstOrNull()?.takeIf {
|
||||||
|
buttons.isNotEmpty() && it.onClickDelegate == null && it.image == null && it.toggle == null
|
||||||
|
}
|
||||||
|
return PlaceCard(heading, buttons, rows - buttons.toSet() - listOfNotNull(heading).toSet())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PlaceHeading(row: CarRow) {
|
||||||
|
Column(Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 6.dp)) {
|
||||||
|
Text(row.title.text(), style = ChromeType.title, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||||
|
row.texts.orEmpty().take(2).forEach {
|
||||||
|
Text(it.text(), style = ChromeType.body, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Button-shaped rows side by side, the first one filled as the thing most drivers came to do. */
|
||||||
|
@Composable
|
||||||
|
private fun RowButtons(rows: List<CarRow>, modifier: Modifier) {
|
||||||
|
val scheme = MaterialTheme.colorScheme
|
||||||
|
Row(modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
rows.forEachIndexed { i, row ->
|
||||||
|
GsIconBox(
|
||||||
|
onClick = { row.onClickDelegate.click("row") },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
colors = if (i == 0) GsColors(scheme.primary, scheme.onPrimary) else GsColors(scheme.surfaceContainerHighest, scheme.onSurface),
|
||||||
|
enabled = row.isEnabled,
|
||||||
|
shape = RoundedCornerShape(24.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 14.dp),
|
||||||
|
horizontalArrangement = Arrangement.Center,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
CarGlyph(row.image, Modifier.size(24.dp))
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text(row.title.text(), style = ChromeType.body, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One fact about a place, tighter than a list row: a small label over its value, or just the
|
||||||
|
* value when the row has no label. A phone number gets a Call button that asks first.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun DetailRow(row: CarRow, placeName: String) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val texts = row.texts.orEmpty().map { it.text() }.filter { it.isNotBlank() }
|
||||||
|
val label = row.title.text().takeIf { texts.isNotEmpty() }
|
||||||
|
val values = texts.ifEmpty { listOf(row.title.text()) }
|
||||||
|
val number = values.firstOrNull { it.isPhoneNumber() }
|
||||||
|
val canCall = number != null &&
|
||||||
|
context.checkSelfPermission(android.Manifest.permission.CALL_PHONE) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.let { if (row.onClickDelegate != null && row.isEnabled) it.clickable { row.onClickDelegate.click("row") } else it }
|
||||||
|
.padding(horizontal = 14.dp, vertical = 6.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
row.image?.let {
|
||||||
|
CarGlyph(it, Modifier.size(24.dp))
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
}
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
label?.let {
|
||||||
|
Text(it, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1)
|
||||||
|
}
|
||||||
|
values.take(3).forEach {
|
||||||
|
Text(it, style = ChromeType.body, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (canCall) {
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
GsIconBox(
|
||||||
|
onClick = { CallPrompt.ask(placeName.ifBlank { number!! }, number!!) },
|
||||||
|
colors = GsColors(MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.onPrimary),
|
||||||
|
shape = RoundedCornerShape(24.dp),
|
||||||
|
) {
|
||||||
|
Row(Modifier.padding(horizontal = 16.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
androidx.compose.material3.Icon(Icons.Filled.Call, null, Modifier.size(20.dp))
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Text("Call", style = ChromeType.body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ActionRow(row.actions.orEmpty())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A line that is only a phone number: digits with the usual separators, 7 to 15 digits long. */
|
||||||
|
private fun String.isPhoneNumber(): Boolean {
|
||||||
|
val t = trim()
|
||||||
|
if (!Regex("""^\+?[0-9 ().\-]+$""").matches(t)) return false
|
||||||
|
return t.count { it.isDigit() } in 7..15
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun RowItem(row: CarRow, selection: RowSelection? = null, large: Boolean = false) {
|
||||||
|
val toggle = row.toggle
|
||||||
|
// Shown at once and corrected when the app answers with its own template, so the switch
|
||||||
|
// never lags a tap; keyed on what the app last said so its answer wins.
|
||||||
|
var checked by remember(toggle?.isChecked) { mutableStateOf(toggle?.isChecked == true) }
|
||||||
|
val clickable = row.isEnabled && (toggle != null || selection != null || row.onClickDelegate != null)
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.let {
|
||||||
|
if (!clickable) it else it.clickable {
|
||||||
|
when {
|
||||||
|
toggle != null -> {
|
||||||
|
checked = !checked
|
||||||
|
toggle.onCheckedChangeDelegate.checkedChanged(checked)
|
||||||
|
}
|
||||||
|
selection != null -> selection.onPick()
|
||||||
|
else -> row.onClickDelegate.click("row")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
if (selection != null) {
|
||||||
|
RadioButton(selected = selection.selected, onClick = null)
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
}
|
||||||
|
row.image?.let {
|
||||||
|
CarGlyph(it, Modifier.size(28.dp))
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
}
|
||||||
|
// large only changes how much room a row gets (more lines, a bigger icon above) - the
|
||||||
|
// type itself is the same scale as every other row, so a pane's one row and a plain
|
||||||
|
// list's tenth row read as the same kind of text.
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
row.title.text(),
|
||||||
|
style = ChromeType.title,
|
||||||
|
maxLines = if (large) 2 else 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
row.texts.orEmpty().take(if (large) 3 else 2).forEach { line ->
|
||||||
|
Text(
|
||||||
|
line.text(),
|
||||||
|
style = ChromeType.body,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = if (large) 2 else 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ActionRow(row.actions.orEmpty())
|
||||||
|
if (toggle != null) {
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Switch(checked = checked, onCheckedChange = null, enabled = row.isEnabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A row's place in a single-choice list: whether it is the chosen one, and what picking it does. */
|
||||||
|
internal class RowSelection(val selected: Boolean, val onPick: () -> Unit)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this colour stands out against the background that [content] was chosen for: at least
|
||||||
|
* 3:1, the WCAG minimum for icons. The background is taken as the opposite of the content colour.
|
||||||
|
*/
|
||||||
|
internal fun Color.readableOn(content: Color): Boolean {
|
||||||
|
val background = if (content.luminanceIsDark()) Color.White else Color(0xFF121212)
|
||||||
|
val a = luminance() + 0.05f
|
||||||
|
val b = background.luminance() + 0.05f
|
||||||
|
return maxOf(a, b) / minOf(a, b) >= 3f
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -24,6 +31,10 @@ 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
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
|
import androidx.compose.material.icons.filled.Home
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A browser inside the car UI. [content] is a URL, or raw HTML when it does not look like one.
|
* A browser inside the car UI. [content] is a URL, or raw HTML when it does not look like one.
|
||||||
@@ -43,11 +54,35 @@ fun WebApp(content: String) {
|
|||||||
val ring = String.format("#%06X", MaterialTheme.colorScheme.primary.toArgb() and 0xFFFFFF)
|
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
|
||||||
|
// What the header shows and what its buttons can do, refreshed as pages load.
|
||||||
|
var pageTitle by remember { mutableStateOf("") }
|
||||||
|
var canGoForward by remember { mutableStateOf(false) }
|
||||||
|
// The address being typed, or null while the header shows the page title.
|
||||||
|
var address by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
// Leaving the browser ends full screen, whoever started it.
|
||||||
|
DisposableEffect(Unit) { onDispose { CarFullscreen.exit() } }
|
||||||
|
// The way out of full screen also ends the page's.
|
||||||
|
LaunchedEffect(fullscreen) {
|
||||||
|
if (!fullscreen) pageFullscreen?.let { (_, callback) ->
|
||||||
|
pageFullscreen = null
|
||||||
|
callback.onCustomViewHidden()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
DisposableEffect(web) {
|
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 {
|
||||||
@@ -59,25 +94,57 @@ 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("Browser", Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
|
if (!fullscreen) {
|
||||||
|
ScreenHeader(pageTitle.ifEmpty { "Browser" }, Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
|
||||||
|
GsIconButton(androidx.compose.material.icons.Icons.Filled.Search, "Search or type an address", {
|
||||||
|
fieldText = null
|
||||||
|
address = ""
|
||||||
|
})
|
||||||
|
if (canGoForward) {
|
||||||
|
GsIconButton(androidx.compose.material.icons.Icons.AutoMirrored.Filled.ArrowForward, "Forward", { web?.goForward() })
|
||||||
|
}
|
||||||
|
GsIconButton(androidx.compose.material.icons.Icons.Filled.Refresh, "Reload", { web?.reload() })
|
||||||
|
GsIconButton(androidx.compose.material.icons.Icons.Filled.Home, "Start page", {
|
||||||
|
web?.let { view -> loadContent(view, content) }
|
||||||
|
})
|
||||||
|
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(ring)
|
webViewClient = LoggingClient(ring) { view ->
|
||||||
|
canGoForward = view.canGoForward()
|
||||||
|
pageTitle = view.title.orEmpty().takeUnless { it.startsWith("http") || it == "about:blank" }.orEmpty()
|
||||||
|
}
|
||||||
|
webChromeClient = object : WebChromeClient() {
|
||||||
|
override fun onReceivedTitle(view: WebView?, title: String?) {
|
||||||
|
pageTitle = title.orEmpty().takeUnless { it.startsWith("http") || it == "about:blank" }.orEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onShowCustomView(view: View, callback: CustomViewCallback) {
|
||||||
|
videoMadeFullscreen = !CarFullscreen.active
|
||||||
|
pageFullscreen = view to callback
|
||||||
|
CarFullscreen.enter()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onHideCustomView() {
|
||||||
|
if (pageFullscreen == null) return
|
||||||
|
pageFullscreen = null
|
||||||
|
if (videoMadeFullscreen) CarFullscreen.exit()
|
||||||
|
}
|
||||||
|
}
|
||||||
addJavascriptInterface(
|
addJavascriptInterface(
|
||||||
FieldFocusBridge(onFocus = { fieldText = it }, onBlur = { fieldText = null }),
|
FieldFocusBridge(onFocus = { fieldText = it }, onBlur = { fieldText = null }),
|
||||||
"GearslipField",
|
"GearslipField",
|
||||||
)
|
)
|
||||||
if (content.startsWith("http://") || content.startsWith("https://")) {
|
loadContent(this, content)
|
||||||
loadUrl(content)
|
|
||||||
} else {
|
|
||||||
loadDataWithBaseURL(null, content, "text/html", "utf-8", null)
|
|
||||||
}
|
|
||||||
web = this
|
web = this
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -85,6 +152,33 @@ 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() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
address?.let { typed ->
|
||||||
|
CarKeyboard(
|
||||||
|
text = typed,
|
||||||
|
onTextChange = { address = it },
|
||||||
|
onSubmit = {
|
||||||
|
web?.loadUrl(addressToUrl(typed))
|
||||||
|
address = null
|
||||||
|
},
|
||||||
|
onDismiss = { address = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
fieldText?.let { text ->
|
fieldText?.let { text ->
|
||||||
CarKeyboard(
|
CarKeyboard(
|
||||||
text = text,
|
text = text,
|
||||||
@@ -102,9 +196,29 @@ fun WebApp(content: String) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private class LoggingClient(private val ring: String) : WebViewClient() {
|
/** The start page: a web address, or a page of HTML Gearslip made itself. */
|
||||||
|
private fun loadContent(view: WebView, content: String) {
|
||||||
|
if (content.startsWith("http://") || content.startsWith("https://")) {
|
||||||
|
view.loadUrl(content)
|
||||||
|
} else {
|
||||||
|
view.loadDataWithBaseURL(null, content, "text/html", "utf-8", null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Something that looks like an address opens as one; anything else is searched for. */
|
||||||
|
private fun addressToUrl(typed: String): String {
|
||||||
|
val text = typed.trim()
|
||||||
|
return when {
|
||||||
|
text.startsWith("http://") || text.startsWith("https://") -> text
|
||||||
|
' ' !in text && '.' in text -> "https://$text"
|
||||||
|
else -> "https://duckduckgo.com/?q=" + java.net.URLEncoder.encode(text, "UTF-8")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class LoggingClient(private val ring: String, private val onLoaded: (WebView) -> Unit) : WebViewClient() {
|
||||||
override fun onPageFinished(view: WebView?, url: String?) {
|
override fun onPageFinished(view: WebView?, url: String?) {
|
||||||
GearslipLog.i("car web page loaded: $url")
|
GearslipLog.i("car web page loaded: $url")
|
||||||
|
view?.let(onLoaded)
|
||||||
view?.evaluateJavascript(FOCUS_TRACKING_JS, null)
|
view?.evaluateJavascript(FOCUS_TRACKING_JS, null)
|
||||||
view?.evaluateJavascript(focusRingJs(ring), null)
|
view?.evaluateJavascript(focusRingJs(ring), null)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package app.seb3thehacker.gearslip.car.theme
|
||||||
|
|
||||||
|
import androidx.compose.material3.ColorScheme
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.compositeOver
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The phone's Material You colours, with its accent washed over the surfaces. Material You's own
|
||||||
|
* surfaces are nearly neutral, which reads as flat grey on a car screen; tinting them with the
|
||||||
|
* phone's primary colour (purple on a purple phone) gives the whole UI the phone's hue, the way
|
||||||
|
* Material 3's surface tint does, and it changes with the wallpaper like everything else.
|
||||||
|
* Higher containers take more of the tint, so cards still stand off the background.
|
||||||
|
*/
|
||||||
|
fun ColorScheme.withTintedSurfaces(dark: Boolean): ColorScheme {
|
||||||
|
val base = if (dark) 0.03f else 0.02f
|
||||||
|
fun tint(own: Color, step: Int) = primary.copy(alpha = base + step * (if (dark) 0.015f else 0.01f)).compositeOver(own)
|
||||||
|
return copy(
|
||||||
|
background = tint(background, 0),
|
||||||
|
surface = tint(surface, 0),
|
||||||
|
surfaceDim = tint(surfaceDim, 0),
|
||||||
|
surfaceContainerLowest = tint(surfaceContainerLowest, 0),
|
||||||
|
surfaceContainerLow = tint(surfaceContainerLow, 1),
|
||||||
|
surfaceContainer = tint(surfaceContainer, 2),
|
||||||
|
surfaceContainerHigh = tint(surfaceContainerHigh, 3),
|
||||||
|
surfaceContainerHighest = tint(surfaceContainerHighest, 4),
|
||||||
|
surfaceBright = tint(surfaceBright, 4),
|
||||||
|
surfaceVariant = tint(surfaceVariant, 3),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -13,6 +13,9 @@ class TemplateApp(
|
|||||||
) {
|
) {
|
||||||
val isNavigation: Boolean get() = CarAppService.CATEGORY_NAVIGATION_APP in categories
|
val isNavigation: Boolean get() = CarAppService.CATEGORY_NAVIGATION_APP in categories
|
||||||
|
|
||||||
|
/** A media car screen, or one that names no category (older media apps). */
|
||||||
|
val isMediaScreen: Boolean get() = kind == "media" || kind == "app"
|
||||||
|
|
||||||
/** "navigation", "media", ... - what the app told the system it is. */
|
/** "navigation", "media", ... - what the app told the system it is. */
|
||||||
val kind: String
|
val kind: String
|
||||||
get() = categories.firstOrNull { it.startsWith(CATEGORY_PREFIX) }
|
get() = categories.firstOrNull { it.startsWith(CATEGORY_PREFIX) }
|
||||||
|
|||||||
@@ -91,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
|
||||||
@@ -223,6 +226,7 @@ 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()
|
clearToasts()
|
||||||
lent?.let { sendSurfaceDestroyed(it) }
|
lent?.let { sendSurfaceDestroyed(it) }
|
||||||
lent = null
|
lent = null
|
||||||
@@ -254,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}")
|
||||||
@@ -271,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} " +
|
||||||
@@ -385,6 +403,8 @@ class CarAppConnection(private val context: Context) {
|
|||||||
// failure worth surfacing.
|
// failure worth surfacing.
|
||||||
runCatching {
|
runCatching {
|
||||||
app.getManager(CarContext.NAVIGATION_SERVICE, object : IOnDoneCallback.Stub() {
|
app.getManager(CarContext.NAVIGATION_SERVICE, object : IOnDoneCallback.Stub() {
|
||||||
|
// Car App Library 1.8 versions its host interfaces; each answers with the version it was built from.
|
||||||
|
override fun getInterfaceVersion(): Int = IOnDoneCallback.VERSION
|
||||||
override fun onSuccess(response: Bundleable?) {
|
override fun onSuccess(response: Bundleable?) {
|
||||||
val value = response?.let { runCatching { it.get() }.getOrNull() }
|
val value = response?.let { runCatching { it.get() }.getOrNull() }
|
||||||
navigationManager = when (value) {
|
navigationManager = when (value) {
|
||||||
@@ -552,9 +572,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"
|
||||||
@@ -575,6 +597,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")) }
|
||||||
@@ -605,6 +628,7 @@ class CarAppConnection(private val context: Context) {
|
|||||||
// --- host interfaces the app calls back into -----------------------------------------
|
// --- host interfaces the app calls back into -----------------------------------------
|
||||||
|
|
||||||
private fun createAppHost(owner: Any) = object : IAppHost.Stub() {
|
private fun createAppHost(owner: Any) = object : IAppHost.Stub() {
|
||||||
|
override fun getInterfaceVersion(): Int = IAppHost.VERSION
|
||||||
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() }
|
||||||
@@ -643,6 +667,7 @@ class CarAppConnection(private val context: Context) {
|
|||||||
val trip: StateFlow<androidx.car.app.navigation.model.Trip?> = _trip.asStateFlow()
|
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 getInterfaceVersion(): Int = INavigationHost.VERSION
|
||||||
override fun navigationStarted() {
|
override fun navigationStarted() {
|
||||||
GearslipLog.i("host: navigation started")
|
GearslipLog.i("host: navigation started")
|
||||||
navigating = true
|
navigating = true
|
||||||
@@ -670,6 +695,7 @@ class CarAppConnection(private val context: Context) {
|
|||||||
* IBinder must be a real IOnDoneCallback - the app dispatches the reply through it.
|
* IBinder must be a real IOnDoneCallback - the app dispatches the reply through it.
|
||||||
*/
|
*/
|
||||||
private val hardwareHost = object : ICarHardwareHost.Stub() {
|
private val hardwareHost = object : ICarHardwareHost.Stub() {
|
||||||
|
override fun getInterfaceVersion(): Int = ICarHardwareHost.VERSION
|
||||||
override fun getCarHardwareResult(
|
override fun getCarHardwareResult(
|
||||||
type: Int,
|
type: Int,
|
||||||
params: Bundleable?,
|
params: Bundleable?,
|
||||||
@@ -700,6 +726,7 @@ class CarAppConnection(private val context: Context) {
|
|||||||
* Library documents for a projected head unit; they are about the screen, not about us.
|
* Library documents for a projected head unit; they are about the screen, not about us.
|
||||||
*/
|
*/
|
||||||
private val constraintHost = object : IConstraintHost.Stub() {
|
private val constraintHost = object : IConstraintHost.Stub() {
|
||||||
|
override fun getInterfaceVersion(): Int = IConstraintHost.VERSION
|
||||||
override fun getContentLimit(type: Int): Int = when (type) {
|
override fun getContentLimit(type: Int): Int = when (type) {
|
||||||
ConstraintManager.CONTENT_LIMIT_TYPE_GRID -> 12
|
ConstraintManager.CONTENT_LIMIT_TYPE_GRID -> 12
|
||||||
ConstraintManager.CONTENT_LIMIT_TYPE_PANE -> 4
|
ConstraintManager.CONTENT_LIMIT_TYPE_PANE -> 4
|
||||||
@@ -718,10 +745,12 @@ class CarAppConnection(private val context: Context) {
|
|||||||
* that pushes suggestions shouldn't fail because its host doesn't surface them.
|
* that pushes suggestions shouldn't fail because its host doesn't surface them.
|
||||||
*/
|
*/
|
||||||
private val suggestionHost = object : ISuggestionHost.Stub() {
|
private val suggestionHost = object : ISuggestionHost.Stub() {
|
||||||
|
override fun getInterfaceVersion(): Int = ISuggestionHost.VERSION
|
||||||
override fun updateSuggestions(suggestions: Bundleable?) = Unit
|
override fun updateSuggestions(suggestions: Bundleable?) = Unit
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createCarHost(owner: Any) = object : ICarHost.Stub() {
|
private fun createCarHost(owner: Any) = object : ICarHost.Stub() {
|
||||||
|
override fun getInterfaceVersion(): Int = ICarHost.VERSION
|
||||||
private val appHost = createAppHost(owner)
|
private val appHost = createAppHost(owner)
|
||||||
|
|
||||||
override fun getHost(type: String?): IBinder? = when (type) {
|
override fun getHost(type: String?): IBinder? = when (type) {
|
||||||
@@ -754,6 +783,7 @@ class CarAppConnection(private val context: Context) {
|
|||||||
*/
|
*/
|
||||||
private fun call(name: String, onValue: (Any?) -> Unit, send: (IOnDoneCallback) -> Unit) {
|
private fun call(name: String, onValue: (Any?) -> Unit, send: (IOnDoneCallback) -> Unit) {
|
||||||
val callback = object : IOnDoneCallback.Stub() {
|
val callback = object : IOnDoneCallback.Stub() {
|
||||||
|
override fun getInterfaceVersion(): Int = IOnDoneCallback.VERSION
|
||||||
override fun onSuccess(response: Bundleable?) {
|
override fun onSuccess(response: Bundleable?) {
|
||||||
val value = response?.let { runCatching { it.get() }.getOrNull() }
|
val value = response?.let { runCatching { it.get() }.getOrNull() }
|
||||||
main.post { onValue(value) }
|
main.post { onValue(value) }
|
||||||
@@ -769,6 +799,7 @@ class CarAppConnection(private val context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun noop(name: String) = object : IOnDoneCallback.Stub() {
|
private fun noop(name: String) = object : IOnDoneCallback.Stub() {
|
||||||
|
override fun getInterfaceVersion(): Int = IOnDoneCallback.VERSION
|
||||||
override fun onSuccess(response: Bundleable?) = Unit
|
override fun onSuccess(response: Bundleable?) = Unit
|
||||||
override fun onFailure(response: Bundleable?) = GearslipLog.w("host: $name failed")
|
override fun onFailure(response: Bundleable?) = GearslipLog.w("host: $name failed")
|
||||||
}
|
}
|
||||||
@@ -833,6 +864,9 @@ class CarAppConnection(private val context: Context) {
|
|||||||
/** How long a new destination waits for the old route to end; see [navigateTo]. */
|
/** How long a new destination waits for the old route to end; see [navigateTo]. */
|
||||||
const val REROUTE_WAIT_MS = 1_500L
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,41 @@ import kotlin.math.roundToInt
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/** The app's text, preferring the longest variant that will fit; variants are ordered longest first. */
|
/** The app's text, preferring the longest variant that will fit; variants are ordered longest first. */
|
||||||
fun CarText?.text(): String = this?.takeIf { !it.isEmpty }?.toCharSequence()?.toString().orEmpty()
|
fun CarText?.text(): String {
|
||||||
|
val chars = this?.takeIf { !it.isEmpty }?.toCharSequence() ?: return ""
|
||||||
|
return chars.withSpansFilledIn()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apps write a route's time and length as spans over placeholder characters ("\u0000 · \u0000"
|
||||||
|
* with a DurationSpan and a DistanceSpan), leaving the host to format them. Plain toString()
|
||||||
|
* keeps only the placeholders, which is how a route option ended up reading "·".
|
||||||
|
*/
|
||||||
|
private fun CharSequence.withSpansFilledIn(): String {
|
||||||
|
if (this !is android.text.Spanned) return toString()
|
||||||
|
val spans = getSpans(0, length, androidx.car.app.model.CarSpan::class.java)
|
||||||
|
.filter { it is androidx.car.app.model.DurationSpan || it is androidx.car.app.model.DistanceSpan }
|
||||||
|
.sortedBy { getSpanStart(it) }
|
||||||
|
if (spans.isEmpty()) return toString()
|
||||||
|
val out = StringBuilder()
|
||||||
|
var at = 0
|
||||||
|
spans.forEach { span ->
|
||||||
|
val start = getSpanStart(span)
|
||||||
|
val end = getSpanEnd(span)
|
||||||
|
if (start < at || end > length) return@forEach
|
||||||
|
out.append(this, at, start)
|
||||||
|
out.append(
|
||||||
|
when (span) {
|
||||||
|
is androidx.car.app.model.DurationSpan -> remainingTime(span.durationSeconds)
|
||||||
|
is androidx.car.app.model.DistanceSpan -> span.distance.display()
|
||||||
|
else -> ""
|
||||||
|
},
|
||||||
|
)
|
||||||
|
at = end
|
||||||
|
}
|
||||||
|
out.append(this, at, length)
|
||||||
|
return out.toString().replace("\u0000", "").trim()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Icons keep coming back under fresh [CarIcon]/IconCompat objects - every template refetch
|
* Icons keep coming back under fresh [CarIcon]/IconCompat objects - every template refetch
|
||||||
@@ -53,7 +87,7 @@ private val iconCache = android.util.LruCache<String, ImageBitmap>(200)
|
|||||||
fun CarIcon?.image(context: Context): ImageBitmap? {
|
fun CarIcon?.image(context: Context): ImageBitmap? {
|
||||||
val icon = this?.icon ?: return null
|
val icon = this?.icon ?: return null
|
||||||
val key = icon.cacheKey(context)
|
val key = icon.cacheKey(context)
|
||||||
iconCache.get(key)?.let { return it }
|
key?.let { k -> iconCache.get(k)?.let { return it } }
|
||||||
val drawable: Drawable = runCatching { icon.loadDrawable(context) }
|
val drawable: Drawable = runCatching { icon.loadDrawable(context) }
|
||||||
.onFailure { GearslipLog.w("icon: loadDrawable threw for ${icon.describe()}: ${it.javaClass.simpleName}: ${it.message}") }
|
.onFailure { GearslipLog.w("icon: loadDrawable threw for ${icon.describe()}: ${it.javaClass.simpleName}: ${it.message}") }
|
||||||
.getOrNull()
|
.getOrNull()
|
||||||
@@ -73,7 +107,7 @@ fun CarIcon?.image(context: Context): ImageBitmap? {
|
|||||||
val bitmap = runCatching { drawable.toBitmap(width, height).asImageBitmap() }
|
val bitmap = runCatching { drawable.toBitmap(width, height).asImageBitmap() }
|
||||||
.onFailure { GearslipLog.w("icon: toBitmap threw for ${icon.describe()}: ${it.javaClass.simpleName}: ${it.message}") }
|
.onFailure { GearslipLog.w("icon: toBitmap threw for ${icon.describe()}: ${it.javaClass.simpleName}: ${it.message}") }
|
||||||
.getOrNull() ?: return null
|
.getOrNull() ?: return null
|
||||||
iconCache.put(key, bitmap)
|
key?.let { iconCache.put(it, bitmap) }
|
||||||
return bitmap
|
return bitmap
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,7 +117,15 @@ fun CarIcon?.image(context: Context): ImageBitmap? {
|
|||||||
* so a cache keyed on the bare id kept drawing the old build's picture on the wrong button (Vela's
|
* so a cache keyed on the bare id kept drawing the old build's picture on the wrong button (Vela's
|
||||||
* new Saved button came up as its search glyph).
|
* new Saved button came up as its search glyph).
|
||||||
*/
|
*/
|
||||||
private fun androidx.core.graphics.drawable.IconCompat.cacheKey(context: Context): String {
|
/**
|
||||||
|
* Null for an icon that carries its own pixels: two different bitmaps describe the same, so a
|
||||||
|
* key would hand one app's turn arrow the picture of whatever bitmap was cached first.
|
||||||
|
*/
|
||||||
|
private fun androidx.core.graphics.drawable.IconCompat.cacheKey(context: Context): String? {
|
||||||
|
if (type == androidx.core.graphics.drawable.IconCompat.TYPE_BITMAP ||
|
||||||
|
type == androidx.core.graphics.drawable.IconCompat.TYPE_ADAPTIVE_BITMAP ||
|
||||||
|
type == androidx.core.graphics.drawable.IconCompat.TYPE_DATA
|
||||||
|
) return null
|
||||||
if (type != androidx.core.graphics.drawable.IconCompat.TYPE_RESOURCE) return describe()
|
if (type != androidx.core.graphics.drawable.IconCompat.TYPE_RESOURCE) return describe()
|
||||||
val installed = runCatching { context.packageManager.getPackageInfo(resPackage, 0).lastUpdateTime }.getOrDefault(0L)
|
val installed = runCatching { context.packageManager.getPackageInfo(resPackage, 0).lastUpdateTime }.getOrDefault(0L)
|
||||||
return "${describe()}@$installed"
|
return "${describe()}@$installed"
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -20,6 +24,7 @@ object KnownApps {
|
|||||||
"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.mapquest.android.ace", // MapQuest: map, routing, navigation
|
||||||
"com.mapswithme.maps.pro", // MAPS.ME: map, search, place pages, navigation
|
"com.mapswithme.maps.pro", // MAPS.ME: map, search, place pages, navigation
|
||||||
|
"app.vela", // Vela Maps: map, search, route options, turn-by-turn with lanes
|
||||||
)
|
)
|
||||||
|
|
||||||
fun works(packageName: String): Boolean = packageName in working
|
fun works(packageName: String): Boolean = packageName in working
|
||||||
@@ -65,14 +70,36 @@ 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
|
||||||
* call working. Wins over [works] and [playerWorks] for the badge.
|
* call working. Wins over [works] and [playerWorks] for the badge.
|
||||||
*/
|
*/
|
||||||
private val partial = setOf(
|
private val partial = setOf(
|
||||||
"app.vela", // Vela Maps: connects and draws, but rough enough not to call it working yet
|
|
||||||
"com.spotify.music", // Spotify: plays through Gearslip's player, but the app has to be opened on the phone first
|
"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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ class CarMediaStatus(
|
|||||||
fun onMessage(messageId: Int, body: ByteArray) {
|
fun onMessage(messageId: Int, body: ByteArray) {
|
||||||
when (messageId) {
|
when (messageId) {
|
||||||
MSG_CHANNEL_OPEN_RESPONSE -> {
|
MSG_CHANNEL_OPEN_RESPONSE -> {
|
||||||
val status = Protobuf.readInt32Field(body, 1) ?: 0
|
val status = Protobuf.readInt32Field(body, 1)
|
||||||
GearslipLog.i("media status: <- ChannelOpenResponse: status=$status")
|
GearslipLog.i("media status: <- ChannelOpenResponse: status=$status")
|
||||||
if (status == 0) start()
|
if (status == 0) start()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package app.seb3thehacker.gearslip.protocol
|
||||||
|
|
||||||
|
import app.seb3thehacker.gearslip.TouchPoint
|
||||||
|
import app.seb3thehacker.gearslip.Wire
|
||||||
|
|
||||||
|
/** Decodes the touch coordinates and pointer index carried by an InputReport. */
|
||||||
|
internal object InputProtocol {
|
||||||
|
data class Touch(val action: Int, val actionIndex: Int, val points: List<TouchPoint>)
|
||||||
|
|
||||||
|
/** [shiftX] and [shiftY] move each point by the margin the car crops off before measuring. */
|
||||||
|
fun touch(body: ByteArray, size: Pair<Int, Int>?, shiftX: Int = 0, shiftY: Int = 0): Touch? {
|
||||||
|
val fields = Wire.fields(body)
|
||||||
|
val action = Wire.varint(fields, 3)?.toInt() ?: 0
|
||||||
|
if (action !in setOf(0, 1, 2, 5, 6)) return null
|
||||||
|
val pointers = Wire.allBytes(fields, 1)
|
||||||
|
if (pointers.isEmpty() || pointers.size > 32) return null
|
||||||
|
val actionIndex = Wire.varint(fields, 2)?.toInt() ?: 0
|
||||||
|
if (actionIndex !in pointers.indices) return null
|
||||||
|
val points = pointers.map { pointer ->
|
||||||
|
val values = Wire.fields(pointer)
|
||||||
|
val x = Wire.varint(values, 1)?.toInt() ?: return null
|
||||||
|
val y = Wire.varint(values, 2)?.toInt() ?: return null
|
||||||
|
val id = Wire.varint(values, 3) ?: return null
|
||||||
|
if (id !in 0L..31L) return null
|
||||||
|
TouchPoint(id.toInt(), clamp(x + shiftX, size?.first), clamp(y + shiftY, size?.second))
|
||||||
|
}
|
||||||
|
if (points.map { it.id }.toSet().size != points.size) return null
|
||||||
|
return Touch(action, actionIndex, points)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clamp(value: Int, limit: Int?): Float = when {
|
||||||
|
limit == null -> value.toFloat()
|
||||||
|
limit <= 0 -> 0f
|
||||||
|
else -> value.coerceIn(0, limit - 1).toFloat()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,16 +18,14 @@ import java.util.concurrent.Executors
|
|||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import org.apache.commons.compress.archivers.tar.TarArchiveInputStream
|
|
||||||
import org.apache.commons.compress.compressors.bzip2.BZip2CompressorInputStream
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The speech models Gearslip listens with, in two roles: a streaming one that writes the words
|
* The speech models Gearslip listens with, in two roles: a streaming one that writes the words
|
||||||
* while the driver talks, and an optional final one that reads the whole phrase again once they
|
* while the driver talks, and an optional final one that reads the whole phrase again once they
|
||||||
* stop, for a more accurate result. None ships in the APK: each is fetched once and kept in the
|
* stop, for a more accurate result. None ships in the APK: each is fetched once and kept in the
|
||||||
* app's own storage. sherpa-onnx's author publishes them twice: as .tar.bz2 archives on GitHub and
|
* app's own storage. sherpa-onnx's author publishes them twice: as .tar.bz2 archives on GitHub and
|
||||||
* as plain files on Hugging Face. The plain files come first where they exist. Unpacking bzip2 on
|
* as plain files on Hugging Face. Gearslip takes the plain files: unpacking bzip2 on the phone
|
||||||
* the phone pinned a core and held a download to 150 KB/s on Wi-Fi that does 19 MB/s.
|
* pinned a core and held a download to 150 KB/s on Wi-Fi that does 19 MB/s.
|
||||||
*/
|
*/
|
||||||
object SpeechModels {
|
object SpeechModels {
|
||||||
|
|
||||||
@@ -45,8 +43,6 @@ object SpeechModels {
|
|||||||
/** Empty for Canary, which decodes with attention and has no joiner. */
|
/** Empty for Canary, which decodes with attention and has no joiner. */
|
||||||
val joiner: String,
|
val joiner: String,
|
||||||
val modelType: String = "",
|
val modelType: String = "",
|
||||||
/** Whether Hugging Face has the files unpacked; otherwise the archive is unpacked on the phone, slowly. */
|
|
||||||
val plainFiles: Boolean = true,
|
|
||||||
/** Where the unpacked files are, when not under the archive's own name on Hugging Face. */
|
/** Where the unpacked files are, when not under the archive's own name on Hugging Face. */
|
||||||
private val filesAt: String? = null,
|
private val filesAt: String? = null,
|
||||||
) {
|
) {
|
||||||
@@ -75,7 +71,6 @@ object SpeechModels {
|
|||||||
),
|
),
|
||||||
;
|
;
|
||||||
|
|
||||||
val url get() = "https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/$archive.tar.bz2"
|
|
||||||
fun fileUrl(name: String) = "https://huggingface.co/${filesAt ?: "csukuangfj/$archive/resolve/main"}/$name"
|
fun fileUrl(name: String) = "https://huggingface.co/${filesAt ?: "csukuangfj/$archive/resolve/main"}/$name"
|
||||||
val files get() = listOf(encoder, decoder, joiner, "tokens.txt").filter { it.isNotEmpty() }
|
val files get() = listOf(encoder, decoder, joiner, "tokens.txt").filter { it.isNotEmpty() }
|
||||||
}
|
}
|
||||||
@@ -215,7 +210,7 @@ object SpeechModels {
|
|||||||
if (model == _selected.value || model == _final.value) SpeechEngine.unload()
|
if (model == _selected.value || model == _final.value) SpeechEngine.unload()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fetches just the files the recognizer reads, plain where it can, else out of the archive. */
|
/** Fetches just the files the recognizer reads. */
|
||||||
fun download(context: Context, model: Model) {
|
fun download(context: Context, model: Model) {
|
||||||
val app = context.applicationContext
|
val app = context.applicationContext
|
||||||
val state = states.getValue(model)
|
val state = states.getValue(model)
|
||||||
@@ -235,7 +230,6 @@ object SpeechModels {
|
|||||||
state.value = State.Downloading((((done + read) * 100) / total).toInt().coerceIn(0, 99))
|
state.value = State.Downloading((((done + read) * 100) / total).toInt().coerceIn(0, 99))
|
||||||
updateProgress(app)
|
updateProgress(app)
|
||||||
}
|
}
|
||||||
if (model.plainFiles) {
|
|
||||||
for (name in model.files) {
|
for (name in model.files) {
|
||||||
val conn = open(model.fileUrl(name))
|
val conn = open(model.fileUrl(name))
|
||||||
val length = conn.contentLengthLong
|
val length = conn.contentLengthLong
|
||||||
@@ -246,19 +240,7 @@ object SpeechModels {
|
|||||||
check(length <= 0 || file.length() == length) { "$name arrived cut short" }
|
check(length <= 0 || file.length() == length) { "$name arrived cut short" }
|
||||||
done += file.length()
|
done += file.length()
|
||||||
}
|
}
|
||||||
} else {
|
check(model.files.all { File(partial, it).isFile }) { "some files were missing" }
|
||||||
val conn = open(model.url)
|
|
||||||
val counting = CountingStream(conn.inputStream, ::progress)
|
|
||||||
TarArchiveInputStream(BZip2CompressorInputStream(counting.buffered())).use { tar ->
|
|
||||||
while (true) {
|
|
||||||
val entry = tar.nextEntry ?: break
|
|
||||||
val name = entry.name.substringAfterLast('/')
|
|
||||||
if (entry.isDirectory || name !in model.files) continue
|
|
||||||
File(partial, name).outputStream().use { tar.copyTo(it) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
check(model.files.all { File(partial, it).isFile }) { "the archive was missing files" }
|
|
||||||
target.deleteRecursively()
|
target.deleteRecursively()
|
||||||
check(partial.renameTo(target)) { "couldn't store the model" }
|
check(partial.renameTo(target)) { "couldn't store the model" }
|
||||||
GearslipLog.i("speech: downloaded ${model.label}")
|
GearslipLog.i("speech: downloaded ${model.label}")
|
||||||
|
|||||||
@@ -251,6 +251,17 @@ object UsageStats {
|
|||||||
if (c.phone) put("phone", "${Build.MANUFACTURER} ${Build.MODEL}")
|
if (c.phone) put("phone", "${Build.MANUFACTURER} ${Build.MODEL}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var signerCache: String? = null
|
||||||
|
|
||||||
|
/** The first 16 hex digits of the SHA-256 of this APK's signing certificate. */
|
||||||
|
private fun signer(context: Context): String = signerCache ?: runCatching {
|
||||||
|
val cert = context.packageManager
|
||||||
|
.getPackageInfo(context.packageName, android.content.pm.PackageManager.GET_SIGNING_CERTIFICATES)
|
||||||
|
.signingInfo?.apkContentsSigners?.firstOrNull()?.toByteArray() ?: return@runCatching ""
|
||||||
|
java.security.MessageDigest.getInstance("SHA-256").digest(cert)
|
||||||
|
.take(8).joinToString("") { "%02x".format(it) }
|
||||||
|
}.getOrDefault("").also { signerCache = it }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What a note carries. With [everything], every field the driver shares, as Settings shows it;
|
* What a note carries. With [everything], every field the driver shares, as Settings shows it;
|
||||||
* otherwise exactly what the next note would send, which leaves out unchanged phone details.
|
* otherwise exactly what the next note would send, which leaves out unchanged phone details.
|
||||||
@@ -263,6 +274,9 @@ object UsageStats {
|
|||||||
.put("version", BuildConfig.VERSION_NAME)
|
.put("version", BuildConfig.VERSION_NAME)
|
||||||
.put("code", BuildConfig.VERSION_CODE)
|
.put("code", BuildConfig.VERSION_CODE)
|
||||||
.put("build", if (BuildConfig.DEBUG) "debug" else "release")
|
.put("build", if (BuildConfig.DEBUG) "debug" else "release")
|
||||||
|
// Who signed this copy, so a fork's builds can be told from Gearslip's own. Every copy
|
||||||
|
// from the same publisher sends the same value, so it says nothing about the phone.
|
||||||
|
.put("signer", signer(context))
|
||||||
// Which boxes are ticked, so the server knows what this phone shares and can drop
|
// Which boxes are ticked, so the server knows what this phone shares and can drop
|
||||||
// anything it kept from a box since unticked.
|
// anything it kept from a box since unticked.
|
||||||
.put("shares", JSONArray().apply {
|
.put("shares", JSONArray().apply {
|
||||||
|
|||||||
@@ -35,15 +35,19 @@ import androidx.compose.ui.platform.LocalContext
|
|||||||
|
|
||||||
/** Where Gearslip lives outside the app: support, the chat room, and the code. */
|
/** Where Gearslip lives outside the app: support, the chat room, and the code. */
|
||||||
internal object GearslipLinks {
|
internal object GearslipLinks {
|
||||||
|
const val BUY_ME_A_COFFEE = "https://buymeacoffee.com/seb3thehacker"
|
||||||
const val LIBERAPAY = "https://liberapay.com/Seb3thehacker"
|
const val LIBERAPAY = "https://liberapay.com/Seb3thehacker"
|
||||||
/** A card payment page for giving once, any amount. Its row stays hidden while this is empty. */
|
/** A card payment page for giving once, any amount. Its row stays hidden while this is empty. */
|
||||||
const val PAYMENT_LINK = ""
|
const val PAYMENT_LINK = ""
|
||||||
/** Gearslip's Monero address. Its row stays hidden while this is empty. */
|
/** Gearslip's Monero address. Its row stays hidden while this is empty. */
|
||||||
const val MONERO = "44gTVxQJY3cirBJ41J5i1bPUVnSkTMhn9esSp1kg9MHwJ3TUz8tkkigUTKwiok5zA5RZvNxyM4pqkAfPs79pPLfaNG21pCn"
|
const val MONERO = "44gTVxQJY3cirBJ41J5i1bPUVnSkTMhn9esSp1kg9MHwJ3TUz8tkkigUTKwiok5zA5RZvNxyM4pqkAfPs79pPLfaNG21pCn"
|
||||||
const val MATRIX = "https://matrix.to/#/!mf0_mgBd5KqKpc9FfX_wHp1jFCcwAtPrndIn_oGIyMk?via=matrix.org"
|
const val MATRIX = "https://matrix.to/#/!mf0_mgBd5KqKpc9FfX_wHp1jFCcwAtPrndIn_oGIyMk?via=matrix.org"
|
||||||
const val SOURCE = "https://github.com/Seb3thehacker/gearslip"
|
const val SOURCE = "https://github.com/twinksoftware/gearslip"
|
||||||
const val CARS = "$SOURCE/blob/main/CARS.md"
|
const val CARS = "$SOURCE/blob/main/CARS.md"
|
||||||
|
|
||||||
|
/** This version's release notes on GitHub; every release is tagged v plus its version name. */
|
||||||
|
val RELEASE_NOTES = "$SOURCE/releases/tag/v${app.seb3thehacker.gearslip.BuildConfig.VERSION_NAME}"
|
||||||
|
|
||||||
fun open(context: Context, url: String) {
|
fun open(context: Context, url: String) {
|
||||||
runCatching {
|
runCatching {
|
||||||
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||||
@@ -66,7 +70,7 @@ internal object GearslipLinks {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The ways to support Gearslip. Liberapay always; card and Monero once they're set up. */
|
/** The ways to support Gearslip: Liberapay first, Monero last, card once it's set up. */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun DonateRows() {
|
internal fun DonateRows() {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@@ -84,6 +88,14 @@ internal fun DonateRows() {
|
|||||||
icon = Icons.Filled.Favorite,
|
icon = Icons.Filled.Favorite,
|
||||||
trailing = arrow,
|
trailing = arrow,
|
||||||
)
|
)
|
||||||
|
SettingsDivider()
|
||||||
|
SettingsRow(
|
||||||
|
"Buy Me a Coffee",
|
||||||
|
Modifier.clickable { GearslipLinks.open(context, GearslipLinks.BUY_ME_A_COFFEE) },
|
||||||
|
subtitle = "Give once or monthly, by card. Buy Me a Coffee keeps 5%",
|
||||||
|
icon = Icons.Filled.Favorite,
|
||||||
|
trailing = arrow,
|
||||||
|
)
|
||||||
if (GearslipLinks.PAYMENT_LINK.isNotEmpty()) {
|
if (GearslipLinks.PAYMENT_LINK.isNotEmpty()) {
|
||||||
SettingsDivider()
|
SettingsDivider()
|
||||||
SettingsRow(
|
SettingsRow(
|
||||||
@@ -110,7 +122,7 @@ internal fun DonateRows() {
|
|||||||
|
|
||||||
/** The Monero address in full, with ways to pay it from this phone or another device. */
|
/** The Monero address in full, with ways to pay it from this phone or another device. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun MoneroDialog(onDismiss: () -> Unit) {
|
internal fun MoneroDialog(onDismiss: () -> Unit) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
|
|||||||
@@ -4,15 +4,8 @@ import android.app.Activity
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.ContextWrapper
|
import android.content.ContextWrapper
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import android.graphics.Bitmap
|
|
||||||
import android.graphics.PixelFormat
|
|
||||||
import android.hardware.HardwareBuffer
|
|
||||||
import android.media.ImageReader
|
|
||||||
import android.os.Handler
|
|
||||||
import android.os.Looper
|
|
||||||
import android.view.MotionEvent
|
import android.view.MotionEvent
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.Image
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.BoxScope
|
import androidx.compose.foundation.layout.BoxScope
|
||||||
@@ -39,9 +32,7 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.asImageBitmap
|
|
||||||
import androidx.compose.ui.input.pointer.pointerInteropFilter
|
import androidx.compose.ui.input.pointer.pointerInteropFilter
|
||||||
import androidx.compose.ui.layout.ContentScale
|
|
||||||
import androidx.compose.ui.layout.onSizeChanged
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
import androidx.compose.ui.platform.LocalConfiguration
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
@@ -59,7 +50,7 @@ import app.seb3thehacker.gearslip.car.CarUi
|
|||||||
* Runs the real car UI on the bench and shows it on the phone.
|
* Runs the real car UI on the bench and shows it on the phone.
|
||||||
*
|
*
|
||||||
* This is the same pipeline the car gets - a VirtualDisplay hosting a Presentation, driven by
|
* This is the same pipeline the car gets - a VirtualDisplay hosting a Presentation, driven by
|
||||||
* the same [ScreenProjector] and fed the same touches - with an ImageReader standing in for the
|
* the same [ScreenProjector] and fed the same touches - with a SurfaceView standing in for the
|
||||||
* H.264 encoder. That matters for anything composited rather than drawn by Compose, such as the
|
* H.264 encoder. That matters for anything composited rather than drawn by Compose, such as the
|
||||||
* SurfaceView a templated app draws its map into: if it appears here, it will reach the car.
|
* SurfaceView a templated app draws its map into: if it appears here, it will reach the car.
|
||||||
*
|
*
|
||||||
@@ -69,49 +60,26 @@ import app.seb3thehacker.gearslip.car.CarUi
|
|||||||
fun CarPreviewScreen(onBack: () -> Unit) {
|
fun CarPreviewScreen(onBack: () -> Unit) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val frame by CarEnvironment.frame.collectAsStateWithLifecycle()
|
val frame by CarEnvironment.frame.collectAsStateWithLifecycle()
|
||||||
var bitmap by remember { mutableStateOf<Bitmap?>(null) }
|
|
||||||
var viewSize by remember { mutableStateOf(0 to 0) }
|
var viewSize by remember { mutableStateOf(0 to 0) }
|
||||||
// Tied to how the phone itself is held, not a button: turn it sideways and the preview goes
|
// Tied to how the phone itself is held, not a button: turn it sideways and the preview goes
|
||||||
// fullscreen, turn it back and the phone's own chrome (the Back/title above the frame) returns.
|
// fullscreen, turn it back and the phone's own chrome (the Back/title above the frame) returns.
|
||||||
val fullscreen = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
|
val fullscreen = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||||
|
|
||||||
val width = frame.width
|
val previewSize by app.seb3thehacker.gearslip.car.CarSettings.previewSize.collectAsStateWithLifecycle()
|
||||||
val height = frame.height
|
val custom by app.seb3thehacker.gearslip.car.CarSettings.previewCustom.collectAsStateWithLifecycle()
|
||||||
val density = frame.densityDpi
|
val (width, height, density) = previewFrame(context, frame, previewSize, custom)
|
||||||
|
|
||||||
val projector = remember { ScreenProjector(context) }
|
val projector = remember { ScreenProjector(context) }
|
||||||
|
|
||||||
DisposableEffect(width, height, density) {
|
DisposableEffect(width, height, density) {
|
||||||
CarEnvironment.start(context)
|
CarEnvironment.start(context)
|
||||||
|
// No surface yet: the SurfaceView below hands its own over once it exists, and the
|
||||||
// COMPOSER_OVERLAY is what makes SurfaceFlinger willing to composite into this reader;
|
// display draws straight into it. Copying each frame into a Bitmap instead cost two
|
||||||
// CPU_READ_OFTEN is what makes the resulting buffer lockable, which is how the frame
|
// full-size allocations per frame on the main thread, and taps lagged with the size.
|
||||||
// gets copied into a Bitmap. Without both, frames arrive but every copy fails.
|
projector.start(null, width, height, density, name = "gearslip-preview") {
|
||||||
val reader = ImageReader.newInstance(
|
androidx.compose.runtime.CompositionLocalProvider(app.seb3thehacker.gearslip.car.LocalCarPreview provides true) { CarUi() }
|
||||||
width, height, PixelFormat.RGBA_8888, 3,
|
|
||||||
HardwareBuffer.USAGE_COMPOSER_OVERLAY or
|
|
||||||
HardwareBuffer.USAGE_GPU_SAMPLED_IMAGE or
|
|
||||||
HardwareBuffer.USAGE_CPU_READ_OFTEN,
|
|
||||||
)
|
|
||||||
val handler = Handler(Looper.getMainLooper())
|
|
||||||
reader.setOnImageAvailableListener({ source ->
|
|
||||||
val image = runCatching { source.acquireLatestImage() }.getOrNull() ?: return@setOnImageAvailableListener
|
|
||||||
try {
|
|
||||||
bitmap = image.toBitmap(width, height)
|
|
||||||
} catch (t: Throwable) {
|
|
||||||
GearslipLog.e("preview: could not copy a frame", t)
|
|
||||||
} finally {
|
|
||||||
image.close()
|
|
||||||
}
|
|
||||||
}, handler)
|
|
||||||
|
|
||||||
projector.start(reader.surface, width, height, density, name = "gearslip-preview") { CarUi() }
|
|
||||||
|
|
||||||
onDispose {
|
|
||||||
projector.stop()
|
|
||||||
reader.close()
|
|
||||||
bitmap = null
|
|
||||||
}
|
}
|
||||||
|
onDispose { projector.stop() }
|
||||||
}
|
}
|
||||||
|
|
||||||
// The frame is drawn the same way in both layouts; only what surrounds it differs.
|
// The frame is drawn the same way in both layouts; only what surrounds it differs.
|
||||||
@@ -140,19 +108,32 @@ fun CarPreviewScreen(onBack: () -> Unit) {
|
|||||||
},
|
},
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
bitmap?.let {
|
androidx.compose.runtime.key(width, height) {
|
||||||
Image(
|
androidx.compose.ui.viewinterop.AndroidView(
|
||||||
it.asImageBitmap(),
|
factory = { viewContext ->
|
||||||
contentDescription = "Car screen",
|
android.view.SurfaceView(viewContext).apply {
|
||||||
|
// The car's own size; the compositor scales it to the view.
|
||||||
|
holder.setFixedSize(width, height)
|
||||||
|
holder.addCallback(object : android.view.SurfaceHolder.Callback {
|
||||||
|
override fun surfaceCreated(holder: android.view.SurfaceHolder) =
|
||||||
|
projector.setSurface(holder.surface)
|
||||||
|
|
||||||
|
override fun surfaceChanged(holder: android.view.SurfaceHolder, format: Int, w: Int, h: Int) =
|
||||||
|
projector.setSurface(holder.surface)
|
||||||
|
|
||||||
|
override fun surfaceDestroyed(holder: android.view.SurfaceHolder) =
|
||||||
|
projector.setSurface(null)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentScale = ContentScale.Fit,
|
|
||||||
)
|
)
|
||||||
} ?: Text("Waiting for a frame…", color = Color.White)
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fullscreen) {
|
if (fullscreen) {
|
||||||
Fullscreen(onBack) {
|
Fullscreen(width.toFloat() / height, onBack) {
|
||||||
// No fillMax here: left to the bounds alone, aspectRatio takes the whole width or the
|
// No fillMax here: left to the bounds alone, aspectRatio takes the whole width or the
|
||||||
// whole height, whichever the car's shape allows, and the rest stays black.
|
// whole height, whichever the car's shape allows, and the rest stays black.
|
||||||
frameContent(Modifier)
|
frameContent(Modifier)
|
||||||
@@ -192,6 +173,7 @@ fun CarPreviewScreen(onBack: () -> Unit) {
|
|||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun Fullscreen(
|
private fun Fullscreen(
|
||||||
|
aspect: Float,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
content: @Composable BoxScope.() -> Unit,
|
content: @Composable BoxScope.() -> Unit,
|
||||||
) {
|
) {
|
||||||
@@ -213,22 +195,34 @@ private fun Fullscreen(
|
|||||||
// is on and turning it sideways/back isn't an option.
|
// is on and turning it sideways/back isn't an option.
|
||||||
BackHandler(onBack = onBack)
|
BackHandler(onBack = onBack)
|
||||||
|
|
||||||
Box(
|
androidx.compose.foundation.layout.BoxWithConstraints(
|
||||||
Modifier.fillMaxSize().background(Color.Black),
|
Modifier.fillMaxSize().background(Color.Black),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
content()
|
content()
|
||||||
|
|
||||||
// The frame swallows every touch inside it, so leaving needs either the back gesture or
|
// The frame swallows every touch inside it, so leaving needs either the back gesture or
|
||||||
// a button of its own. This one sits in the corner the car UI leaves emptiest.
|
// a button of its own. The button goes in the black beside the frame, never on it: over
|
||||||
|
// the frame it covered the car's own buttons. A frame the screen's own shape leaves no
|
||||||
|
// room, and then the back gesture is the way out.
|
||||||
|
val frameWidth = minOf(maxWidth, maxHeight * aspect)
|
||||||
|
val frameHeight = minOf(maxHeight, maxWidth / aspect)
|
||||||
|
val room = 56.dp
|
||||||
|
val corner = when {
|
||||||
|
maxWidth - frameWidth >= room * 2 -> Alignment.BottomStart
|
||||||
|
maxHeight - frameHeight >= room * 2 -> Alignment.TopStart
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
if (corner != null) {
|
||||||
FilledTonalIconButton(
|
FilledTonalIconButton(
|
||||||
onClick = onBack,
|
onClick = onBack,
|
||||||
modifier = Modifier.align(Alignment.BottomStart).padding(8.dp),
|
modifier = Modifier.align(corner).padding(8.dp),
|
||||||
) {
|
) {
|
||||||
Icon(Icons.Filled.Close, contentDescription = "Leave the car preview")
|
Icon(Icons.Filled.Close, contentDescription = "Leave the car preview")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Compose hands out a themed wrapper, so the activity is a few unwraps down. */
|
/** Compose hands out a themed wrapper, so the activity is a few unwraps down. */
|
||||||
private tailrec fun Context.activity(): Activity? = when (this) {
|
private tailrec fun Context.activity(): Activity? = when (this) {
|
||||||
@@ -250,14 +244,26 @@ private fun Int.toCarAction(): Int = when (this) {
|
|||||||
* Copies one frame out of the reader. The reader's rows are padded to a hardware-friendly
|
* Copies one frame out of the reader. The reader's rows are padded to a hardware-friendly
|
||||||
* stride, so the copy is made at the padded width and then cropped back.
|
* stride, so the copy is made at the padded width and then cropped back.
|
||||||
*/
|
*/
|
||||||
private fun android.media.Image.toBitmap(width: Int, height: Int): Bitmap {
|
/**
|
||||||
val plane = planes[0]
|
* The size the preview draws at. The last car's frame by default; this device's whole screen,
|
||||||
val pixelStride = plane.pixelStride
|
* for a head unit running Gearslip on its own (issue #12); or a size typed into Settings. The
|
||||||
val rowPadding = plane.rowStride - pixelStride * width
|
* two new ones get a car-like density: 160dpi for every 480 pixels of height.
|
||||||
val padded = Bitmap.createBitmap(
|
*/
|
||||||
width + rowPadding / pixelStride, height, Bitmap.Config.ARGB_8888,
|
private fun previewFrame(
|
||||||
)
|
context: android.content.Context,
|
||||||
padded.copyPixelsFromBuffer(plane.buffer)
|
lastCar: CarEnvironment.Frame,
|
||||||
return if (padded.width == width) padded
|
size: app.seb3thehacker.gearslip.car.PreviewSize,
|
||||||
else Bitmap.createBitmap(padded, 0, 0, width, height).also { padded.recycle() }
|
custom: app.seb3thehacker.gearslip.car.ScreenSize?,
|
||||||
|
): Triple<Int, Int, Int> {
|
||||||
|
fun carLike(w: Int, h: Int) = Triple(w, h, (160 * h / 480).coerceIn(120, 640))
|
||||||
|
return when (size) {
|
||||||
|
app.seb3thehacker.gearslip.car.PreviewSize.LAST_CAR -> Triple(lastCar.width, lastCar.height, lastCar.densityDpi)
|
||||||
|
app.seb3thehacker.gearslip.car.PreviewSize.CUSTOM ->
|
||||||
|
custom?.let { carLike(it.width, it.height) } ?: Triple(lastCar.width, lastCar.height, lastCar.densityDpi)
|
||||||
|
app.seb3thehacker.gearslip.car.PreviewSize.FILL -> {
|
||||||
|
val bounds = context.getSystemService(android.view.WindowManager::class.java).maximumWindowMetrics.bounds
|
||||||
|
// Landscape, whichever way the device is held right now.
|
||||||
|
carLike(maxOf(bounds.width(), bounds.height()) / 2 * 2, minOf(bounds.width(), bounds.height()) / 2 * 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
package app.seb3thehacker.gearslip.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.selection.selectable
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.RadioButton
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import app.seb3thehacker.gearslip.car.CarSettings
|
||||||
|
import app.seb3thehacker.gearslip.car.PreviewSize
|
||||||
|
import app.seb3thehacker.gearslip.car.ScreenFit
|
||||||
|
import app.seb3thehacker.gearslip.car.ScreenSize
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The car screen's real size, for a head unit that reports the wrong one and stretches the
|
||||||
|
* picture, and the size the phone's car preview draws at. The same screen size and fit are in
|
||||||
|
* the car's own Settings; typing in any size is only here.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun CarScreenSection() {
|
||||||
|
val size by CarSettings.screenSize.collectAsState()
|
||||||
|
val fit by CarSettings.screenFit.collectAsState()
|
||||||
|
val preview by CarSettings.previewSize.collectAsState()
|
||||||
|
val previewCustom by CarSettings.previewCustom.collectAsState()
|
||||||
|
var editingCar by remember { mutableStateOf(false) }
|
||||||
|
var editingPreview by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
if (editingCar) {
|
||||||
|
SizeDialog(
|
||||||
|
title = "Car screen size",
|
||||||
|
note = "Use this if the car's picture looks stretched. Enter the screen's real size, " +
|
||||||
|
"such as 1280x480. It takes effect the next time you connect.",
|
||||||
|
initial = size,
|
||||||
|
clearLabel = "Use the car's",
|
||||||
|
onDismiss = { editingCar = false },
|
||||||
|
onSave = { CarSettings.setScreenSize(it); editingCar = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (editingPreview) {
|
||||||
|
SizeDialog(
|
||||||
|
title = "Preview size",
|
||||||
|
note = "The size the car preview draws at, such as 1920x720.",
|
||||||
|
initial = previewCustom,
|
||||||
|
clearLabel = null,
|
||||||
|
onDismiss = { editingPreview = false },
|
||||||
|
onSave = { custom ->
|
||||||
|
CarSettings.setPreviewSize(if (custom == null) PreviewSize.LAST_CAR else PreviewSize.CUSTOM, custom)
|
||||||
|
editingPreview = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsSection(
|
||||||
|
"Car screen",
|
||||||
|
footer = "Gearslip uses the size the car reports unless you set one.",
|
||||||
|
) {
|
||||||
|
SettingsRow(
|
||||||
|
"Screen size",
|
||||||
|
Modifier.clickable { editingCar = true },
|
||||||
|
subtitle = size?.let { "$it, set by you" } ?: "From the car",
|
||||||
|
)
|
||||||
|
if (size != null) {
|
||||||
|
SettingsDivider()
|
||||||
|
SettingsRow(
|
||||||
|
"Crop the edges",
|
||||||
|
Modifier.selectable(fit == ScreenFit.CROP, role = Role.RadioButton) { CarSettings.setScreenFit(ScreenFit.CROP) },
|
||||||
|
subtitle = "For a car that cuts off the top and bottom, or the sides",
|
||||||
|
trailing = { RadioButton(selected = fit == ScreenFit.CROP, onClick = null) },
|
||||||
|
)
|
||||||
|
SettingsDivider()
|
||||||
|
SettingsRow(
|
||||||
|
"Squash to fit",
|
||||||
|
Modifier.selectable(fit == ScreenFit.SQUASH, role = Role.RadioButton) { CarSettings.setScreenFit(ScreenFit.SQUASH) },
|
||||||
|
subtitle = "For a car that squeezes the whole picture, so circles look like ovals",
|
||||||
|
trailing = { RadioButton(selected = fit == ScreenFit.SQUASH, onClick = null) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsSection("Car preview size") {
|
||||||
|
SettingsRow(
|
||||||
|
"Last car",
|
||||||
|
Modifier.selectable(preview == PreviewSize.LAST_CAR, role = Role.RadioButton) {
|
||||||
|
CarSettings.setPreviewSize(PreviewSize.LAST_CAR)
|
||||||
|
},
|
||||||
|
subtitle = "The size of the last car you connected to",
|
||||||
|
trailing = { RadioButton(selected = preview == PreviewSize.LAST_CAR, onClick = null) },
|
||||||
|
)
|
||||||
|
SettingsDivider()
|
||||||
|
SettingsRow(
|
||||||
|
"This screen",
|
||||||
|
Modifier.selectable(preview == PreviewSize.FILL, role = Role.RadioButton) {
|
||||||
|
CarSettings.setPreviewSize(PreviewSize.FILL)
|
||||||
|
},
|
||||||
|
subtitle = "Fills this device's screen, for a head unit running Gearslip itself",
|
||||||
|
trailing = { RadioButton(selected = preview == PreviewSize.FILL, onClick = null) },
|
||||||
|
)
|
||||||
|
SettingsDivider()
|
||||||
|
SettingsRow(
|
||||||
|
"Custom",
|
||||||
|
Modifier.selectable(preview == PreviewSize.CUSTOM, role = Role.RadioButton) {
|
||||||
|
if (previewCustom == null) editingPreview = true else CarSettings.setPreviewSize(PreviewSize.CUSTOM)
|
||||||
|
},
|
||||||
|
subtitle = previewCustom?.toString() ?: "Enter a size",
|
||||||
|
trailing = { RadioButton(selected = preview == PreviewSize.CUSTOM, onClick = null) },
|
||||||
|
)
|
||||||
|
if (preview == PreviewSize.CUSTOM) {
|
||||||
|
SettingsDivider()
|
||||||
|
SettingsRow("Change the custom size", Modifier.clickable { editingPreview = true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Asks for a width and height; [clearLabel], when given, offers going back to no size at all. */
|
||||||
|
@Composable
|
||||||
|
private fun SizeDialog(
|
||||||
|
title: String,
|
||||||
|
note: String,
|
||||||
|
initial: ScreenSize?,
|
||||||
|
clearLabel: String?,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onSave: (ScreenSize?) -> Unit,
|
||||||
|
) {
|
||||||
|
var width by remember { mutableStateOf(initial?.width?.toString().orEmpty()) }
|
||||||
|
var height by remember { mutableStateOf(initial?.height?.toString().orEmpty()) }
|
||||||
|
val parsed = ScreenSize.parse("${width}x$height")
|
||||||
|
val number = KeyboardOptions(keyboardType = KeyboardType.Number)
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(title) },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Text(note)
|
||||||
|
OutlinedTextField(width, { width = it.filter(Char::isDigit) }, label = { Text("Width") }, singleLine = true, keyboardOptions = number)
|
||||||
|
OutlinedTextField(height, { height = it.filter(Char::isDigit) }, label = { Text("Height") }, singleLine = true, keyboardOptions = number)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = { TextButton(onClick = { onSave(parsed) }, enabled = parsed != null) { Text("Save") } },
|
||||||
|
dismissButton = {
|
||||||
|
if (clearLabel != null) TextButton(onClick = { onSave(null) }) { Text(clearLabel) }
|
||||||
|
else TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -14,7 +14,9 @@ import androidx.compose.material3.Text
|
|||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -85,7 +87,12 @@ object DonatePrompt {
|
|||||||
internal fun DonateDialog(live: Boolean) {
|
internal fun DonateDialog(live: Boolean) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val waiting by remember { DonatePrompt.pending(context) }.collectAsStateWithLifecycle()
|
val waiting by remember { DonatePrompt.pending(context) }.collectAsStateWithLifecycle()
|
||||||
|
var monero by remember { mutableStateOf(false) }
|
||||||
if (!waiting || live) return
|
if (!waiting || live) return
|
||||||
|
if (monero) {
|
||||||
|
MoneroDialog { DonatePrompt.answer(context, donated = true) }
|
||||||
|
return
|
||||||
|
}
|
||||||
fun give(url: String) {
|
fun give(url: String) {
|
||||||
DonatePrompt.answer(context, donated = true)
|
DonatePrompt.answer(context, donated = true)
|
||||||
GearslipLinks.open(context, url)
|
GearslipLinks.open(context, url)
|
||||||
@@ -105,11 +112,17 @@ internal fun DonateDialog(live: Boolean) {
|
|||||||
Button(onClick = { give(GearslipLinks.LIBERAPAY) }, Modifier.fillMaxWidth()) {
|
Button(onClick = { give(GearslipLinks.LIBERAPAY) }, Modifier.fillMaxWidth()) {
|
||||||
Text("Support on Liberapay")
|
Text("Support on Liberapay")
|
||||||
}
|
}
|
||||||
|
OutlinedButton(onClick = { give(GearslipLinks.BUY_ME_A_COFFEE) }, Modifier.fillMaxWidth()) {
|
||||||
|
Text("Buy me a coffee")
|
||||||
|
}
|
||||||
if (GearslipLinks.PAYMENT_LINK.isNotEmpty()) {
|
if (GearslipLinks.PAYMENT_LINK.isNotEmpty()) {
|
||||||
OutlinedButton(onClick = { give(GearslipLinks.PAYMENT_LINK) }, Modifier.fillMaxWidth()) {
|
OutlinedButton(onClick = { give(GearslipLinks.PAYMENT_LINK) }, Modifier.fillMaxWidth()) {
|
||||||
Text("Give once by card")
|
Text("Give once by card")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
OutlinedButton(onClick = { monero = true }, Modifier.fillMaxWidth()) {
|
||||||
|
Text("Monero")
|
||||||
|
}
|
||||||
TextButton(onClick = { DonatePrompt.answer(context, donated = false) }, Modifier.fillMaxWidth()) {
|
TextButton(onClick = { DonatePrompt.answer(context, donated = false) }, Modifier.fillMaxWidth()) {
|
||||||
Text("Not now")
|
Text("Not now")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ import app.seb3thehacker.gearslip.BuildConfig
|
|||||||
import app.seb3thehacker.gearslip.CertProvider
|
import app.seb3thehacker.gearslip.CertProvider
|
||||||
import app.seb3thehacker.gearslip.stats.UsageStats
|
import app.seb3thehacker.gearslip.stats.UsageStats
|
||||||
|
|
||||||
private enum class Screen { INTRO, HOME, LOGS, SETTINGS, CAR_PREVIEW, CERT_SETUP, SETUP_GUIDE, USAGE_NOTES, HELP, WHATS_NEW, RECOMMENDED_APPS }
|
private enum class Screen { HOME, LOGS, SETTINGS, CAR_PREVIEW, CERT_SETUP, SETUP_GUIDE, USAGE_NOTES, HELP, WHATS_NEW, RECOMMENDED_APPS, CAR_GROUP }
|
||||||
|
|
||||||
/** Three screens and a back stack of depth one: no navigation library needed. */
|
/** Three screens and a back stack of depth one: no navigation library needed. */
|
||||||
@Composable
|
@Composable
|
||||||
@@ -47,8 +47,8 @@ fun GearslipApp(onDisconnect: () -> Unit, onRequestCallScreening: () -> Unit) {
|
|||||||
!AppSettings.hasSkippedCertSetup(context) && !CertProvider.hasAnyCert(context)
|
!AppSettings.hasSkippedCertSetup(context) && !CertProvider.hasAnyCert(context)
|
||||||
}
|
}
|
||||||
val needsSetupGuide = remember { !AppSettings.hasSeenPermissionsSetup(context) }
|
val needsSetupGuide = remember { !AppSettings.hasSeenPermissionsSetup(context) }
|
||||||
// Where launch goes once the intro (if any) is done.
|
// Where launch goes: setup until it's done, then Home.
|
||||||
val afterIntro = remember {
|
val start = remember {
|
||||||
when {
|
when {
|
||||||
needsCertSetup -> Screen.CERT_SETUP
|
needsCertSetup -> Screen.CERT_SETUP
|
||||||
needsSetupGuide -> Screen.SETUP_GUIDE
|
needsSetupGuide -> Screen.SETUP_GUIDE
|
||||||
@@ -57,14 +57,14 @@ fun GearslipApp(onDisconnect: () -> Unit, onRequestCallScreening: () -> Unit) {
|
|||||||
else -> Screen.HOME
|
else -> Screen.HOME
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var screen by rememberSaveable { mutableStateOf(if (Intro.due(context)) Screen.INTRO else afterIntro) }
|
var screen by rememberSaveable { mutableStateOf(start) }
|
||||||
// A replay from About goes back to Settings; the launch one carries on into setup.
|
|
||||||
var introReturn by rememberSaveable { mutableStateOf(afterIntro) }
|
|
||||||
// Home's "it worked" card opens Settings at usage notes rather than at the top.
|
// Home's "it worked" card opens Settings at usage notes rather than at the top.
|
||||||
var settingsAtUsageNotes by rememberSaveable { mutableStateOf(false) }
|
var settingsAtUsageNotes by rememberSaveable { mutableStateOf(false) }
|
||||||
// What's new and Connection help open from Home and from Settings; back returns to whichever.
|
// What's new and Connection help open from Home and from Settings; back returns to whichever.
|
||||||
var backTo by rememberSaveable { mutableStateOf(Screen.SETTINGS) }
|
var backTo by rememberSaveable { mutableStateOf(Screen.SETTINGS) }
|
||||||
BackHandler(enabled = screen != Screen.HOME && screen != Screen.INTRO) {
|
// Which of the car's setting groups Home opened.
|
||||||
|
var carGroup by rememberSaveable { mutableStateOf(CarGroup.SCREEN) }
|
||||||
|
BackHandler(enabled = screen != Screen.HOME) {
|
||||||
screen = when (screen) {
|
screen = when (screen) {
|
||||||
Screen.RECOMMENDED_APPS, Screen.LOGS, Screen.CAR_PREVIEW -> Screen.SETTINGS
|
Screen.RECOMMENDED_APPS, Screen.LOGS, Screen.CAR_PREVIEW -> Screen.SETTINGS
|
||||||
Screen.HELP, Screen.WHATS_NEW -> backTo
|
Screen.HELP, Screen.WHATS_NEW -> backTo
|
||||||
@@ -101,33 +101,30 @@ fun GearslipApp(onDisconnect: () -> Unit, onRequestCallScreening: () -> Unit) {
|
|||||||
.windowInsetsPadding(WindowInsets.statusBars)
|
.windowInsetsPadding(WindowInsets.statusBars)
|
||||||
.consumeWindowInsets(WindowInsets.statusBars),
|
.consumeWindowInsets(WindowInsets.statusBars),
|
||||||
) {
|
) {
|
||||||
if (BuildConfig.DEBUG && screen != Screen.CAR_PREVIEW && screen != Screen.INTRO) DebugBuildBanner()
|
if (BuildConfig.DEBUG && screen != Screen.CAR_PREVIEW) DebugBuildBanner()
|
||||||
|
|
||||||
Column(Modifier.weight(1f)) {
|
Column(Modifier.weight(1f)) {
|
||||||
when (screen) {
|
when (screen) {
|
||||||
Screen.INTRO -> IntroScreen(onDone = { screen = introReturn })
|
|
||||||
Screen.HOME -> HomeScreen(
|
Screen.HOME -> HomeScreen(
|
||||||
onOpenSettings = { settingsAtUsageNotes = false; screen = Screen.SETTINGS },
|
onOpenSettings = { settingsAtUsageNotes = false; screen = Screen.SETTINGS },
|
||||||
onOpenWhatsNew = { backTo = Screen.HOME; screen = Screen.WHATS_NEW },
|
|
||||||
onOpenHelp = { backTo = Screen.HOME; screen = Screen.HELP },
|
onOpenHelp = { backTo = Screen.HOME; screen = Screen.HELP },
|
||||||
onOpenUsageNotes = { settingsAtUsageNotes = true; screen = Screen.SETTINGS },
|
onOpenUsageNotes = { settingsAtUsageNotes = true; screen = Screen.SETTINGS },
|
||||||
|
onOpenCarPreview = { backTo = Screen.HOME; screen = Screen.CAR_PREVIEW },
|
||||||
|
onOpenLogs = { backTo = Screen.HOME; screen = Screen.LOGS },
|
||||||
onDisconnect = onDisconnect,
|
onDisconnect = onDisconnect,
|
||||||
|
onOpenCarGroup = { carGroup = it; screen = Screen.CAR_GROUP },
|
||||||
)
|
)
|
||||||
|
Screen.CAR_GROUP -> CarGroupScreen(carGroup, onBack = { screen = Screen.HOME })
|
||||||
Screen.WHATS_NEW -> WhatsNewScreen(onBack = { screen = backTo })
|
Screen.WHATS_NEW -> WhatsNewScreen(onBack = { screen = backTo })
|
||||||
Screen.LOGS -> LogsScreen(onBack = { screen = Screen.SETTINGS })
|
Screen.LOGS -> LogsScreen(onBack = { screen = backTo })
|
||||||
Screen.SETTINGS -> SettingsScreen(
|
Screen.SETTINGS -> SettingsScreen(
|
||||||
onBack = { screen = Screen.HOME },
|
onBack = { screen = Screen.HOME },
|
||||||
onOpenRecommendedApps = { settingsAtUsageNotes = false; screen = Screen.RECOMMENDED_APPS },
|
onOpenRecommendedApps = { settingsAtUsageNotes = false; screen = Screen.RECOMMENDED_APPS },
|
||||||
onOpenWhatsNew = { settingsAtUsageNotes = false; backTo = Screen.SETTINGS; screen = Screen.WHATS_NEW },
|
|
||||||
onOpenHelp = { settingsAtUsageNotes = false; backTo = Screen.SETTINGS; screen = Screen.HELP },
|
|
||||||
onOpenCarPreview = { settingsAtUsageNotes = false; screen = Screen.CAR_PREVIEW },
|
|
||||||
onOpenLogs = { settingsAtUsageNotes = false; screen = Screen.LOGS },
|
|
||||||
onReplayTutorial = { screen = Screen.SETUP_GUIDE },
|
onReplayTutorial = { screen = Screen.SETUP_GUIDE },
|
||||||
onReplayIntro = { Intro.replay(context); introReturn = Screen.SETTINGS; screen = Screen.INTRO },
|
|
||||||
atUsageNotes = settingsAtUsageNotes,
|
atUsageNotes = settingsAtUsageNotes,
|
||||||
)
|
)
|
||||||
Screen.RECOMMENDED_APPS -> RecommendedAppsScreen(onBack = { screen = Screen.SETTINGS })
|
Screen.RECOMMENDED_APPS -> RecommendedAppsScreen(onBack = { screen = Screen.SETTINGS })
|
||||||
Screen.CAR_PREVIEW -> CarPreviewScreen(onBack = { screen = Screen.SETTINGS })
|
Screen.CAR_PREVIEW -> CarPreviewScreen(onBack = { screen = backTo })
|
||||||
Screen.HELP -> HelpScreen(
|
Screen.HELP -> HelpScreen(
|
||||||
onBack = { screen = backTo },
|
onBack = { screen = backTo },
|
||||||
onRequestCallScreening = onRequestCallScreening,
|
onRequestCallScreening = onRequestCallScreening,
|
||||||
@@ -148,8 +145,9 @@ fun GearslipApp(onDisconnect: () -> Unit, onRequestCallScreening: () -> Unit) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// A car keyboard is open: offer the phone's own, for a passenger.
|
// A car keyboard is open: offer the phone's own, for a passenger. In the car preview it is
|
||||||
if (screen != Screen.CAR_PREVIEW) PhoneTypingBar()
|
// the only keyboard, since the preview draws none of its own.
|
||||||
|
PhoneTypingBar()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,11 +6,16 @@ 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.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import app.seb3thehacker.gearslip.car.theme.withTintedSurfaces
|
||||||
|
|
||||||
/** Material You: the palette comes from the wallpaper (minSdk 35, so dynamic color always exists). */
|
/**
|
||||||
|
* Material You: the palette comes from the wallpaper (minSdk 35, so dynamic color always exists),
|
||||||
|
* with the same accent tint over the surfaces as the car screen, so the two look like one app.
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun GearslipTheme(content: @Composable () -> Unit) {
|
fun GearslipTheme(content: @Composable () -> Unit) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val scheme = if (isSystemInDarkTheme()) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
val dark = isSystemInDarkTheme()
|
||||||
|
val scheme = (if (dark) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)).withTintedSurfaces(dark)
|
||||||
MaterialTheme(colorScheme = scheme, content = content)
|
MaterialTheme(colorScheme = scheme, content = content)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
package app.seb3thehacker.gearslip.ui
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.Animatable
|
||||||
|
import androidx.compose.animation.core.CubicBezierEasing
|
||||||
|
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||||
|
import androidx.compose.animation.core.LinearEasing
|
||||||
|
import androidx.compose.animation.core.RepeatMode
|
||||||
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
|
import androidx.compose.animation.core.animateFloat
|
||||||
|
import androidx.compose.animation.core.infiniteRepeatable
|
||||||
|
import androidx.compose.animation.core.keyframes
|
||||||
|
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.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.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
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.Path
|
||||||
|
import androidx.compose.ui.graphics.StrokeCap
|
||||||
|
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||||
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
|
import androidx.compose.ui.graphics.drawscope.rotate
|
||||||
|
import androidx.compose.ui.graphics.drawscope.scale
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.TextUnit
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import app.seb3thehacker.gearslip.car.CarSettings
|
||||||
|
import app.seb3thehacker.gearslip.car.theme.GsButton
|
||||||
|
import app.seb3thehacker.gearslip.car.theme.GsIconButton
|
||||||
|
import app.seb3thehacker.gearslip.car.theme.GsThemes
|
||||||
|
import app.seb3thehacker.gearslip.car.theme.GsTone
|
||||||
|
import app.seb3thehacker.gearslip.car.theme.LocalGsTheme
|
||||||
|
import kotlin.math.PI
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.atan2
|
||||||
|
import kotlin.math.sqrt
|
||||||
|
import kotlin.math.sin
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The setup guide's tones, taken from the phone's own Material You colours like the rest of the app.
|
||||||
|
* Every size is taken from the screen's width, so the guide scales from small phones to tablets.
|
||||||
|
*/
|
||||||
|
internal class IntroColors(
|
||||||
|
val surface: Color, val onSurface: Color, val onVariant: Color, val outline: Color,
|
||||||
|
val primary: Color, val onPrimary: Color, val primaryC: Color, val onPrimaryC: Color,
|
||||||
|
val tertiaryC: Color, val onTertiaryC: Color, val secondaryC: Color, val onSecondaryC: Color,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun introColors(): IntroColors {
|
||||||
|
val m = MaterialTheme.colorScheme
|
||||||
|
return IntroColors(
|
||||||
|
surface = m.surface, onSurface = m.onSurface, onVariant = m.onSurfaceVariant, outline = m.outline,
|
||||||
|
primary = m.primary, onPrimary = m.onPrimary, primaryC = m.primaryContainer, onPrimaryC = m.onPrimaryContainer,
|
||||||
|
tertiaryC = m.tertiaryContainer, onTertiaryC = m.onTertiaryContainer,
|
||||||
|
secondaryC = m.secondaryContainer, onSecondaryC = m.onSecondaryContainer,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val Pop = CubicBezierEasing(0.3f, 1.6f, 0.5f, 1f)
|
||||||
|
private val Rise = CubicBezierEasing(0.2f, 0.8f, 0.2f, 1f)
|
||||||
|
|
||||||
|
/** 0 to 1 once each time [key] changes, after [delayMs]: drives the entrance of one element. */
|
||||||
|
@Composable
|
||||||
|
internal fun entrance(key: Any, delayMs: Int, durationMs: Int = 650): Float {
|
||||||
|
val a = remember(key) { Animatable(0f) }
|
||||||
|
LaunchedEffect(key) {
|
||||||
|
delay(delayMs.toLong())
|
||||||
|
a.animateTo(1f, tween(durationMs, easing = LinearEasing))
|
||||||
|
}
|
||||||
|
return a.value
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Slides up and fades in, like the mockup's `up`. */
|
||||||
|
internal fun Modifier.up(p: Float, distance: Dp) = graphicsLayer {
|
||||||
|
val e = Rise.transform(p)
|
||||||
|
alpha = e
|
||||||
|
translationY = (1 - e) * distance.toPx()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Grows in with a bounce, like the mockup's `bounce`. */
|
||||||
|
internal fun Modifier.pop(p: Float) = graphicsLayer {
|
||||||
|
val e = Pop.transform(p)
|
||||||
|
alpha = p.coerceAtMost(1f)
|
||||||
|
scaleX = 0.5f + 0.5f * e
|
||||||
|
scaleY = 0.5f + 0.5f * e
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The car's button styles side by side, each drawn in its own style, and a row of real car
|
||||||
|
* controls below that redraws in whichever is picked. Picking saves it, so the car shows it too.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun ThemePicker(c: IntroColors, cq: (Float) -> Dp, cqSp: (Float) -> TextUnit, key: Any) {
|
||||||
|
val chosenId by CarSettings.uiTheme.collectAsState()
|
||||||
|
val chosen = GsThemes.byId(chosenId)
|
||||||
|
// Flat first: it's the default.
|
||||||
|
val themes = listOf(GsThemes.Flat) + GsThemes.builtIn.filter { it != GsThemes.Flat }
|
||||||
|
Column(
|
||||||
|
Modifier.padding(top = cq(7f)).fillMaxWidth().up(entrance(key, 500, 700), cq(7f)),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(cq(6f)),
|
||||||
|
) {
|
||||||
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(cq(3f))) {
|
||||||
|
themes.forEach { theme ->
|
||||||
|
CompositionLocalProvider(LocalGsTheme provides theme) {
|
||||||
|
GsButton(
|
||||||
|
onClick = { CarSettings.setUiTheme(theme.id) },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
tone = if (theme == chosen) GsTone.Primary else GsTone.Neutral,
|
||||||
|
contentPadding = PaddingValues(horizontal = cq(1f), vertical = cq(3f)),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
if (theme == chosen) "✓ ${theme.name}" else theme.name,
|
||||||
|
style = TextStyle(fontSize = cqSp(4.2f), fontWeight = FontWeight.Bold),
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// What the car shows: the player's controls and a full-width action.
|
||||||
|
CompositionLocalProvider(LocalGsTheme provides chosen) {
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(cq(3f)),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
GsIconButton(Icons.Filled.Search, "Search", onClick = {}, size = cq(15f), iconSize = cq(7f))
|
||||||
|
GsIconButton(Icons.Filled.PlayArrow, "Play", onClick = {}, tone = GsTone.Primary, size = cq(15f), iconSize = cq(8f))
|
||||||
|
GsButton(
|
||||||
|
onClick = {},
|
||||||
|
modifier = Modifier.weight(1f).height(cq(15f)),
|
||||||
|
tone = GsTone.Tonal,
|
||||||
|
) { Text("Navigate", style = TextStyle(fontSize = cqSp(4.6f), fontWeight = FontWeight.Bold)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Three keys of a keypad, pressing one after another. */
|
||||||
|
@Composable
|
||||||
|
internal fun ButtonsIcon(color: Color) {
|
||||||
|
val t by rememberInfiniteTransition(label = "keys").animateFloat(
|
||||||
|
0f, 3f, infiniteRepeatable(tween(2400, easing = LinearEasing)), label = "keys",
|
||||||
|
)
|
||||||
|
Canvas(Modifier.fillMaxSize()) {
|
||||||
|
inIconBox {
|
||||||
|
listOf(6f, 19f, 32f).forEachIndexed { i, x ->
|
||||||
|
// Each key dips for a moment while its turn comes round.
|
||||||
|
val dip = (1f - kotlin.math.abs(t - i - 0.5f) * 2f).coerceIn(0f, 1f) * 4f
|
||||||
|
drawRoundRect(color.copy(alpha = 0.45f), Offset(x, 16f + 4f), Size(10f, 16f),
|
||||||
|
androidx.compose.ui.geometry.CornerRadius(3f))
|
||||||
|
drawRoundRect(color, Offset(x, 16f + dip), Size(10f, 16f), androidx.compose.ui.geometry.CornerRadius(3f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Material's scalloped "cookie": a circle whose radius waves around its edge. */
|
||||||
|
@Composable
|
||||||
|
internal fun Cookie(color: Color, modifier: Modifier) {
|
||||||
|
Canvas(modifier) {
|
||||||
|
val unit = size.minDimension / 100f
|
||||||
|
val path = Path()
|
||||||
|
var deg = 0
|
||||||
|
while (deg <= 360) {
|
||||||
|
val a = deg * PI / 180
|
||||||
|
val r = (44 + 4.5 * cos(9 * a)) * unit
|
||||||
|
val x = center.x + (r * cos(a)).toFloat()
|
||||||
|
val y = center.y + (r * sin(a)).toFloat()
|
||||||
|
if (deg == 0) path.moveTo(x, y) else path.lineTo(x, y)
|
||||||
|
deg += 3
|
||||||
|
}
|
||||||
|
path.close()
|
||||||
|
drawPath(path, color)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Draws in the mockup's 48-unit icon box. */
|
||||||
|
private inline fun DrawScope.inIconBox(block: DrawScope.() -> Unit) {
|
||||||
|
val s = size.minDimension / 48f
|
||||||
|
scale(s, s, Offset.Zero) { block() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun WifiIcon(color: Color) {
|
||||||
|
val t = rememberInfiniteTransition(label = "wifi")
|
||||||
|
val waves = (0..2).map { i ->
|
||||||
|
t.animateFloat(
|
||||||
|
0.35f, 1f,
|
||||||
|
infiniteRepeatable(tween(800, delayMillis = 0, easing = FastOutSlowInEasing), RepeatMode.Reverse,
|
||||||
|
initialStartOffset = androidx.compose.animation.core.StartOffset(i * 200)),
|
||||||
|
label = "wave$i",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Canvas(Modifier.fillMaxSize()) {
|
||||||
|
inIconBox {
|
||||||
|
val stroke = Stroke(5f, cap = StrokeCap.Round)
|
||||||
|
// The mockup's three arcs, inner to outer: radius r through (24 ± half, y).
|
||||||
|
listOf(Triple(8f, 6f, 34f), Triple(18f, 13f, 26f), Triple(28f, 20f, 18f)).forEachIndexed { i, (r, half, y) ->
|
||||||
|
val cy = y + sqrt(r * r - half * half)
|
||||||
|
val start = Math.toDegrees(atan2((y - cy).toDouble(), (-half).toDouble())).toFloat()
|
||||||
|
val end = Math.toDegrees(atan2((y - cy).toDouble(), half.toDouble())).toFloat()
|
||||||
|
drawArc(color.copy(alpha = waves[i].value), start, (end - start + 360f) % 360f, false,
|
||||||
|
Offset(24f - r, cy - r), Size(2 * r, 2 * r), style = stroke)
|
||||||
|
}
|
||||||
|
drawCircle(color, 3.6f, Offset(24f, 41f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun MicIcon(color: Color) {
|
||||||
|
val t = rememberInfiniteTransition(label = "mic")
|
||||||
|
val waves = (0..1).map { i ->
|
||||||
|
t.animateFloat(
|
||||||
|
0.25f, 1f,
|
||||||
|
infiniteRepeatable(tween(700, easing = FastOutSlowInEasing), RepeatMode.Reverse,
|
||||||
|
initialStartOffset = androidx.compose.animation.core.StartOffset(i * 250)),
|
||||||
|
label = "mic$i",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Canvas(Modifier.fillMaxSize()) {
|
||||||
|
inIconBox {
|
||||||
|
drawRoundRect(color, Offset(18f, 5f), Size(12f, 22f), androidx.compose.ui.geometry.CornerRadius(6f))
|
||||||
|
val stroke = Stroke(4f, cap = StrokeCap.Round)
|
||||||
|
drawArc(color, 0f, 180f, false, Offset(12f, 14f), Size(24f, 22f), style = stroke)
|
||||||
|
drawLine(color, Offset(24f, 36f), Offset(24f, 42f), 4f, StrokeCap.Round)
|
||||||
|
// Sound waves either side, pulsing as if someone's talking.
|
||||||
|
listOf(1f to 0, -1f to 0, 1f to 1, -1f to 1).forEach { (side, i) ->
|
||||||
|
val r = if (i == 0) 15f else 21f
|
||||||
|
drawArc(color.copy(alpha = waves[i].value), if (side > 0) -35f else 145f, 70f, false,
|
||||||
|
Offset(24f - r, 16f - r), Size(2 * r, 2 * r), style = stroke)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun StarIcon(color: Color) {
|
||||||
|
val turn by rememberInfiniteTransition(label = "star").animateFloat(
|
||||||
|
-8f, 8f, infiniteRepeatable(tween(1600, easing = FastOutSlowInEasing), RepeatMode.Reverse), label = "star",
|
||||||
|
)
|
||||||
|
Canvas(Modifier.fillMaxSize()) {
|
||||||
|
inIconBox {
|
||||||
|
val star = Path()
|
||||||
|
for (i in 0 until 10) {
|
||||||
|
val r = if (i % 2 == 0) 20f else 8.5f
|
||||||
|
val a = (-90 + i * 36) * PI / 180
|
||||||
|
val x = 24f + (r * cos(a)).toFloat()
|
||||||
|
val y = 25f + (r * sin(a)).toFloat()
|
||||||
|
if (i == 0) star.moveTo(x, y) else star.lineTo(x, y)
|
||||||
|
}
|
||||||
|
star.close()
|
||||||
|
rotate(turn, Offset(24f, 25f)) { drawPath(star, color) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun HeartIcon(color: Color) {
|
||||||
|
val beat by rememberInfiniteTransition(label = "beat").animateFloat(
|
||||||
|
1f, 1f,
|
||||||
|
infiniteRepeatable(keyframes {
|
||||||
|
durationMillis = 1400
|
||||||
|
1f at 0; 1.16f at 210; 1f at 420; 1.1f at 630; 1f at 840
|
||||||
|
}),
|
||||||
|
label = "beat",
|
||||||
|
)
|
||||||
|
Canvas(Modifier.fillMaxSize()) {
|
||||||
|
inIconBox {
|
||||||
|
scale(beat, beat, Offset(24f, 24f)) {
|
||||||
|
val heart = Path().apply {
|
||||||
|
moveTo(24f, 41f)
|
||||||
|
cubicTo(10f, 31f, 5f, 23f, 5f, 16f)
|
||||||
|
cubicTo(5f, 10f, 10f, 6f, 15f, 6f)
|
||||||
|
cubicTo(19f, 6f, 22f, 8f, 24f, 12f)
|
||||||
|
cubicTo(26f, 8f, 29f, 6f, 33f, 6f)
|
||||||
|
cubicTo(38f, 6f, 43f, 10f, 43f, 16f)
|
||||||
|
cubicTo(43f, 23f, 38f, 31f, 24f, 41f)
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
drawPath(heart, color)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun Dots(c: IntroColors, count: Int, current: Int, cq: (Float) -> Dp, modifier: Modifier) {
|
||||||
|
Row(modifier, horizontalArrangement = Arrangement.spacedBy(cq(2f))) {
|
||||||
|
repeat(count) { i ->
|
||||||
|
val on = i == current
|
||||||
|
val width by animateDpAsState(if (on) cq(7f) else cq(2f), tween(300), label = "dot")
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.size(width, cq(2f))
|
||||||
|
.clip(RoundedCornerShape(50))
|
||||||
|
.background(if (on) c.primary else c.outline.copy(alpha = 0.45f)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
package app.seb3thehacker.gearslip.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
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.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import app.seb3thehacker.gearslip.car.AppTheme
|
||||||
|
import app.seb3thehacker.gearslip.car.CarApps
|
||||||
|
import app.seb3thehacker.gearslip.car.CarSettings
|
||||||
|
import app.seb3thehacker.gearslip.car.NavBarMode
|
||||||
|
import app.seb3thehacker.gearslip.car.NightMode
|
||||||
|
import app.seb3thehacker.gearslip.car.Units
|
||||||
|
import app.seb3thehacker.gearslip.car.theme.GsThemes
|
||||||
|
|
||||||
|
/** The car's setting groups, each opening its own screen from the phone's home screen. */
|
||||||
|
enum class CarGroup(val title: String) {
|
||||||
|
SCREEN("Screen"),
|
||||||
|
NAV_BAR("Nav bar and player"),
|
||||||
|
SOUND("Sound and voice"),
|
||||||
|
WARNINGS("Warnings"),
|
||||||
|
}
|
||||||
|
|
||||||
|
private val sizeChoices = listOf(1f to "Normal", 1.25f to "Large", 1.5f to "Extra large")
|
||||||
|
private val themeChoices = listOf(AppTheme.PHONE to "Follow phone", AppTheme.LIGHT to "Light", AppTheme.DARK to "Dark")
|
||||||
|
private val nightChoices = listOf(NightMode.AUTO to "Auto", NightMode.DAY to "Day", NightMode.NIGHT to "Night")
|
||||||
|
private val unitChoices = listOf(Units.AUTO to "Auto", Units.IMPERIAL to "Imperial", Units.METRIC to "Metric")
|
||||||
|
private val navBarChoices = listOf(
|
||||||
|
NavBarMode.SHOW to "Always show",
|
||||||
|
NavBarMode.HIDE_NAVIGATING to "Hide while navigating",
|
||||||
|
NavBarMode.HIDE to "Hide until tapped",
|
||||||
|
)
|
||||||
|
private fun openChoices() = listOf(CarSettings.HOME to "Home") + CarApps.all.filter { it.id != "settings" }.map { it.id to it.label }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The car's settings on the phone's home screen: one card per group with a summary of where it
|
||||||
|
* stands, each opening a screen of its own. They are the same settings as the car's own Settings
|
||||||
|
* app, so a change in either shows in both; the phone is just the easier place when parked.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun HomeCarSettings(onOpen: (CarGroup) -> Unit, modifier: Modifier = Modifier) {
|
||||||
|
val scale by CarSettings.scale.collectAsState()
|
||||||
|
val open by CarSettings.openOnConnect.collectAsState()
|
||||||
|
val navBar by CarSettings.navBarMode.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 playerLeft by CarSettings.playerLeft.collectAsState()
|
||||||
|
val theme by CarSettings.appTheme.collectAsState()
|
||||||
|
|
||||||
|
fun summary(group: CarGroup) = when (group) {
|
||||||
|
CarGroup.SCREEN -> listOf(openChoices().label(open), sizeChoices.label(scale), themeChoices.label(theme))
|
||||||
|
CarGroup.NAV_BAR -> listOf(navBarChoices.label(navBar), "Player on the ${if (playerLeft) "left" else "right"}")
|
||||||
|
CarGroup.SOUND -> listOf(if (pipeAudio) "USB audio" else "Bluetooth audio", "Directions ${if (speakDirections) "on" else "off"}")
|
||||||
|
CarGroup.WARNINGS -> listOf(
|
||||||
|
"Bluetooth ${if (bluetoothWarning && !pipeAudio) "on" else "off"}",
|
||||||
|
"Location ${if (locationWarning) "on" else "off"}",
|
||||||
|
)
|
||||||
|
}.filter { it.isNotEmpty() }.joinToString(" · ")
|
||||||
|
|
||||||
|
Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Text(
|
||||||
|
"Car",
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
|
modifier = Modifier.padding(start = 4.dp),
|
||||||
|
)
|
||||||
|
SettingsCard {
|
||||||
|
CarGroup.entries.forEachIndexed { i, group ->
|
||||||
|
if (i > 0) SettingsDivider()
|
||||||
|
SettingsRow(
|
||||||
|
group.title,
|
||||||
|
Modifier.clickable { onOpen(group) },
|
||||||
|
subtitle = summary(group),
|
||||||
|
trailing = { Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One group of the car's settings, on a screen of its own. */
|
||||||
|
@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
internal fun CarGroupScreen(group: CarGroup, onBack: () -> Unit) {
|
||||||
|
androidx.compose.material3.Scaffold(
|
||||||
|
containerColor = MaterialTheme.colorScheme.background,
|
||||||
|
topBar = {
|
||||||
|
androidx.compose.material3.TopAppBar(
|
||||||
|
title = { Text(group.title) },
|
||||||
|
navigationIcon = {
|
||||||
|
androidx.compose.material3.IconButton(onClick = onBack) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { padding ->
|
||||||
|
Column(
|
||||||
|
Modifier.fillMaxSize().padding(padding).padding(horizontal = 24.dp)
|
||||||
|
.verticalScroll(androidx.compose.foundation.rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
SettingsCard { GroupRows(group) }
|
||||||
|
Text(
|
||||||
|
"These are the same settings as Settings in the car. A change here shows there too.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun GroupRows(group: CarGroup) {
|
||||||
|
val scale by CarSettings.scale.collectAsState()
|
||||||
|
val open by CarSettings.openOnConnect.collectAsState()
|
||||||
|
val night by CarSettings.nightMode.collectAsState()
|
||||||
|
val units by CarSettings.units.collectAsState()
|
||||||
|
val navBar by CarSettings.navBarMode.collectAsState()
|
||||||
|
val pipeAudio by CarSettings.pipeAudio.collectAsState()
|
||||||
|
val speakDirections by CarSettings.speakDirections.collectAsState()
|
||||||
|
val systemSpeech by CarSettings.systemSpeech.collectAsState()
|
||||||
|
val bluetoothWarning by CarSettings.bluetoothWarning.collectAsState()
|
||||||
|
val locationWarning by CarSettings.locationWarning.collectAsState()
|
||||||
|
val autoplay by CarSettings.autoplay.collectAsState()
|
||||||
|
val playerTitle by CarSettings.playerTitle.collectAsState()
|
||||||
|
val playerLeft by CarSettings.playerLeft.collectAsState()
|
||||||
|
val theme by CarSettings.appTheme.collectAsState()
|
||||||
|
val uiTheme by CarSettings.uiTheme.collectAsState()
|
||||||
|
|
||||||
|
when (group) {
|
||||||
|
CarGroup.SCREEN -> {
|
||||||
|
Choice("Open on connect", openChoices(), open, CarSettings::setOpenOnConnect)
|
||||||
|
SettingsDivider()
|
||||||
|
Choice("Size", sizeChoices, scale, CarSettings::setScale)
|
||||||
|
SettingsDivider()
|
||||||
|
Choice("App theme", themeChoices, theme, CarSettings::setAppTheme)
|
||||||
|
SettingsDivider()
|
||||||
|
Choice("Buttons", GsThemes.builtIn.map { it.id to it.name }, uiTheme, CarSettings::setUiTheme)
|
||||||
|
SettingsDivider()
|
||||||
|
Choice("Map day/night", nightChoices, night, CarSettings::setNightMode)
|
||||||
|
SettingsDivider()
|
||||||
|
Choice("Units", unitChoices, units, CarSettings::setUnits)
|
||||||
|
}
|
||||||
|
CarGroup.NAV_BAR -> {
|
||||||
|
Choice("Nav bar", navBarChoices, navBar, CarSettings::setNavBarMode)
|
||||||
|
SettingsDivider()
|
||||||
|
Choice("Player in the nav bar", listOf(false to "Skip buttons", true to "Song title"), playerTitle, CarSettings::setPlayerTitle)
|
||||||
|
SettingsDivider()
|
||||||
|
Choice("Player beside the map", listOf(false to "Right", true to "Left"), playerLeft, CarSettings::setPlayerLeft)
|
||||||
|
SettingsDivider()
|
||||||
|
Toggle("Autoplay on connect", autoplay, CarSettings::setAutoplay)
|
||||||
|
}
|
||||||
|
CarGroup.SOUND -> {
|
||||||
|
Choice("Car audio", listOf(false to "Bluetooth", true to "USB"), pipeAudio, CarSettings::setPipeAudio)
|
||||||
|
SettingsDivider()
|
||||||
|
Toggle(
|
||||||
|
"Spoken directions",
|
||||||
|
speakDirections,
|
||||||
|
CarSettings::setSpeakDirections,
|
||||||
|
note = "Turn it off if your map app's own voice already plays in the car.",
|
||||||
|
)
|
||||||
|
SettingsDivider()
|
||||||
|
Toggle(
|
||||||
|
"Use the phone's speech recognition",
|
||||||
|
systemSpeech,
|
||||||
|
CarSettings::setSystemSpeech,
|
||||||
|
note = "On most phones that's Google's, which may send your voice to Google.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
CarGroup.WARNINGS -> {
|
||||||
|
if (!pipeAudio) {
|
||||||
|
Toggle("Warn when Bluetooth isn't connected", bluetoothWarning, CarSettings::setBluetoothWarning)
|
||||||
|
SettingsDivider()
|
||||||
|
}
|
||||||
|
Toggle("Warn when location is off", locationWarning, CarSettings::setLocationWarning)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun <T> List<Pair<T, String>>.label(value: T): String = firstOrNull { it.first == value }?.second.orEmpty()
|
||||||
|
|
||||||
|
/** A setting with a few named choices: its current one shown, the rest a tap away. */
|
||||||
|
@Composable
|
||||||
|
private fun <T> Choice(title: String, choices: List<Pair<T, String>>, selected: T, onSelect: (T) -> Unit) {
|
||||||
|
var menu by remember { mutableStateOf(false) }
|
||||||
|
SettingsRow(
|
||||||
|
title,
|
||||||
|
Modifier.clickable { menu = true },
|
||||||
|
trailing = {
|
||||||
|
// The menu hangs from the value it changes, at the row's end.
|
||||||
|
Box {
|
||||||
|
Text(choices.label(selected), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.primary)
|
||||||
|
DropdownMenu(menu, onDismissRequest = { menu = false }) {
|
||||||
|
choices.forEach { (value, label) ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(label, fontWeight = if (value == selected) FontWeight.Bold else FontWeight.Normal) },
|
||||||
|
onClick = {
|
||||||
|
onSelect(value)
|
||||||
|
menu = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Toggle(title: String, on: Boolean, onChange: (Boolean) -> Unit, note: String? = null) {
|
||||||
|
SettingsRow(
|
||||||
|
title,
|
||||||
|
Modifier.clickable { onChange(!on) },
|
||||||
|
subtitle = note,
|
||||||
|
trailing = { Switch(checked = on, onCheckedChange = null) },
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import app.seb3thehacker.gearslip.speech.SpeechModels
|
|||||||
import androidx.compose.material3.LinearProgressIndicator
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -27,6 +28,8 @@ import androidx.compose.foundation.rememberScrollState
|
|||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.List
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.Send
|
||||||
import androidx.compose.material.icons.filled.Info
|
import androidx.compose.material.icons.filled.Info
|
||||||
import androidx.compose.material.icons.filled.Send
|
import androidx.compose.material.icons.filled.Send
|
||||||
import androidx.compose.material.icons.filled.List
|
import androidx.compose.material.icons.filled.List
|
||||||
@@ -60,10 +63,12 @@ import app.seb3thehacker.gearslip.stats.WorkedPrompt
|
|||||||
@Composable
|
@Composable
|
||||||
fun HomeScreen(
|
fun HomeScreen(
|
||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
onOpenWhatsNew: () -> Unit,
|
|
||||||
onOpenHelp: () -> Unit,
|
onOpenHelp: () -> Unit,
|
||||||
onOpenUsageNotes: () -> Unit,
|
onOpenUsageNotes: () -> Unit,
|
||||||
|
onOpenCarPreview: () -> Unit,
|
||||||
|
onOpenLogs: () -> Unit,
|
||||||
onDisconnect: () -> Unit,
|
onDisconnect: () -> Unit,
|
||||||
|
onOpenCarGroup: (CarGroup) -> Unit,
|
||||||
) {
|
) {
|
||||||
val status by SessionStatus.state.collectAsStateWithLifecycle()
|
val status by SessionStatus.state.collectAsStateWithLifecycle()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@@ -121,25 +126,18 @@ fun HomeScreen(
|
|||||||
}
|
}
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
UpdateCard()
|
UpdateCard()
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
HomeCarSettings(onOpenCarGroup)
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sits at the bottom, where a thumb reaches it.
|
// Sits at the bottom, where a thumb reaches it.
|
||||||
Column(Modifier.padding(vertical = 16.dp)) {
|
Column(Modifier.padding(vertical = 16.dp)) {
|
||||||
SettingsCard {
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
SettingsRow(
|
HomeShortcut(Icons.Filled.Info, "Help", onOpenHelp, Modifier.weight(1f))
|
||||||
"What's new in ${Intro.release}",
|
// The preview draws the car screen on the phone, so it waits until no car is using it.
|
||||||
Modifier.clickable(onClick = onOpenWhatsNew),
|
if (!live) HomeShortcut(Icons.Filled.PlayArrow, "Preview", onOpenCarPreview, Modifier.weight(1f))
|
||||||
icon = Icons.Filled.Star,
|
HomeShortcut(Icons.AutoMirrored.Filled.List, "Logs", onOpenLogs, Modifier.weight(1f))
|
||||||
trailing = { Arrow() },
|
|
||||||
)
|
|
||||||
SettingsDivider()
|
|
||||||
SettingsRow(
|
|
||||||
"Which cars work",
|
|
||||||
Modifier.clickable(onClick = onOpenHelp),
|
|
||||||
subtitle = "Most do. See which ones, and what to set up first",
|
|
||||||
icon = Icons.Filled.Info,
|
|
||||||
trailing = { Arrow() },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
if (live) {
|
if (live) {
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
@@ -157,6 +155,27 @@ fun HomeScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One of Home's shortcuts at the bottom: an icon over a one-word label, small enough that all fit in a row. */
|
||||||
|
@Composable
|
||||||
|
private fun HomeShortcut(icon: androidx.compose.ui.graphics.vector.ImageVector, label: String, onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||||
|
Surface(
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = modifier.heightIn(min = 64.dp),
|
||||||
|
shape = MaterialTheme.shapes.large,
|
||||||
|
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
Modifier.padding(vertical = 10.dp, horizontal = 4.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
Icon(icon, contentDescription = null, modifier = Modifier.size(22.dp))
|
||||||
|
Text(label, style = MaterialTheme.typography.labelMedium, maxLines = 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Bluetooth audio is chosen, but the phone isn't connected to the car. See [BluetoothAudioCheck]. */
|
/** Bluetooth audio is chosen, but the phone isn't connected to the car. See [BluetoothAudioCheck]. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun BluetoothMissingCard() {
|
private fun BluetoothMissingCard() {
|
||||||
|
|||||||
@@ -1,877 +0,0 @@
|
|||||||
package app.seb3thehacker.gearslip.ui
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.net.Uri
|
|
||||||
import android.provider.Settings
|
|
||||||
import androidx.activity.compose.BackHandler
|
|
||||||
import androidx.compose.animation.core.Animatable
|
|
||||||
import androidx.compose.animation.core.CubicBezierEasing
|
|
||||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
|
||||||
import androidx.compose.animation.core.LinearEasing
|
|
||||||
import androidx.compose.animation.core.RepeatMode
|
|
||||||
import androidx.compose.animation.core.animateDpAsState
|
|
||||||
import androidx.compose.animation.core.animateFloat
|
|
||||||
import androidx.compose.animation.core.infiniteRepeatable
|
|
||||||
import androidx.compose.animation.core.keyframes
|
|
||||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
|
||||||
import androidx.compose.animation.core.tween
|
|
||||||
import androidx.compose.foundation.Canvas
|
|
||||||
import androidx.compose.foundation.BorderStroke
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.border
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.offset
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.layout.wrapContentWidth
|
|
||||||
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.getValue
|
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.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.Path
|
|
||||||
import androidx.compose.ui.graphics.StrokeCap
|
|
||||||
import androidx.compose.ui.graphics.StrokeJoin
|
|
||||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
|
||||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
|
||||||
import androidx.compose.ui.graphics.drawscope.rotate
|
|
||||||
import androidx.compose.ui.graphics.drawscope.scale
|
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
|
||||||
import androidx.compose.ui.text.TextStyle
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.unit.Dp
|
|
||||||
import androidx.compose.ui.unit.TextUnit
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import app.seb3thehacker.gearslip.BuildConfig
|
|
||||||
import app.seb3thehacker.gearslip.CertProvider
|
|
||||||
import app.seb3thehacker.gearslip.AppSettings
|
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.ui.draw.clipToBounds
|
|
||||||
import androidx.compose.ui.draw.drawWithContent
|
|
||||||
import androidx.compose.ui.graphics.BlendMode
|
|
||||||
import androidx.compose.ui.graphics.Brush
|
|
||||||
import androidx.compose.ui.graphics.CompositingStrategy
|
|
||||||
import androidx.compose.foundation.layout.wrapContentHeight
|
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
|
||||||
import androidx.compose.material.icons.filled.Search
|
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
|
||||||
import app.seb3thehacker.gearslip.car.CarSettings
|
|
||||||
import app.seb3thehacker.gearslip.car.theme.GsButton
|
|
||||||
import app.seb3thehacker.gearslip.car.theme.GsIconButton
|
|
||||||
import app.seb3thehacker.gearslip.car.theme.GsThemes
|
|
||||||
import app.seb3thehacker.gearslip.car.theme.GsTone
|
|
||||||
import app.seb3thehacker.gearslip.car.theme.LocalGsTheme
|
|
||||||
import app.seb3thehacker.gearslip.speech.SpeechModels
|
|
||||||
import app.seb3thehacker.gearslip.stats.UsageStats
|
|
||||||
import kotlin.math.PI
|
|
||||||
import kotlin.math.cos
|
|
||||||
import kotlin.math.atan2
|
|
||||||
import kotlin.math.sqrt
|
|
||||||
import kotlin.math.sin
|
|
||||||
import kotlin.random.Random
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The release intro: a few screens shown once after updating to a new release, before setup. New
|
|
||||||
* installs skip it. Its look follows the 0.3 mockup: Material shapes in the phone's own colours,
|
|
||||||
* with every size taken from the screen's width so it scales from small phones to tablets.
|
|
||||||
*/
|
|
||||||
object Intro {
|
|
||||||
private const val PREFS = "intro"
|
|
||||||
private const val KEY_SEEN = "seen_release"
|
|
||||||
private const val KEY_STEP = "step"
|
|
||||||
|
|
||||||
/** "0.3" from "0.3.00-alpha": the intro belongs to a release, not to each patch. */
|
|
||||||
val release: String = BuildConfig.VERSION_NAME.split('.').take(2).joinToString(".")
|
|
||||||
|
|
||||||
/**
|
|
||||||
* True once per release for people who used Gearslip before. A new install, or one with its
|
|
||||||
* data cleared, gets the setup guide instead: there's nothing "new" yet, so this release
|
|
||||||
* counts as seen and the next one shows. Install dates can't tell those apart, since
|
|
||||||
* clearing data keeps them, so this goes by what setup left behind; 0.2 saved the same.
|
|
||||||
*/
|
|
||||||
fun due(context: Context): Boolean {
|
|
||||||
if (context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString(KEY_SEEN, null) == release) return false
|
|
||||||
val usedBefore = AppSettings.hasSeenPermissionsSetup(context) ||
|
|
||||||
AppSettings.hasSkippedCertSetup(context) || CertProvider.hasAnyCert(context)
|
|
||||||
if (!usedBefore) {
|
|
||||||
markSeen(context)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
fun markSeen(context: Context) {
|
|
||||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
|
|
||||||
.putString(KEY_SEEN, release)
|
|
||||||
.remove(KEY_STEP)
|
|
||||||
.apply()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A replay from Settings starts at the first page. */
|
|
||||||
fun replay(context: Context) {
|
|
||||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().remove(KEY_STEP).apply()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The page the intro was on, so an app restart part-way through resumes there. */
|
|
||||||
internal fun savedStep(context: Context): String? =
|
|
||||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString(KEY_STEP, null)
|
|
||||||
|
|
||||||
internal fun saveStep(context: Context, step: String) {
|
|
||||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putString(KEY_STEP, step).apply()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The intro's tones, taken from the phone's own Material You colours like the rest of the app. */
|
|
||||||
internal class IntroColors(
|
|
||||||
val surface: Color, val onSurface: Color, val onVariant: Color, val outline: Color,
|
|
||||||
val primary: Color, val onPrimary: Color, val primaryC: Color, val onPrimaryC: Color,
|
|
||||||
val tertiaryC: Color, val onTertiaryC: Color, val secondaryC: Color, val onSecondaryC: Color,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
internal fun introColors(): IntroColors {
|
|
||||||
val m = MaterialTheme.colorScheme
|
|
||||||
return IntroColors(
|
|
||||||
surface = m.surface, onSurface = m.onSurface, onVariant = m.onSurfaceVariant, outline = m.outline,
|
|
||||||
primary = m.primary, onPrimary = m.onPrimary, primaryC = m.primaryContainer, onPrimaryC = m.onPrimaryContainer,
|
|
||||||
tertiaryC = m.tertiaryContainer, onTertiaryC = m.onTertiaryContainer,
|
|
||||||
secondaryC = m.secondaryContainer, onSecondaryC = m.onSecondaryContainer,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** In the order shown: what's new, what to pick, housekeeping, then the people behind it. */
|
|
||||||
private enum class IntroStep { WELCOME, WIRELESS, KNOBS, VOICE, THEME, APPS, GRAPHENE, CHAT, THANKS }
|
|
||||||
|
|
||||||
private val Pop = CubicBezierEasing(0.3f, 1.6f, 0.5f, 1f)
|
|
||||||
private val Rise = CubicBezierEasing(0.2f, 0.8f, 0.2f, 1f)
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun IntroScreen(onDone: () -> Unit) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
val c = introColors()
|
|
||||||
// The GrapheneOS screen is only news to people running it.
|
|
||||||
val steps = remember {
|
|
||||||
IntroStep.entries.filter { it != IntroStep.GRAPHENE || UsageStats.osName(context) == "GrapheneOS" }
|
|
||||||
}
|
|
||||||
// Turning exploit protection back on restarts the app, so the intro picks up where it was.
|
|
||||||
var index by remember {
|
|
||||||
mutableIntStateOf(steps.indexOfFirst { it.name == Intro.savedStep(context) }.coerceAtLeast(0))
|
|
||||||
}
|
|
||||||
val step = steps[index]
|
|
||||||
LaunchedEffect(step) { Intro.saveStep(context, step.name) }
|
|
||||||
val finish = {
|
|
||||||
Intro.markSeen(context)
|
|
||||||
onDone()
|
|
||||||
}
|
|
||||||
BackHandler(enabled = index > 0) { index-- }
|
|
||||||
|
|
||||||
BoxWithConstraints(Modifier.fillMaxSize().background(c.surface)) {
|
|
||||||
val w = maxWidth
|
|
||||||
val h = maxHeight
|
|
||||||
// The mockup's cqw: one percent of the screen's width.
|
|
||||||
fun cq(n: Float): Dp = w * (n / 100f)
|
|
||||||
val density = LocalDensity.current
|
|
||||||
fun cqSp(n: Float): TextUnit = with(density) { cq(n).toSp() }
|
|
||||||
|
|
||||||
// The step on top, Back and Next under it, the same as the setup guide.
|
|
||||||
Column(Modifier.fillMaxSize()) {
|
|
||||||
Box(Modifier.weight(1f).fillMaxWidth()) {
|
|
||||||
when (step) {
|
|
||||||
IntroStep.WELCOME -> Welcome(c, ::cq, ::cqSp, h)
|
|
||||||
IntroStep.WIRELESS -> BadgeStep(c, ::cq, ::cqSp, c.secondaryC, "Wireless.",
|
|
||||||
"If your car does wireless Android Auto, pair once and leave the cable at home.", step) { WifiIcon(c.onSecondaryC) }
|
|
||||||
IntroStep.KNOBS -> BadgeStep(c, ::cq, ::cqSp, c.tertiaryC, "Knobs work.",
|
|
||||||
"Turn, push, done. No touchscreen? Gearslip doesn't need one anymore.", step) { DialIcon(c.onTertiaryC) }
|
|
||||||
IntroStep.VOICE -> VoiceStep(c, ::cq, ::cqSp, step)
|
|
||||||
IntroStep.GRAPHENE -> BadgeStep(c, ::cq, ::cqSp, c.secondaryC, "Protections back on.",
|
|
||||||
"On GrapheneOS, Gearslip no longer needs exploit protection compatibility mode. If you " +
|
|
||||||
"turned it on for Gearslip, turn it off to get full protection back.", step,
|
|
||||||
action = "Open app settings" to {
|
|
||||||
runCatching {
|
|
||||||
context.startActivity(
|
|
||||||
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", context.packageName, null)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
outlined = true,
|
|
||||||
) { ShieldIcon(c.onSecondaryC, c.secondaryC) }
|
|
||||||
IntroStep.THANKS -> BadgeStep(c, ::cq, ::cqSp, c.tertiaryC, "Thank you.",
|
|
||||||
"Thanks to your usage notes, Gearslip is confirmed to work in cars newer than 2020. " +
|
|
||||||
"Each note shows which cars work and which still fail, so I know what to fix next.", step,
|
|
||||||
action = "See which cars work" to { GearslipLinks.open(context, GearslipLinks.CARS) },
|
|
||||||
outlined = true,
|
|
||||||
secondAction = "Support on Liberapay" to { GearslipLinks.open(context, GearslipLinks.LIBERAPAY) },
|
|
||||||
) { HeartIcon(c.onTertiaryC) }
|
|
||||||
IntroStep.CHAT -> BadgeStep(c, ::cq, ::cqSp, c.primaryC, "Come say hi.",
|
|
||||||
"Gearslip has a chat room on Matrix. Talk with other drivers about cars, apps, and " +
|
|
||||||
"where Gearslip is headed.", step,
|
|
||||||
action = "Join on Matrix" to { GearslipLinks.open(context, GearslipLinks.MATRIX) },
|
|
||||||
outlined = true,
|
|
||||||
) { ChatIcon(c.onPrimaryC, c.primaryC) }
|
|
||||||
IntroStep.APPS -> BadgeStep(c, ::cq, ::cqSp, c.secondaryC, "Good company.",
|
|
||||||
"Free, open-source apps that work well in the car. The first in each row works best. Tap one to see it on GitHub.", step,
|
|
||||||
extra = { AppPicks(c, ::cq, ::cqSp, step) }) { StarIcon(c.onSecondaryC) }
|
|
||||||
IntroStep.THEME -> BadgeStep(c, ::cq, ::cqSp, c.tertiaryC, "Your buttons.",
|
|
||||||
"Pick how the car's buttons look. Change it any time in the car's settings.", step,
|
|
||||||
extra = { ThemePicker(c, ::cq, ::cqSp, step) }) { ButtonsIcon(c.onTertiaryC) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Column(Modifier.fillMaxWidth().padding(horizontal = cq(8f))) {
|
|
||||||
Dots(c, steps.size, index, ::cq, Modifier.align(Alignment.CenterHorizontally))
|
|
||||||
Spacer(Modifier.height(cq(5f)))
|
|
||||||
PillRow(
|
|
||||||
c, ::cq, ::cqSp,
|
|
||||||
onBack = if (index > 0) ({ index-- }) else null,
|
|
||||||
label = if (index == steps.lastIndex) "Start" else "Next",
|
|
||||||
) { if (index < steps.lastIndex) index++ else finish() }
|
|
||||||
Spacer(Modifier.height(cq(6f)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 0 to 1 once each time [key] changes, after [delayMs]: drives the entrance of one element. */
|
|
||||||
@Composable
|
|
||||||
internal fun entrance(key: Any, delayMs: Int, durationMs: Int = 650): Float {
|
|
||||||
val a = remember(key) { Animatable(0f) }
|
|
||||||
LaunchedEffect(key) {
|
|
||||||
delay(delayMs.toLong())
|
|
||||||
a.animateTo(1f, tween(durationMs, easing = LinearEasing))
|
|
||||||
}
|
|
||||||
return a.value
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Slides up and fades in, like the mockup's `up`. */
|
|
||||||
internal fun Modifier.up(p: Float, distance: Dp) = graphicsLayer {
|
|
||||||
val e = Rise.transform(p)
|
|
||||||
alpha = e
|
|
||||||
translationY = (1 - e) * distance.toPx()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Grows in with a bounce, like the mockup's `bounce`. */
|
|
||||||
internal fun Modifier.pop(p: Float) = graphicsLayer {
|
|
||||||
val e = Pop.transform(p)
|
|
||||||
alpha = p.coerceAtMost(1f)
|
|
||||||
scaleX = 0.5f + 0.5f * e
|
|
||||||
scaleY = 0.5f + 0.5f * e
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun Welcome(c: IntroColors, cq: (Float) -> Dp, cqSp: (Float) -> TextUnit, height: Dp) {
|
|
||||||
val key = IntroStep.WELCOME
|
|
||||||
val spin by rememberInfiniteTransition(label = "spin").animateFloat(
|
|
||||||
0f, 360f, infiniteRepeatable(tween(18_000, easing = LinearEasing)), label = "spin",
|
|
||||||
)
|
|
||||||
Box(Modifier.fillMaxSize()) {
|
|
||||||
Cookie(c.primaryC, Modifier.size(cq(120f)).offset(x = cq(28f), y = -cq(22f)).align(Alignment.TopEnd).graphicsLayer { rotationZ = spin; alpha = 0.9f })
|
|
||||||
Ticker(c.secondaryC, c.onSecondaryC, "OPEN SOURCE ✦ NO ACCOUNT ✦ YOUR CAR, YOUR PHONE ✦ ", cq, cqSp, reverse = true, tilt = 3f,
|
|
||||||
key = key, modifier = Modifier.align(Alignment.TopCenter).padding(top = height * 0.11f))
|
|
||||||
|
|
||||||
Column(Modifier.align(Alignment.CenterStart).padding(horizontal = cq(8f))) {
|
|
||||||
Text("Welcome to", color = c.onVariant, style = TextStyle(fontSize = cqSp(4.2f), fontWeight = FontWeight.SemiBold),
|
|
||||||
modifier = Modifier.up(entrance(key, 100), cq(7f)))
|
|
||||||
Text(
|
|
||||||
Intro.release, color = c.primary,
|
|
||||||
style = TextStyle(fontSize = cqSp(44f), fontWeight = FontWeight.Black, letterSpacing = cqSp(-2.4f), lineHeight = cqSp(38f)),
|
|
||||||
modifier = Modifier.offset(x = -cq(1.5f)).pop(entrance(key, 250, 900)),
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
"alpha · the big one", color = c.onTertiaryC,
|
|
||||||
style = TextStyle(fontSize = cqSp(4f), fontWeight = FontWeight.Bold),
|
|
||||||
modifier = Modifier.padding(top = cq(4f)).up(entrance(key, 700), cq(7f))
|
|
||||||
.clip(RoundedCornerShape(50)).background(c.tertiaryC).padding(horizontal = cq(4.4f), vertical = cq(2.2f)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Confetti(c, key, Modifier.fillMaxSize())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A tilted band of words scrolling past, slid in from the side when the step opens. */
|
|
||||||
@Composable
|
|
||||||
private fun Ticker(
|
|
||||||
bg: Color, fg: Color, words: String, cq: (Float) -> Dp, cqSp: (Float) -> TextUnit,
|
|
||||||
reverse: Boolean, tilt: Float, key: Any, modifier: Modifier,
|
|
||||||
) {
|
|
||||||
val enter = entrance(key, 400, 800)
|
|
||||||
val scroll by rememberInfiniteTransition(label = "ticker").animateFloat(
|
|
||||||
0f, 1f, infiniteRepeatable(tween(if (reverse) 20_000 else 16_000, easing = LinearEasing)), label = "ticker",
|
|
||||||
)
|
|
||||||
Box(
|
|
||||||
modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.graphicsLayer {
|
|
||||||
rotationZ = tilt
|
|
||||||
scaleX = 1.2f
|
|
||||||
val e = Rise.transform(enter)
|
|
||||||
alpha = e
|
|
||||||
translationX = (1 - e) * -cq(30f).toPx()
|
|
||||||
}
|
|
||||||
.background(bg)
|
|
||||||
.padding(vertical = cq(2.6f)),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
words.repeat(4),
|
|
||||||
color = fg, maxLines = 1, softWrap = false,
|
|
||||||
style = TextStyle(fontSize = cqSp(5f), fontWeight = FontWeight.ExtraBold),
|
|
||||||
modifier = Modifier
|
|
||||||
.wrapContentWidth(Alignment.Start, unbounded = true)
|
|
||||||
.graphicsLayer {
|
|
||||||
// The text holds the words four times; sliding by half loops cleanly.
|
|
||||||
val shift = size.width / 2f * (if (reverse) 1 - scroll else scroll)
|
|
||||||
translationX = -shift
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Material shapes thrown once from behind the version number. */
|
|
||||||
@Composable
|
|
||||||
private fun Confetti(c: IntroColors, key: Any, modifier: Modifier) {
|
|
||||||
val p = entrance(key, 450, 1300)
|
|
||||||
val bits = remember {
|
|
||||||
val r = Random(7)
|
|
||||||
List(22) { i ->
|
|
||||||
val a = i / 22f * 2 * PI.toFloat() + r.nextFloat() * 0.4f
|
|
||||||
val d = 30f + r.nextFloat() * 45f
|
|
||||||
Bit(cos(a) * d, sin(a) * d * 1.2f, r.nextInt(-270, 270).toFloat(), i % 4, i % 3)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (p <= 0f || p >= 1f) return
|
|
||||||
val tones = listOf(c.primary, c.primaryC, c.tertiaryC, c.secondaryC)
|
|
||||||
Canvas(modifier) {
|
|
||||||
val unit = size.width / 100f
|
|
||||||
val e = CubicBezierEasing(0.15f, 0.7f, 0.3f, 1f).transform(p)
|
|
||||||
val alpha = if (p < 0.7f) 1f else 1f - (p - 0.7f) / 0.3f
|
|
||||||
val origin = Offset(size.width * 0.3f, size.height * 0.42f)
|
|
||||||
bits.forEach { b ->
|
|
||||||
val at = origin + Offset(b.dx * unit * e, b.dy * unit * e)
|
|
||||||
val s = 3.6f * unit * (0.3f + 0.7f * e)
|
|
||||||
rotate(b.spin * e, at) {
|
|
||||||
val color = tones[b.tone].copy(alpha = alpha)
|
|
||||||
when (b.shape) {
|
|
||||||
0 -> drawCircle(color, s / 2, at)
|
|
||||||
1 -> drawRoundRect(color, at - Offset(s / 2, s / 2), Size(s, s), androidx.compose.ui.geometry.CornerRadius(unit))
|
|
||||||
else -> drawRoundRect(color, at - Offset(s / 2, s / 4), Size(s, s / 2), androidx.compose.ui.geometry.CornerRadius(s / 4))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class Bit(val dx: Float, val dy: Float, val spin: Float, val tone: Int, val shape: Int)
|
|
||||||
|
|
||||||
/** One idea per screen: a spinning cookie with an icon, a headline, a line of text, maybe a button. */
|
|
||||||
@Composable
|
|
||||||
private fun BadgeStep(
|
|
||||||
c: IntroColors, cq: (Float) -> Dp, cqSp: (Float) -> TextUnit, badge: Color,
|
|
||||||
title: String, body: String, key: Any,
|
|
||||||
action: Pair<String, () -> Unit>? = null, outlined: Boolean = false,
|
|
||||||
secondAction: Pair<String, () -> Unit>? = null,
|
|
||||||
extra: (@Composable () -> Unit)? = null,
|
|
||||||
icon: @Composable () -> Unit,
|
|
||||||
) {
|
|
||||||
val spin by rememberInfiniteTransition(label = "spin").animateFloat(
|
|
||||||
0f, 360f, infiniteRepeatable(tween(18_000, easing = LinearEasing)), label = "spin",
|
|
||||||
)
|
|
||||||
Column(
|
|
||||||
Modifier.fillMaxSize().padding(horizontal = cq(8f)),
|
|
||||||
verticalArrangement = Arrangement.Center,
|
|
||||||
) {
|
|
||||||
Box(Modifier.size(cq(34f)).pop(entrance(key, 50, 800)), contentAlignment = Alignment.Center) {
|
|
||||||
Cookie(badge, Modifier.fillMaxSize().graphicsLayer { rotationZ = spin })
|
|
||||||
Box(Modifier.size(cq(15f))) { icon() }
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(cq(8f)))
|
|
||||||
Text(
|
|
||||||
title, color = c.onSurface,
|
|
||||||
style = TextStyle(fontSize = cqSp(13.5f), fontWeight = FontWeight.Black, letterSpacing = cqSp(-0.6f), lineHeight = cqSp(12.5f)),
|
|
||||||
modifier = Modifier.up(entrance(key, 200, 700), cq(7f)),
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(cq(4f)))
|
|
||||||
Text(
|
|
||||||
body, color = c.onVariant,
|
|
||||||
style = TextStyle(fontSize = cqSp(4.5f), lineHeight = cqSp(6.5f)),
|
|
||||||
modifier = Modifier.up(entrance(key, 350, 700), cq(7f)),
|
|
||||||
)
|
|
||||||
extra?.invoke()
|
|
||||||
listOfNotNull(action, secondAction).forEachIndexed { i, (label, onClick) ->
|
|
||||||
Spacer(Modifier.height(if (i > 0) cq(3f) else if (outlined) cq(7f) else cq(9f)))
|
|
||||||
Text(
|
|
||||||
label,
|
|
||||||
color = if (outlined) c.primary else c.onPrimary,
|
|
||||||
style = TextStyle(fontSize = cqSp(if (outlined) 4.4f else 4.6f), fontWeight = FontWeight.Bold),
|
|
||||||
modifier = Modifier
|
|
||||||
.up(entrance(key, 500 + i * 150, 700), cq(7f))
|
|
||||||
.clip(RoundedCornerShape(50))
|
|
||||||
.then(
|
|
||||||
if (outlined) Modifier.border(BorderStroke(2.dp, c.outline), RoundedCornerShape(50))
|
|
||||||
else Modifier.background(c.primary),
|
|
||||||
)
|
|
||||||
.clickable(onClick = onClick)
|
|
||||||
.padding(horizontal = if (outlined) cq(7f) else cq(9f), vertical = if (outlined) cq(3.6f) else cq(4.2f)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Voice: a few things to say, drifting past, and a button that fetches both speech models so the
|
|
||||||
* assistant works the first time someone tries it in the car.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun VoiceStep(c: IntroColors, cq: (Float) -> Dp, cqSp: (Float) -> TextUnit, key: IntroStep) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
remember { SpeechModels.init(context) }
|
|
||||||
val needed = SpeechModels.RECOMMENDED
|
|
||||||
val states = needed.map { SpeechModels.state(it).collectAsState().value }
|
|
||||||
val ready = states.all { it == SpeechModels.State.Ready }
|
|
||||||
val label = when {
|
|
||||||
states.any { it is SpeechModels.State.Downloading } ->
|
|
||||||
"Downloading, ${needed.zip(states).sumOf { (m, st) -> m.downloadMb * percentOf(st) } / needed.sumOf { it.downloadMb }}%"
|
|
||||||
states.any { it is SpeechModels.State.Failed } -> "Download failed. Try again"
|
|
||||||
else -> "Download voice, ${needed.sumOf { it.downloadMb }} MB"
|
|
||||||
}
|
|
||||||
BadgeStep(
|
|
||||||
c, cq, cqSp, c.primaryC, "Just say it.",
|
|
||||||
"Tap the mic, or press the talk button on your wheel, and speak. Your phone turns speech " +
|
|
||||||
"into text by itself, and nothing you say leaves it. Say \"help\" in the car for the full list.",
|
|
||||||
key,
|
|
||||||
action = if (ready) null else label to { SpeechModels.downloadRecommended(context) },
|
|
||||||
outlined = true,
|
|
||||||
extra = { CommandDrift(c, cq, cqSp, key) },
|
|
||||||
) { MicIcon(c.onPrimaryC) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun percentOf(state: SpeechModels.State) = when (state) {
|
|
||||||
SpeechModels.State.Ready -> 100
|
|
||||||
is SpeechModels.State.Downloading -> state.percent
|
|
||||||
else -> 0
|
|
||||||
}
|
|
||||||
|
|
||||||
private val SAMPLE_COMMANDS = listOf(
|
|
||||||
"Play Daft Punk", "What's playing?", "Next song",
|
|
||||||
"Call Mom", "Open Organic Maps", "Pause", "Help",
|
|
||||||
)
|
|
||||||
|
|
||||||
/** Example commands rising slowly through a window that fades at both edges. */
|
|
||||||
@Composable
|
|
||||||
private fun CommandDrift(c: IntroColors, cq: (Float) -> Dp, cqSp: (Float) -> TextUnit, key: Any) {
|
|
||||||
val scroll by rememberInfiniteTransition(label = "drift").animateFloat(
|
|
||||||
0f, 1f, infiniteRepeatable(tween(SAMPLE_COMMANDS.size * 2_200, easing = LinearEasing)), label = "drift",
|
|
||||||
)
|
|
||||||
Box(
|
|
||||||
Modifier
|
|
||||||
.padding(top = cq(6f))
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(cq(34f))
|
|
||||||
.up(entrance(key, 500, 700), cq(7f))
|
|
||||||
.graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen }
|
|
||||||
.drawWithContent {
|
|
||||||
drawContent()
|
|
||||||
drawRect(
|
|
||||||
Brush.verticalGradient(0f to Color.Transparent, 0.25f to Color.Black, 0.75f to Color.Black, 1f to Color.Transparent),
|
|
||||||
blendMode = BlendMode.DstIn,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.clipToBounds(),
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
Modifier
|
|
||||||
.wrapContentHeight(Alignment.Top, unbounded = true)
|
|
||||||
// The list is there twice; rising by half loops cleanly.
|
|
||||||
.graphicsLayer { translationY = -size.height / 2f * scroll },
|
|
||||||
verticalArrangement = Arrangement.spacedBy(cq(2.4f)),
|
|
||||||
) {
|
|
||||||
repeat(2) {
|
|
||||||
SAMPLE_COMMANDS.forEach { say ->
|
|
||||||
Text(
|
|
||||||
"\u201c$say\u201d", color = c.onSecondaryC,
|
|
||||||
style = TextStyle(fontSize = cqSp(4.4f), fontWeight = FontWeight.SemiBold),
|
|
||||||
modifier = Modifier
|
|
||||||
.clip(RoundedCornerShape(50))
|
|
||||||
.background(c.secondaryC)
|
|
||||||
.padding(horizontal = cq(4.4f), vertical = cq(2f)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Each kind of app with its picks, the first recommended most. */
|
|
||||||
private val APP_PICKS = listOf(
|
|
||||||
"Maps" to listOf(
|
|
||||||
"Organic Maps" to "https://github.com/organicmaps/organicmaps",
|
|
||||||
"Vela" to "https://github.com/PimpinPumpkin/Vela",
|
|
||||||
),
|
|
||||||
"Music" to listOf(
|
|
||||||
"ViVi Music" to "https://github.com/vivizzz007/vivi-music",
|
|
||||||
"Metrolist" to "https://github.com/MetrolistGroup/Metrolist",
|
|
||||||
),
|
|
||||||
"Podcasts" to listOf(
|
|
||||||
"AntennaPod" to "https://github.com/AntennaPod/AntennaPod",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
/** One row per kind, with a chip per app opening its GitHub page; the rows arrive one after another. */
|
|
||||||
@Composable
|
|
||||||
private fun AppPicks(c: IntroColors, cq: (Float) -> Dp, cqSp: (Float) -> TextUnit, key: IntroStep) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
Column(Modifier.padding(top = cq(6f)), verticalArrangement = Arrangement.spacedBy(cq(2.6f))) {
|
|
||||||
APP_PICKS.forEachIndexed { i, (kind, apps) ->
|
|
||||||
Column(
|
|
||||||
Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.up(entrance(key, 500 + i * 120, 700), cq(7f))
|
|
||||||
.clip(RoundedCornerShape(cq(5f)))
|
|
||||||
.background(c.secondaryC)
|
|
||||||
.padding(horizontal = cq(4f), vertical = cq(3f)),
|
|
||||||
) {
|
|
||||||
Text(kind.uppercase(), color = c.onSecondaryC.copy(alpha = 0.7f),
|
|
||||||
style = TextStyle(fontSize = cqSp(3.2f), fontWeight = FontWeight.Bold, letterSpacing = cqSp(0.3f)),
|
|
||||||
modifier = Modifier.padding(start = cq(1f), bottom = cq(1.6f)))
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(cq(2f))) {
|
|
||||||
apps.forEach { (name, url) ->
|
|
||||||
Text(
|
|
||||||
"$name ↗", color = c.onSecondaryC,
|
|
||||||
style = TextStyle(fontSize = cqSp(4.4f), fontWeight = FontWeight.ExtraBold),
|
|
||||||
maxLines = 1,
|
|
||||||
modifier = Modifier
|
|
||||||
.clip(RoundedCornerShape(50))
|
|
||||||
.border(BorderStroke(1.5.dp, c.onSecondaryC.copy(alpha = 0.35f)), RoundedCornerShape(50))
|
|
||||||
.clickable {
|
|
||||||
runCatching {
|
|
||||||
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(horizontal = cq(3.6f), vertical = cq(2f)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The car's button styles side by side, each drawn in its own style, and a row of real car
|
|
||||||
* controls below that redraws in whichever is picked. Picking saves it, so the car shows it too.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun ThemePicker(c: IntroColors, cq: (Float) -> Dp, cqSp: (Float) -> TextUnit, key: IntroStep) {
|
|
||||||
val chosenId by CarSettings.uiTheme.collectAsState()
|
|
||||||
val chosen = GsThemes.byId(chosenId)
|
|
||||||
// Flat first: it's the default.
|
|
||||||
val themes = listOf(GsThemes.Flat) + GsThemes.builtIn.filter { it != GsThemes.Flat }
|
|
||||||
Column(
|
|
||||||
Modifier.padding(top = cq(7f)).fillMaxWidth().up(entrance(key, 500, 700), cq(7f)),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(cq(6f)),
|
|
||||||
) {
|
|
||||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(cq(3f))) {
|
|
||||||
themes.forEach { theme ->
|
|
||||||
CompositionLocalProvider(LocalGsTheme provides theme) {
|
|
||||||
GsButton(
|
|
||||||
onClick = { CarSettings.setUiTheme(theme.id) },
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
tone = if (theme == chosen) GsTone.Primary else GsTone.Neutral,
|
|
||||||
contentPadding = PaddingValues(horizontal = cq(1f), vertical = cq(3f)),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
if (theme == chosen) "✓ ${theme.name}" else theme.name,
|
|
||||||
style = TextStyle(fontSize = cqSp(4.2f), fontWeight = FontWeight.Bold),
|
|
||||||
maxLines = 1,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// What the car shows: the player's controls and a full-width action.
|
|
||||||
CompositionLocalProvider(LocalGsTheme provides chosen) {
|
|
||||||
Row(
|
|
||||||
Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(cq(3f)),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
GsIconButton(Icons.Filled.Search, "Search", onClick = {}, size = cq(15f), iconSize = cq(7f))
|
|
||||||
GsIconButton(Icons.Filled.PlayArrow, "Play", onClick = {}, tone = GsTone.Primary, size = cq(15f), iconSize = cq(8f))
|
|
||||||
GsButton(
|
|
||||||
onClick = {},
|
|
||||||
modifier = Modifier.weight(1f).height(cq(15f)),
|
|
||||||
tone = GsTone.Tonal,
|
|
||||||
) { Text("Navigate", style = TextStyle(fontSize = cqSp(4.6f), fontWeight = FontWeight.Bold)) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Three keys of a keypad, pressing one after another. */
|
|
||||||
@Composable
|
|
||||||
private fun ButtonsIcon(color: Color) {
|
|
||||||
val t by rememberInfiniteTransition(label = "keys").animateFloat(
|
|
||||||
0f, 3f, infiniteRepeatable(tween(2400, easing = LinearEasing)), label = "keys",
|
|
||||||
)
|
|
||||||
Canvas(Modifier.fillMaxSize()) {
|
|
||||||
inIconBox {
|
|
||||||
listOf(6f, 19f, 32f).forEachIndexed { i, x ->
|
|
||||||
// Each key dips for a moment while its turn comes round.
|
|
||||||
val dip = (1f - kotlin.math.abs(t - i - 0.5f) * 2f).coerceIn(0f, 1f) * 4f
|
|
||||||
drawRoundRect(color.copy(alpha = 0.45f), Offset(x, 16f + 4f), Size(10f, 16f),
|
|
||||||
androidx.compose.ui.geometry.CornerRadius(3f))
|
|
||||||
drawRoundRect(color, Offset(x, 16f + dip), Size(10f, 16f), androidx.compose.ui.geometry.CornerRadius(3f))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Material's scalloped "cookie": a circle whose radius waves around its edge. */
|
|
||||||
@Composable
|
|
||||||
internal fun Cookie(color: Color, modifier: Modifier) {
|
|
||||||
Canvas(modifier) {
|
|
||||||
val unit = size.minDimension / 100f
|
|
||||||
val path = Path()
|
|
||||||
var deg = 0
|
|
||||||
while (deg <= 360) {
|
|
||||||
val a = deg * PI / 180
|
|
||||||
val r = (44 + 4.5 * cos(9 * a)) * unit
|
|
||||||
val x = center.x + (r * cos(a)).toFloat()
|
|
||||||
val y = center.y + (r * sin(a)).toFloat()
|
|
||||||
if (deg == 0) path.moveTo(x, y) else path.lineTo(x, y)
|
|
||||||
deg += 3
|
|
||||||
}
|
|
||||||
path.close()
|
|
||||||
drawPath(path, color)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Draws in the mockup's 48-unit icon box. */
|
|
||||||
private inline fun DrawScope.inIconBox(block: DrawScope.() -> Unit) {
|
|
||||||
val s = size.minDimension / 48f
|
|
||||||
scale(s, s, Offset.Zero) { block() }
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
internal fun WifiIcon(color: Color) {
|
|
||||||
val t = rememberInfiniteTransition(label = "wifi")
|
|
||||||
val waves = (0..2).map { i ->
|
|
||||||
t.animateFloat(
|
|
||||||
0.35f, 1f,
|
|
||||||
infiniteRepeatable(tween(800, delayMillis = 0, easing = FastOutSlowInEasing), RepeatMode.Reverse,
|
|
||||||
initialStartOffset = androidx.compose.animation.core.StartOffset(i * 200)),
|
|
||||||
label = "wave$i",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Canvas(Modifier.fillMaxSize()) {
|
|
||||||
inIconBox {
|
|
||||||
val stroke = Stroke(5f, cap = StrokeCap.Round)
|
|
||||||
// The mockup's three arcs, inner to outer: radius r through (24 ± half, y).
|
|
||||||
listOf(Triple(8f, 6f, 34f), Triple(18f, 13f, 26f), Triple(28f, 20f, 18f)).forEachIndexed { i, (r, half, y) ->
|
|
||||||
val cy = y + sqrt(r * r - half * half)
|
|
||||||
val start = Math.toDegrees(atan2((y - cy).toDouble(), (-half).toDouble())).toFloat()
|
|
||||||
val end = Math.toDegrees(atan2((y - cy).toDouble(), half.toDouble())).toFloat()
|
|
||||||
drawArc(color.copy(alpha = waves[i].value), start, (end - start + 360f) % 360f, false,
|
|
||||||
Offset(24f - r, cy - r), Size(2 * r, 2 * r), style = stroke)
|
|
||||||
}
|
|
||||||
drawCircle(color, 3.6f, Offset(24f, 41f))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun DialIcon(color: Color) {
|
|
||||||
val angle by rememberInfiniteTransition(label = "dial").animateFloat(
|
|
||||||
-70f, -70f,
|
|
||||||
infiniteRepeatable(keyframes {
|
|
||||||
durationMillis = 2600
|
|
||||||
-70f at 0 using CubicBezierEasing(0.5f, 0f, 0.3f, 1f)
|
|
||||||
70f at 1040
|
|
||||||
70f at 1430 using CubicBezierEasing(0.5f, 0f, 0.3f, 1f)
|
|
||||||
-70f at 2470
|
|
||||||
}),
|
|
||||||
label = "dial",
|
|
||||||
)
|
|
||||||
Canvas(Modifier.fillMaxSize()) {
|
|
||||||
inIconBox {
|
|
||||||
drawCircle(color, 19f, Offset(24f, 24f), style = Stroke(5f))
|
|
||||||
rotate(angle, Offset(24f, 24f)) {
|
|
||||||
drawRoundRect(color, Offset(21.5f, 7f), Size(5f, 13f), androidx.compose.ui.geometry.CornerRadius(2.5f))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
internal fun MicIcon(color: Color) {
|
|
||||||
val t = rememberInfiniteTransition(label = "mic")
|
|
||||||
val waves = (0..1).map { i ->
|
|
||||||
t.animateFloat(
|
|
||||||
0.25f, 1f,
|
|
||||||
infiniteRepeatable(tween(700, easing = FastOutSlowInEasing), RepeatMode.Reverse,
|
|
||||||
initialStartOffset = androidx.compose.animation.core.StartOffset(i * 250)),
|
|
||||||
label = "mic$i",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Canvas(Modifier.fillMaxSize()) {
|
|
||||||
inIconBox {
|
|
||||||
drawRoundRect(color, Offset(18f, 5f), Size(12f, 22f), androidx.compose.ui.geometry.CornerRadius(6f))
|
|
||||||
val stroke = Stroke(4f, cap = StrokeCap.Round)
|
|
||||||
drawArc(color, 0f, 180f, false, Offset(12f, 14f), Size(24f, 22f), style = stroke)
|
|
||||||
drawLine(color, Offset(24f, 36f), Offset(24f, 42f), 4f, StrokeCap.Round)
|
|
||||||
// Sound waves either side, pulsing as if someone's talking.
|
|
||||||
listOf(1f to 0, -1f to 0, 1f to 1, -1f to 1).forEach { (side, i) ->
|
|
||||||
val r = if (i == 0) 15f else 21f
|
|
||||||
drawArc(color.copy(alpha = waves[i].value), if (side > 0) -35f else 145f, 70f, false,
|
|
||||||
Offset(24f - r, 16f - r), Size(2 * r, 2 * r), style = stroke)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
internal fun StarIcon(color: Color) {
|
|
||||||
val turn by rememberInfiniteTransition(label = "star").animateFloat(
|
|
||||||
-8f, 8f, infiniteRepeatable(tween(1600, easing = FastOutSlowInEasing), RepeatMode.Reverse), label = "star",
|
|
||||||
)
|
|
||||||
Canvas(Modifier.fillMaxSize()) {
|
|
||||||
inIconBox {
|
|
||||||
val star = Path()
|
|
||||||
for (i in 0 until 10) {
|
|
||||||
val r = if (i % 2 == 0) 20f else 8.5f
|
|
||||||
val a = (-90 + i * 36) * PI / 180
|
|
||||||
val x = 24f + (r * cos(a)).toFloat()
|
|
||||||
val y = 25f + (r * sin(a)).toFloat()
|
|
||||||
if (i == 0) star.moveTo(x, y) else star.lineTo(x, y)
|
|
||||||
}
|
|
||||||
star.close()
|
|
||||||
rotate(turn, Offset(24f, 25f)) { drawPath(star, color) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
internal fun ShieldIcon(color: Color, tick: Color) {
|
|
||||||
val p = entrance(IntroStep.GRAPHENE, 800, 500)
|
|
||||||
Canvas(Modifier.fillMaxSize()) {
|
|
||||||
inIconBox {
|
|
||||||
val shield = Path().apply {
|
|
||||||
moveTo(24f, 4f); lineTo(40f, 10f); lineTo(40f, 22f)
|
|
||||||
cubicTo(40f, 32f, 33f, 40f, 24f, 44f)
|
|
||||||
cubicTo(15f, 40f, 8f, 32f, 8f, 22f)
|
|
||||||
lineTo(8f, 10f); close()
|
|
||||||
}
|
|
||||||
drawPath(shield, color)
|
|
||||||
drawPartialStroke(listOf(Offset(16f, 24f), Offset(22f, 30f), Offset(33f, 18f)), p, tick, 4.5f)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A speech bubble with three dots that pulse, like someone typing. */
|
|
||||||
@Composable
|
|
||||||
private fun ChatIcon(color: Color, dots: Color) {
|
|
||||||
val t = rememberInfiniteTransition(label = "typing")
|
|
||||||
val pulse = (0..2).map { i ->
|
|
||||||
t.animateFloat(
|
|
||||||
0.35f, 1f,
|
|
||||||
infiniteRepeatable(tween(500, easing = FastOutSlowInEasing), RepeatMode.Reverse,
|
|
||||||
initialStartOffset = androidx.compose.animation.core.StartOffset(i * 170)),
|
|
||||||
label = "dot$i",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Canvas(Modifier.fillMaxSize()) {
|
|
||||||
inIconBox {
|
|
||||||
drawRoundRect(color, Offset(4f, 7f), Size(40f, 28f), CornerRadius(10f, 10f))
|
|
||||||
drawPath(Path().apply { moveTo(12f, 33f); lineTo(10f, 43f); lineTo(22f, 33f); close() }, color)
|
|
||||||
pulse.forEachIndexed { i, a -> drawCircle(dots.copy(alpha = a.value), 3.2f, Offset(15f + i * 9f, 21f)) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
internal fun HeartIcon(color: Color) {
|
|
||||||
val beat by rememberInfiniteTransition(label = "beat").animateFloat(
|
|
||||||
1f, 1f,
|
|
||||||
infiniteRepeatable(keyframes {
|
|
||||||
durationMillis = 1400
|
|
||||||
1f at 0; 1.16f at 210; 1f at 420; 1.1f at 630; 1f at 840
|
|
||||||
}),
|
|
||||||
label = "beat",
|
|
||||||
)
|
|
||||||
Canvas(Modifier.fillMaxSize()) {
|
|
||||||
inIconBox {
|
|
||||||
scale(beat, beat, Offset(24f, 24f)) {
|
|
||||||
val heart = Path().apply {
|
|
||||||
moveTo(24f, 41f)
|
|
||||||
cubicTo(10f, 31f, 5f, 23f, 5f, 16f)
|
|
||||||
cubicTo(5f, 10f, 10f, 6f, 15f, 6f)
|
|
||||||
cubicTo(19f, 6f, 22f, 8f, 24f, 12f)
|
|
||||||
cubicTo(26f, 8f, 29f, 6f, 33f, 6f)
|
|
||||||
cubicTo(38f, 6f, 43f, 10f, 43f, 16f)
|
|
||||||
cubicTo(43f, 23f, 38f, 31f, 24f, 41f)
|
|
||||||
close()
|
|
||||||
}
|
|
||||||
drawPath(heart, color)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A polyline drawn up to [progress] of its length, so a tick can sweep in. */
|
|
||||||
private fun DrawScope.drawPartialStroke(points: List<Offset>, progress: Float, color: Color, width: Float) {
|
|
||||||
if (progress <= 0f) return
|
|
||||||
val lengths = points.zipWithNext { a, b -> (b - a).getDistance() }
|
|
||||||
var left = lengths.sum() * FastOutSlowInEasing.transform(progress.coerceAtMost(1f))
|
|
||||||
val path = Path().apply { moveTo(points[0].x, points[0].y) }
|
|
||||||
for (i in lengths.indices) {
|
|
||||||
if (left <= 0f) break
|
|
||||||
val f = (left / lengths[i]).coerceAtMost(1f)
|
|
||||||
val to = points[i] + (points[i + 1] - points[i]) * f
|
|
||||||
path.lineTo(to.x, to.y)
|
|
||||||
left -= lengths[i]
|
|
||||||
}
|
|
||||||
drawPath(path, color, style = Stroke(width, cap = StrokeCap.Round, join = StrokeJoin.Round))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
internal fun Dots(c: IntroColors, count: Int, current: Int, cq: (Float) -> Dp, modifier: Modifier) {
|
|
||||||
Row(modifier, horizontalArrangement = Arrangement.spacedBy(cq(2f))) {
|
|
||||||
repeat(count) { i ->
|
|
||||||
val on = i == current
|
|
||||||
val width by animateDpAsState(if (on) cq(7f) else cq(2f), tween(300), label = "dot")
|
|
||||||
Box(
|
|
||||||
Modifier
|
|
||||||
.size(width, cq(2f))
|
|
||||||
.clip(RoundedCornerShape(50))
|
|
||||||
.background(if (on) c.primary else c.outline.copy(alpha = 0.45f)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.navigationBarsPadding
|
|||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
@@ -55,6 +56,9 @@ fun LogsScreen(onBack: () -> Unit) {
|
|||||||
val version by GearslipLog.version.collectAsStateWithLifecycle()
|
val version by GearslipLog.version.collectAsStateWithLifecycle()
|
||||||
val lines = remember(version) { GearslipLog.snapshot() }
|
val lines = remember(version) { GearslipLog.snapshot() }
|
||||||
|
|
||||||
|
var pastOpen by remember { mutableStateOf(false) }
|
||||||
|
if (pastOpen) PastLogsDialog(onDismiss = { pastOpen = false })
|
||||||
|
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
val atEnd by remember {
|
val atEnd by remember {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
@@ -97,16 +101,15 @@ fun LogsScreen(onBack: () -> Unit) {
|
|||||||
onClick = {
|
onClick = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val uri = withContext(Dispatchers.IO) { GearslipLog.shareableFile(context) }
|
val uri = withContext(Dispatchers.IO) { GearslipLog.shareableFile(context) }
|
||||||
val send = Intent(Intent.ACTION_SEND)
|
shareLog(context, uri)
|
||||||
.setType("text/plain")
|
|
||||||
.putExtra(Intent.EXTRA_SUBJECT, "Gearslip log")
|
|
||||||
.putExtra(Intent.EXTRA_STREAM, uri)
|
|
||||||
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
|
||||||
context.startActivity(Intent.createChooser(send, "Share log"))
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
modifier = Modifier.weight(1f).height(56.dp),
|
modifier = Modifier.weight(1f).height(56.dp),
|
||||||
) { Text("Share") }
|
) { Text("Share") }
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { pastOpen = true },
|
||||||
|
modifier = Modifier.weight(1f).height(56.dp),
|
||||||
|
) { Text("Earlier") }
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
onClick = { GearslipLog.clearLive() },
|
onClick = { GearslipLog.clearLive() },
|
||||||
modifier = Modifier.weight(1f).height(56.dp),
|
modifier = Modifier.weight(1f).height(56.dp),
|
||||||
@@ -153,6 +156,68 @@ fun LogsScreen(onBack: () -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun shareLog(context: android.content.Context, uri: android.net.Uri) {
|
||||||
|
val send = Intent(Intent.ACTION_SEND)
|
||||||
|
.setType("text/plain")
|
||||||
|
.putExtra(Intent.EXTRA_SUBJECT, "Gearslip log")
|
||||||
|
.putExtra(Intent.EXTRA_STREAM, uri)
|
||||||
|
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
|
context.startActivity(Intent.createChooser(send, "Share log"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logs from earlier launches. Opening the app after a crash starts a new log, so the one that
|
||||||
|
* saw the crash is only reachable here.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun PastLogsDialog(onDismiss: () -> Unit) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val logs by androidx.compose.runtime.produceState<List<GearslipLog.PastLog>?>(null) {
|
||||||
|
value = withContext(Dispatchers.IO) { GearslipLog.pastLogs(context) }
|
||||||
|
}
|
||||||
|
val format = remember { java.text.SimpleDateFormat("EEE d MMM, h:mm a", java.util.Locale.getDefault()) }
|
||||||
|
androidx.compose.material3.AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text("Earlier logs") },
|
||||||
|
text = {
|
||||||
|
val list = logs
|
||||||
|
when {
|
||||||
|
list == null -> Text("Loading…")
|
||||||
|
list.isEmpty() -> Text("No earlier logs yet. Each time Gearslip starts, it begins a new one.")
|
||||||
|
else -> LazyColumn {
|
||||||
|
itemsIndexed(list) { _, log ->
|
||||||
|
androidx.compose.foundation.layout.Column(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable {
|
||||||
|
scope.launch {
|
||||||
|
val uri = withContext(Dispatchers.IO) { GearslipLog.shareableFile(context, log.file) }
|
||||||
|
shareLog(context, uri)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(vertical = 12.dp),
|
||||||
|
) {
|
||||||
|
Text(log.started?.let(format::format) ?: log.file.name, style = MaterialTheme.typography.titleMedium)
|
||||||
|
val detail = listOfNotNull(
|
||||||
|
"Crashed".takeIf { log.crashed },
|
||||||
|
log.car,
|
||||||
|
"${(log.file.length() + 1023) / 1024} KB",
|
||||||
|
).joinToString(" · ")
|
||||||
|
Text(
|
||||||
|
detail,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = if (log.crashed) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = { androidx.compose.material3.TextButton(onClick = onDismiss) { Text("Close") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/** Lines look like "HH:mm:ss.SSS L message"; the level letter sits at index 13. */
|
/** Lines look like "HH:mm:ss.SSS L message"; the level letter sits at index 13. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun colorFor(line: String): Color = when (line.getOrNull(13)) {
|
private fun colorFor(line: String): Color = when (line.getOrNull(13)) {
|
||||||
|
|||||||
@@ -57,6 +57,9 @@ private val recommended = listOf(
|
|||||||
"Podcasts" to listOf(
|
"Podcasts" to listOf(
|
||||||
RecommendedApp("AntennaPod", "de.danoeh.antennapod", "https://github.com/AntennaPod/AntennaPod", "Podcasts, chapters and all."),
|
RecommendedApp("AntennaPod", "de.danoeh.antennapod", "https://github.com/AntennaPod/AntennaPod", "Podcasts, chapters and all."),
|
||||||
),
|
),
|
||||||
|
"Other cool apps" to listOf(
|
||||||
|
RecommendedApp("RF Sentinel", "com.rfsentinel.app", "https://github.com/CIS-C0/RFSentinel", "Spots trackers and cameras nearby."),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Opened from Settings: the same list the setup guide ends with. */
|
/** Opened from Settings: the same list the setup guide ends with. */
|
||||||
@@ -135,11 +138,13 @@ private fun AppRow(app: RecommendedApp, onOpen: () -> Unit) {
|
|||||||
if (icon != null) {
|
if (icon != null) {
|
||||||
Image(icon.toBitmap(96, 96).asImageBitmap(), contentDescription = null, Modifier.fillMaxSize())
|
Image(icon.toBitmap(96, 96).asImageBitmap(), contentDescription = null, Modifier.fillMaxSize())
|
||||||
} else {
|
} else {
|
||||||
Text(app.name.take(1), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
Text(app.name.take(1), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Column(Modifier.weight(1f).padding(horizontal = 14.dp)) {
|
Column(Modifier.weight(1f).padding(horizontal = 14.dp)) {
|
||||||
Text(app.name, style = MaterialTheme.typography.titleMedium)
|
// Colours set outright: the setup guide draws this on its own background, with no
|
||||||
|
// Surface to hand down a content colour, and unset text came out dark on dark.
|
||||||
|
Text(app.name, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface)
|
||||||
Text(app.about, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
Text(app.about, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
if (icon != null) {
|
if (icon != null) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 4.dp)) {
|
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 4.dp)) {
|
||||||
|
|||||||
@@ -79,12 +79,7 @@ import kotlinx.coroutines.withContext
|
|||||||
fun SettingsScreen(
|
fun SettingsScreen(
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onOpenRecommendedApps: () -> Unit,
|
onOpenRecommendedApps: () -> Unit,
|
||||||
onOpenWhatsNew: () -> Unit,
|
|
||||||
onOpenHelp: () -> Unit,
|
|
||||||
onOpenCarPreview: () -> Unit,
|
|
||||||
onOpenLogs: () -> Unit,
|
|
||||||
onReplayTutorial: () -> Unit,
|
onReplayTutorial: () -> Unit,
|
||||||
onReplayIntro: () -> Unit,
|
|
||||||
atUsageNotes: Boolean = false,
|
atUsageNotes: Boolean = false,
|
||||||
) {
|
) {
|
||||||
val scroll = rememberScrollState()
|
val scroll = rememberScrollState()
|
||||||
@@ -119,13 +114,23 @@ fun SettingsScreen(
|
|||||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||||
) {
|
) {
|
||||||
Spacer(Modifier.height(0.dp))
|
Spacer(Modifier.height(0.dp))
|
||||||
|
SettingsSection {
|
||||||
|
val context = LocalContext.current
|
||||||
|
SettingsRow(
|
||||||
|
"What's new",
|
||||||
|
Modifier.clickable { GearslipLinks.open(context, GearslipLinks.RELEASE_NOTES) },
|
||||||
|
subtitle = "Read the ${BuildConfig.VERSION_NAME} release notes on GitHub",
|
||||||
|
icon = Icons.Filled.Star,
|
||||||
|
trailing = { Arrow() },
|
||||||
|
)
|
||||||
|
}
|
||||||
SettingsSection(
|
SettingsSection(
|
||||||
"Support Gearslip",
|
"Support Gearslip",
|
||||||
footer = "Gearslip is free, with no ads and no tracking. Your support keeps it going.",
|
footer = "Gearslip is free, with no ads and no tracking. Your support keeps it going.",
|
||||||
) { DonateRows() }
|
) { DonateRows() }
|
||||||
ConnectionSection()
|
ConnectionSection()
|
||||||
HelpSection(onOpenHelp, onOpenCarPreview, onOpenLogs)
|
|
||||||
CarAudioSection()
|
CarAudioSection()
|
||||||
|
CarScreenSection()
|
||||||
VoiceModelSection()
|
VoiceModelSection()
|
||||||
if (SHOW_RECOMMENDED_APPS) AppsSection(onOpenRecommendedApps)
|
if (SHOW_RECOMMENDED_APPS) AppsSection(onOpenRecommendedApps)
|
||||||
SettingsSection(
|
SettingsSection(
|
||||||
@@ -135,7 +140,8 @@ fun SettingsScreen(
|
|||||||
) { UsageStatsRows() }
|
) { UsageStatsRows() }
|
||||||
SettingsSection("Updates") { UpdateRow() }
|
SettingsSection("Updates") { UpdateRow() }
|
||||||
CertificateSection()
|
CertificateSection()
|
||||||
AboutSection(onOpenWhatsNew, onReplayTutorial, onReplayIntro)
|
AboutSection(onReplayTutorial)
|
||||||
|
if (BuildConfig.DEBUG) DebugSection()
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -148,26 +154,10 @@ private fun Arrow() = Icon(
|
|||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** What's new, the replays, and where Gearslip lives outside the app. */
|
/** The setup guide replay, and where Gearslip lives outside the app. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun AboutSection(onOpenWhatsNew: () -> Unit, onReplayTutorial: () -> Unit, onReplayIntro: () -> Unit) {
|
private fun AboutSection(onReplayTutorial: () -> Unit) {
|
||||||
SettingsSection("About") {
|
SettingsSection("About") {
|
||||||
SettingsRow(
|
|
||||||
"What's new",
|
|
||||||
Modifier.clickable(onClick = onOpenWhatsNew),
|
|
||||||
subtitle = "Version ${BuildConfig.VERSION_NAME}",
|
|
||||||
icon = Icons.Filled.Star,
|
|
||||||
trailing = { Arrow() },
|
|
||||||
)
|
|
||||||
SettingsDivider()
|
|
||||||
SettingsRow(
|
|
||||||
"Replay the ${Intro.release} intro",
|
|
||||||
Modifier.clickable(onClick = onReplayIntro),
|
|
||||||
subtitle = "What's in this release, in a few taps",
|
|
||||||
icon = Icons.Filled.Star,
|
|
||||||
trailing = { Arrow() },
|
|
||||||
)
|
|
||||||
SettingsDivider()
|
|
||||||
SettingsRow(
|
SettingsRow(
|
||||||
"Replay the setup guide",
|
"Replay the setup guide",
|
||||||
Modifier.clickable(onClick = onReplayTutorial),
|
Modifier.clickable(onClick = onReplayTutorial),
|
||||||
@@ -199,48 +189,25 @@ private fun ConnectionSection() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Connection help, and the tools for checking what Gearslip does. */
|
/** Tools for testing a debug build; release builds never show this section. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun HelpSection(onOpenHelp: () -> Unit, onOpenCarPreview: () -> Unit, onOpenLogs: () -> Unit) {
|
private fun DebugSection() {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val status by SessionStatus.state.collectAsState()
|
SettingsSection("Debug build") {
|
||||||
val live = status.phase == SessionStatus.Phase.CONNECTING || status.phase == SessionStatus.Phase.PROJECTING
|
|
||||||
SettingsSection("Help and tools") {
|
|
||||||
SettingsRow(
|
|
||||||
"Connection help",
|
|
||||||
Modifier.clickable(onClick = onOpenHelp),
|
|
||||||
subtitle = "Which cars work, and what to set up first",
|
|
||||||
icon = Icons.Filled.Info,
|
|
||||||
trailing = { Arrow() },
|
|
||||||
)
|
|
||||||
// The preview draws the car screen on the phone, so it waits until no car is using it.
|
|
||||||
if (!live) {
|
|
||||||
SettingsDivider()
|
|
||||||
SettingsRow(
|
|
||||||
"Car preview",
|
|
||||||
Modifier.clickable(onClick = onOpenCarPreview),
|
|
||||||
subtitle = "See the car screen on your phone",
|
|
||||||
icon = Icons.Filled.PlayArrow,
|
|
||||||
trailing = { Arrow() },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
SettingsDivider()
|
|
||||||
SettingsRow(
|
|
||||||
"Live logs",
|
|
||||||
Modifier.clickable(onClick = onOpenLogs),
|
|
||||||
subtitle = "What Gearslip and the car are saying to each other",
|
|
||||||
icon = Icons.AutoMirrored.Filled.List,
|
|
||||||
trailing = { Arrow() },
|
|
||||||
)
|
|
||||||
if (BuildConfig.DEBUG) {
|
|
||||||
SettingsDivider()
|
|
||||||
SettingsRow(
|
SettingsRow(
|
||||||
"Send a test message",
|
"Send a test message",
|
||||||
Modifier.clickable { TestMessage.post(context) },
|
Modifier.clickable { TestMessage.post(context) },
|
||||||
subtitle = "Debug builds only. Posts a message notification to read on the car",
|
subtitle = "Posts a message notification to read on the car",
|
||||||
icon = Icons.AutoMirrored.Filled.Send,
|
icon = Icons.AutoMirrored.Filled.Send,
|
||||||
)
|
)
|
||||||
}
|
SettingsDivider()
|
||||||
|
val badge by CarSettings.debugBadge.collectAsState()
|
||||||
|
SettingsRow(
|
||||||
|
"Debug badge on the car screen",
|
||||||
|
Modifier.clickable { CarSettings.setDebugBadge(!badge) },
|
||||||
|
subtitle = "Turn it off for screenshots",
|
||||||
|
trailing = { Switch(checked = badge, onCheckedChange = null) },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,7 +229,7 @@ private fun CarAudioSection() {
|
|||||||
SettingsRow(
|
SettingsRow(
|
||||||
"USB",
|
"USB",
|
||||||
Modifier.selectable(usb, role = Role.RadioButton) { CarSettings.setPipeAudio(true) },
|
Modifier.selectable(usb, role = Role.RadioButton) { CarSettings.setPipeAudio(true) },
|
||||||
subtitle = "Clearer sound with no delay. Android asks to share audio once each drive",
|
subtitle = "Clearer sound with no delay. Each drive, Android asks you to share your screen, which lets Gearslip send the sound",
|
||||||
trailing = { RadioButton(selected = usb, onClick = null) },
|
trailing = { RadioButton(selected = usb, onClick = null) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,6 +93,9 @@ import app.seb3thehacker.gearslip.host.AndroidAuto
|
|||||||
import app.seb3thehacker.gearslip.notify.GearslipNotificationListener
|
import app.seb3thehacker.gearslip.notify.GearslipNotificationListener
|
||||||
import app.seb3thehacker.gearslip.stats.UsageStats
|
import app.seb3thehacker.gearslip.stats.UsageStats
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.animation.core.RepeatMode
|
||||||
|
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One page of the guide. [permissions] drives a system permission dialog; [command] is an adb
|
* One page of the guide. [permissions] drives a system permission dialog; [command] is an adb
|
||||||
@@ -100,6 +103,9 @@ import androidx.compose.runtime.DisposableEffect
|
|||||||
* settings screen) without by itself finishing the page. Everything is skippable: none of this
|
* settings screen) without by itself finishing the page. Everything is skippable: none of this
|
||||||
* can be verified as done, only offered.
|
* can be verified as done, only offered.
|
||||||
*/
|
*/
|
||||||
|
/** The guide's colours and its one-percent-of-the-width scale, for content that sizes itself the same way. */
|
||||||
|
internal class GuideScale(val c: IntroColors, val cq: (Float) -> Dp, val cqSp: (Float) -> TextUnit)
|
||||||
|
|
||||||
private class GuidePage(
|
private class GuidePage(
|
||||||
val title: String,
|
val title: String,
|
||||||
/** Plain text, or an [AnnotatedString] when part of it needs to stand out. */
|
/** Plain text, or an [AnnotatedString] when part of it needs to stand out. */
|
||||||
@@ -112,8 +118,8 @@ private class GuidePage(
|
|||||||
val command: String? = null,
|
val command: String? = null,
|
||||||
val actionLabel: String? = null,
|
val actionLabel: String? = null,
|
||||||
val onAction: (() -> Unit)? = null,
|
val onAction: (() -> Unit)? = null,
|
||||||
/** Drawn under [explanation]. */
|
/** Drawn under [explanation], sized off the same screen-width scale as the page. */
|
||||||
val content: (@Composable () -> Unit)? = null,
|
val content: (@Composable (GuideScale) -> Unit)? = null,
|
||||||
/** Only shown when the driver picked USB audio on the car audio page. */
|
/** Only shown when the driver picked USB audio on the car audio page. */
|
||||||
val usbOnly: Boolean = false,
|
val usbOnly: Boolean = false,
|
||||||
)
|
)
|
||||||
@@ -365,8 +371,8 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Apps worth installing, once everything above is set up -----------------------
|
// --- At the end, every time: apps worth installing, how the car's buttons look, and last
|
||||||
if (SHOW_RECOMMENDED_APPS) {
|
// the usage notes. On a replay, the switch shows what was chosen before.
|
||||||
add(
|
add(
|
||||||
GuidePage(
|
GuidePage(
|
||||||
title = "Good company.",
|
title = "Good company.",
|
||||||
@@ -375,10 +381,14 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
|
|||||||
content = { RecommendedAppsList() },
|
content = { RecommendedAppsList() },
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
add(
|
||||||
|
GuidePage(
|
||||||
// --- The very end, every time: usage notes. On a replay, the switch shows what was
|
title = "Your buttons.",
|
||||||
// chosen before.
|
explanation = "Pick how the car's buttons look. Change it any time in the car's settings.",
|
||||||
|
icon = { ButtonsIcon(it) },
|
||||||
|
content = { ThemePicker(it.c, it.cq, it.cqSp, key = "guide-buttons") },
|
||||||
|
),
|
||||||
|
)
|
||||||
add(usageNotesPage(context))
|
add(usageNotesPage(context))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -417,7 +427,7 @@ private fun AudioChoice() {
|
|||||||
) { CarSettings.setPipeAudio(false) }
|
) { CarSettings.setPipeAudio(false) }
|
||||||
AudioCard(
|
AudioCard(
|
||||||
"USB", null,
|
"USB", null,
|
||||||
"Clearer sound with no delay. Android asks to share audio once each drive.",
|
"Clearer sound with no delay. Each drive, Android asks you to share your screen, which lets Gearslip send the sound.",
|
||||||
selected = usb,
|
selected = usb,
|
||||||
) { CarSettings.setPipeAudio(true) }
|
) { CarSettings.setPipeAudio(true) }
|
||||||
}
|
}
|
||||||
@@ -509,10 +519,11 @@ fun SetupGuideScreen(onRequestCallScreening: () -> Unit, onDone: () -> Unit, onl
|
|||||||
// pushes them off the screen. Short pages sit in the middle.
|
// pushes them off the screen. Short pages sit in the middle.
|
||||||
BoxWithConstraints(Modifier.weight(1f).fillMaxWidth()) {
|
BoxWithConstraints(Modifier.weight(1f).fillMaxWidth()) {
|
||||||
key(index) {
|
key(index) {
|
||||||
|
val scroll = rememberScrollState()
|
||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.verticalScroll(rememberScrollState())
|
.verticalScroll(scroll)
|
||||||
.heightIn(min = maxHeight)
|
.heightIn(min = maxHeight)
|
||||||
.padding(vertical = cq(6f)),
|
.padding(vertical = cq(6f)),
|
||||||
verticalArrangement = Arrangement.Center,
|
verticalArrangement = Arrangement.Center,
|
||||||
@@ -550,7 +561,7 @@ fun SetupGuideScreen(onRequestCallScreening: () -> Unit, onDone: () -> Unit, onl
|
|||||||
style = TextStyle(fontSize = cqSp(4.5f), lineHeight = cqSp(6.5f)),
|
style = TextStyle(fontSize = cqSp(4.5f), lineHeight = cqSp(6.5f)),
|
||||||
)
|
)
|
||||||
page.command?.let { CopyableCommand(it) }
|
page.command?.let { CopyableCommand(it) }
|
||||||
page.content?.invoke()
|
page.content?.invoke(GuideScale(c, ::cq, ::cqSp))
|
||||||
if (page.permissions != null && alreadyGranted) {
|
if (page.permissions != null && alreadyGranted) {
|
||||||
Text(
|
Text(
|
||||||
"Already allowed.", color = c.primary,
|
"Already allowed.", color = c.primary,
|
||||||
@@ -559,6 +570,9 @@ fun SetupGuideScreen(onRequestCallScreening: () -> Unit, onDone: () -> Unit, onl
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// A long page (the app list, the usage notes) says so: a fade over the
|
||||||
|
// last lines and a nudge to keep going, until the bottom is in view.
|
||||||
|
if (scroll.canScrollForward) ScrollHint(c, ::cq, ::cqSp, Modifier.align(Alignment.BottomCenter))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -585,6 +599,28 @@ fun SetupGuideScreen(onRequestCallScreening: () -> Unit, onDone: () -> Unit, onl
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A fade into the page's background with "Scroll for more" over it, at the bottom of a page that goes on. */
|
||||||
|
@Composable
|
||||||
|
private fun ScrollHint(c: IntroColors, cq: (Float) -> Dp, cqSp: (Float) -> TextUnit, modifier: Modifier = Modifier) {
|
||||||
|
val bob by rememberInfiniteTransition(label = "hint").animateFloat(
|
||||||
|
0f, 1f, infiniteRepeatable(tween(900, easing = FastOutSlowInEasing), RepeatMode.Reverse), label = "bob",
|
||||||
|
)
|
||||||
|
Box(
|
||||||
|
modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(cq(22f))
|
||||||
|
.background(androidx.compose.ui.graphics.Brush.verticalGradient(listOf(c.surface.copy(alpha = 0f), c.surface))),
|
||||||
|
contentAlignment = Alignment.BottomCenter,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"Scroll for more ↓",
|
||||||
|
color = c.primary,
|
||||||
|
style = TextStyle(fontSize = cqSp(4f), fontWeight = FontWeight.Bold),
|
||||||
|
modifier = Modifier.padding(bottom = cq(2f)).offset(y = cq(1.2f) * bob),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
internal enum class PillStyle { FILLED, OUTLINED, TEXT }
|
internal enum class PillStyle { FILLED, OUTLINED, TEXT }
|
||||||
|
|
||||||
/** The intro's rounded buttons: filled for the step's main action, outlined beside it, bare text to skip. */
|
/** The intro's rounded buttons: filled for the step's main action, outlined beside it, bare text to skip. */
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package app.seb3thehacker.gearslip.ui
|
|||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
import android.bluetooth.BluetoothDevice
|
import android.bluetooth.BluetoothDevice
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
@@ -65,8 +66,18 @@ internal fun WirelessButton() {
|
|||||||
Text(
|
Text(
|
||||||
"Gearslip needs Bluetooth and nearby Wi-Fi access for this.",
|
"Gearslip needs Bluetooth and nearby Wi-Fi access for this.",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
modifier = Modifier.padding(bottom = 8.dp),
|
|
||||||
)
|
)
|
||||||
|
// Once Android stops asking, the only way back is the app's own permission settings.
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
runCatching {
|
||||||
|
context.startActivity(
|
||||||
|
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", context.packageName, null)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.padding(bottom = 4.dp),
|
||||||
|
) { Text("Enable Nearby devices permission") }
|
||||||
}
|
}
|
||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
@@ -98,6 +109,8 @@ internal fun WirelessButton() {
|
|||||||
.clickable {
|
.clickable {
|
||||||
devices = null
|
devices = null
|
||||||
WirelessLink.start(context, device)
|
WirelessLink.start(context, device)
|
||||||
|
// Next time this car connects, Gearslip does this by itself.
|
||||||
|
app.seb3thehacker.gearslip.wireless.WirelessAuto.connectedByHand(context, device)
|
||||||
}
|
}
|
||||||
.padding(vertical = 14.dp),
|
.padding(vertical = 14.dp),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -77,9 +77,9 @@ object UpdateChecker {
|
|||||||
private const val CHANNEL = "updates"
|
private const val CHANNEL = "updates"
|
||||||
private const val NOTIFICATION_ID = 4100
|
private const val NOTIFICATION_ID = 4100
|
||||||
private const val DAY_MS = 24 * 60 * 60 * 1000L
|
private const val DAY_MS = 24 * 60 * 60 * 1000L
|
||||||
private const val LATEST = "https://api.github.com/repos/Seb3thehacker/gearslip/releases/latest"
|
private const val LATEST = "https://api.github.com/repos/twinksoftware/gearslip/releases/latest"
|
||||||
/** Newest first, pre-releases included. Ten covers any run of betas between two releases. */
|
/** Newest first, pre-releases included. Ten covers any run of betas between two releases. */
|
||||||
private const val ALL = "https://api.github.com/repos/Seb3thehacker/gearslip/releases?per_page=10"
|
private const val ALL = "https://api.github.com/repos/twinksoftware/gearslip/releases?per_page=10"
|
||||||
|
|
||||||
/** Set when the driver tapped Install but first had to allow installs from Gearslip. */
|
/** Set when the driver tapped Install but first had to allow installs from Gearslip. */
|
||||||
@Volatile private var installWhenAllowed = false
|
@Volatile private var installWhenAllowed = false
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package app.seb3thehacker.gearslip.wireless
|
||||||
|
|
||||||
|
import android.companion.CompanionDeviceService
|
||||||
|
|
||||||
|
/** Android calls this when a car approved in [WirelessAuto] connects over Bluetooth. */
|
||||||
|
class CarPresenceService : CompanionDeviceService() {
|
||||||
|
|
||||||
|
@Deprecated("Deprecated in Java")
|
||||||
|
override fun onDeviceAppeared(address: String) {
|
||||||
|
WirelessAuto.carAppeared(applicationContext, address)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Deprecated("Deprecated in Java")
|
||||||
|
override fun onDeviceDisappeared(address: String) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package app.seb3thehacker.gearslip.wireless
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.bluetooth.BluetoothDevice
|
||||||
|
import android.companion.AssociationRequest
|
||||||
|
import android.companion.BluetoothDeviceFilter
|
||||||
|
import android.companion.CompanionDeviceManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.IntentSender
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import app.seb3thehacker.gearslip.GearslipLog
|
||||||
|
import app.seb3thehacker.gearslip.GearslipActivity
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts wireless Android Auto by itself when a car Gearslip has connected to before comes up
|
||||||
|
* over Bluetooth.
|
||||||
|
*
|
||||||
|
* Android only lets an app wake up and open its screen from the background for a device it
|
||||||
|
* manages as a companion, so the first wireless connection to a car also asks Android to make
|
||||||
|
* that car Gearslip's companion. Android shows its own one-time "Allow" for it; after that it
|
||||||
|
* tells [CarPresenceService] whenever the car connects, and Gearslip connects to it.
|
||||||
|
*/
|
||||||
|
object WirelessAuto {
|
||||||
|
|
||||||
|
/** Action on the activity's intent when the car came up on its own. */
|
||||||
|
const val ACTION_CAR_NEARBY = "app.seb3thehacker.gearslip.CAR_NEARBY"
|
||||||
|
const val EXTRA_ADDRESS = "address"
|
||||||
|
|
||||||
|
private const val PREFS = "gearslip_wireless_auto"
|
||||||
|
|
||||||
|
private val log = GearslipLog.tagged("AAW")
|
||||||
|
|
||||||
|
/** Set by the activity: shows Android's companion dialog, which only an activity can do. */
|
||||||
|
@Volatile var showApproval: ((IntentSender, String) -> Unit)? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called whenever the driver connects to [device] by hand. Asks Android once per car; a car
|
||||||
|
* already approved only has its presence watched again, in case Android forgot. A car only
|
||||||
|
* counts as asked once the driver has answered the dialog: if it never showed, the next
|
||||||
|
* connection by hand asks again.
|
||||||
|
*/
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
fun connectedByHand(context: Context, device: BluetoothDevice) {
|
||||||
|
val manager = context.getSystemService(CompanionDeviceManager::class.java) ?: return
|
||||||
|
val address = device.address
|
||||||
|
if (isCompanion(manager, address)) {
|
||||||
|
watch(context, address)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getBoolean(address, false)) {
|
||||||
|
log.i("auto-connect: already asked about this car and it was declined; connect by hand")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val request = AssociationRequest.Builder()
|
||||||
|
.addDeviceFilter(BluetoothDeviceFilter.Builder().setAddress(address).build())
|
||||||
|
.setSingleDevice(true)
|
||||||
|
.build()
|
||||||
|
log.i("auto-connect: asking Android to let Gearslip watch for ${WirelessLink.name(device)}")
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
manager.associate(request, object : CompanionDeviceManager.Callback() {
|
||||||
|
@Deprecated("Deprecated in Java")
|
||||||
|
override fun onDeviceFound(chooser: IntentSender) {
|
||||||
|
showApproval?.invoke(chooser, address) ?: log.w("auto-connect: no screen to ask on")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onFailure(error: CharSequence?) {
|
||||||
|
log.w("auto-connect: Android wouldn't ask ($error)")
|
||||||
|
}
|
||||||
|
}, Handler(Looper.getMainLooper()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The driver allowed it in Android's dialog. */
|
||||||
|
fun approved(context: Context, address: String) {
|
||||||
|
log.i("auto-connect: allowed")
|
||||||
|
answered(context, address)
|
||||||
|
watch(context, address)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The driver said no in Android's dialog: this car isn't asked about again. */
|
||||||
|
fun declined(context: Context, address: String) {
|
||||||
|
log.w("auto-connect: declined in Android's dialog")
|
||||||
|
answered(context, address)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun answered(context: Context, address: String) {
|
||||||
|
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putBoolean(address, true).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
private fun watch(context: Context, address: String) {
|
||||||
|
val manager = context.getSystemService(CompanionDeviceManager::class.java) ?: return
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
runCatching { manager.startObservingDevicePresence(address) }
|
||||||
|
.onFailure { log.w("auto-connect: couldn't watch for the car (${it.message})") }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
private fun isCompanion(manager: CompanionDeviceManager, address: String): Boolean =
|
||||||
|
runCatching { manager.associations.any { it.equals(address, ignoreCase = true) } }.getOrDefault(false)
|
||||||
|
|
||||||
|
/** From [CarPresenceService]: the car is here, so bring Gearslip up to connect to it. */
|
||||||
|
fun carAppeared(context: Context, address: String) {
|
||||||
|
log.i("auto-connect: a car Gearslip knows connected over Bluetooth")
|
||||||
|
runCatching {
|
||||||
|
context.startActivity(
|
||||||
|
Intent(context, GearslipActivity::class.java)
|
||||||
|
.setAction(ACTION_CAR_NEARBY)
|
||||||
|
.putExtra(EXTRA_ADDRESS, address)
|
||||||
|
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
|
||||||
|
)
|
||||||
|
}.onFailure { log.w("auto-connect: couldn't open Gearslip (${it.message})") }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -82,8 +82,13 @@ object WirelessLink {
|
|||||||
@Volatile private var tcp: Socket? = null
|
@Volatile private var tcp: Socket? = null
|
||||||
@Volatile private var network: ConnectivityManager.NetworkCallback? = null
|
@Volatile private var network: ConnectivityManager.NetworkCallback? = null
|
||||||
@Volatile private var worker: Thread? = null
|
@Volatile private var worker: Thread? = null
|
||||||
|
|
||||||
|
/** A start-up or a session's Bluetooth channel is under way. */
|
||||||
|
val busy: Boolean get() = worker?.isAlive == true
|
||||||
/** Set when Android drops the car's network mid-try, so the rest don't bind to a dead one. */
|
/** Set when Android drops the car's network mid-try, so the rest don't bind to a dead one. */
|
||||||
@Volatile private var lostNetwork: Network? = null
|
@Volatile private var lostNetwork: Network? = null
|
||||||
|
/** The car network asked for or joined, until Android says it's gone or never came. */
|
||||||
|
@Volatile private var activeSsid: String? = null
|
||||||
/** Bumped on each Wi-Fi join and on stop, so an older run of TCP tries ends instead of racing the new one. */
|
/** Bumped on each Wi-Fi join and on stop, so an older run of TCP tries ends instead of racing the new one. */
|
||||||
@Volatile private var tcpRun = 0
|
@Volatile private var tcpRun = 0
|
||||||
private val writeLock = Any()
|
private val writeLock = Any()
|
||||||
@@ -128,6 +133,7 @@ object WirelessLink {
|
|||||||
runCatching { context.getSystemService(ConnectivityManager::class.java).unregisterNetworkCallback(cb) }
|
runCatching { context.getSystemService(ConnectivityManager::class.java).unregisterNetworkCallback(cb) }
|
||||||
}
|
}
|
||||||
network = null
|
network = null
|
||||||
|
activeSsid = null
|
||||||
// Stopped before the car's session began, so nothing else resets Home's card.
|
// Stopped before the car's session began, so nothing else resets Home's card.
|
||||||
if (starting) {
|
if (starting) {
|
||||||
log.i("wireless start-up stopped")
|
log.i("wireless start-up stopped")
|
||||||
@@ -171,6 +177,8 @@ object WirelessLink {
|
|||||||
val input = DataInputStream(socket.inputStream)
|
val input = DataInputStream(socket.inputStream)
|
||||||
val output = socket.outputStream
|
val output = socket.outputStream
|
||||||
var target: Pair<String, Int>? = null
|
var target: Pair<String, Int>? = null
|
||||||
|
// Wi-Fi details that came before the address, joined once the StartRequest brings it.
|
||||||
|
var waitingJoin: ((String, Int) -> Unit)? = null
|
||||||
try {
|
try {
|
||||||
while (!Thread.currentThread().isInterrupted) {
|
while (!Thread.currentThread().isInterrupted) {
|
||||||
val length = input.readUnsignedShort()
|
val length = input.readUnsignedShort()
|
||||||
@@ -201,9 +209,15 @@ object WirelessLink {
|
|||||||
val port = Wire.varint(f, 2)?.toInt() ?: 0
|
val port = Wire.varint(f, 2)?.toInt() ?: 0
|
||||||
log.i("<- StartRequest: projection at $ip:$port")
|
log.i("<- StartRequest: projection at $ip:$port")
|
||||||
target = ip to port
|
target = ip to port
|
||||||
|
val join = waitingJoin
|
||||||
|
if (join != null) {
|
||||||
|
waitingJoin = null
|
||||||
|
join(ip, port)
|
||||||
|
} else {
|
||||||
send(output, MSG_INFO_REQUEST, ByteArray(0))
|
send(output, MSG_INFO_REQUEST, ByteArray(0))
|
||||||
log.i("-> InfoRequest")
|
log.i("-> InfoRequest")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
MSG_INFO_RESPONSE -> {
|
MSG_INFO_RESPONSE -> {
|
||||||
val f = Wire.fields(body)
|
val f = Wire.fields(body)
|
||||||
fun text(n: Int) = f.firstOrNull { it.number == n && it.wireType == 2 }?.bytes?.decodeToString().orEmpty()
|
fun text(n: Int) = f.firstOrNull { it.number == n && it.wireType == 2 }?.bytes?.decodeToString().orEmpty()
|
||||||
@@ -212,11 +226,23 @@ object WirelessLink {
|
|||||||
val bssid = text(3)
|
val bssid = text(3)
|
||||||
val security = Wire.varint(f, 4) ?: -1
|
val security = Wire.varint(f, 4) ?: -1
|
||||||
log.i("<- InfoResponse: ssid=\"$ssid\" bssid=$bssid security=$security password=${if (password.isEmpty()) "none" else "(${password.length} chars)"}")
|
log.i("<- InfoResponse: ssid=\"$ssid\" bssid=$bssid security=$security password=${if (password.isEmpty()) "none" else "(${password.length} chars)"}")
|
||||||
val (ip, port) = target ?: run {
|
// ZLink head units send the Wi-Fi details again once the stream is up.
|
||||||
log.w("InfoResponse before StartRequest: no address to connect to yet")
|
// Asking Android for the network again releases the one carrying the
|
||||||
"" to 0
|
// stream, which aborts its socket; a phone running Android Auto just
|
||||||
|
// carries on, so the repeat is ignored while that network is in use.
|
||||||
|
if (ssid == activeSsid) {
|
||||||
|
log.i("InfoResponse again for \"$ssid\", which the phone is already on - ignoring it")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val join = { ip: String, port: Int -> joinWifi(context, output, name, ssid, password, bssid, ip, port) }
|
||||||
|
val known = target
|
||||||
|
if (known != null) {
|
||||||
|
join(known.first, known.second)
|
||||||
|
} else {
|
||||||
|
// Some dongles send the Wi-Fi details first; the address follows a moment later.
|
||||||
|
log.i("InfoResponse before StartRequest: waiting for the address")
|
||||||
|
waitingJoin = join
|
||||||
}
|
}
|
||||||
joinWifi(context, output, name, ssid, password, bssid, ip, port)
|
|
||||||
}
|
}
|
||||||
MSG_PING -> send(output, MSG_PONG, body)
|
MSG_PING -> send(output, MSG_PONG, body)
|
||||||
MSG_CONNECTION_STATUS, MSG_START_RESPONSE -> {
|
MSG_CONNECTION_STATUS, MSG_START_RESPONSE -> {
|
||||||
@@ -317,6 +343,7 @@ object WirelessLink {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onUnavailable() {
|
override fun onUnavailable() {
|
||||||
|
if (activeSsid == ssid) activeSsid = null
|
||||||
log.w("Wi-Fi: couldn't join \"$ssid\" (declined, out of range, or wrong password)")
|
log.w("Wi-Fi: couldn't join \"$ssid\" (declined, out of range, or wrong password)")
|
||||||
runCatching { send(output, MSG_CONNECTION_STATUS, Protobuf.varintField(1, -1)) }
|
runCatching { send(output, MSG_CONNECTION_STATUS, Protobuf.varintField(1, -1)) }
|
||||||
SessionStatus.failed(
|
SessionStatus.failed(
|
||||||
@@ -341,6 +368,7 @@ object WirelessLink {
|
|||||||
|
|
||||||
override fun onLost(net: Network) {
|
override fun onLost(net: Network) {
|
||||||
lostNetwork = net
|
lostNetwork = net
|
||||||
|
if (activeSsid == ssid) activeSsid = null
|
||||||
log.w(
|
log.w(
|
||||||
"Wi-Fi: lost \"$ssid\" - Android dropped the car's network. Picking it by hand " +
|
"Wi-Fi: lost \"$ssid\" - Android dropped the car's network. Picking it by hand " +
|
||||||
"in the Wi-Fi list does this; the remaining tries go over the phone's own Wi-Fi",
|
"in the Wi-Fi list does this; the remaining tries go over the phone's own Wi-Fi",
|
||||||
@@ -349,6 +377,7 @@ object WirelessLink {
|
|||||||
}
|
}
|
||||||
network?.let { runCatching { manager.unregisterNetworkCallback(it) } }
|
network?.let { runCatching { manager.unregisterNetworkCallback(it) } }
|
||||||
network = callback
|
network = callback
|
||||||
|
activeSsid = ssid
|
||||||
manager.requestNetwork(request, callback, WIFI_WAIT_MS)
|
manager.requestNetwork(request, callback, WIFI_WAIT_MS)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -369,7 +398,7 @@ object WirelessLink {
|
|||||||
log.i("probe: Bluetooth (any device) calls ${profileState(calls)}, media audio ${profileState(audio)}")
|
log.i("probe: Bluetooth (any device) calls ${profileState(calls)}, media audio ${profileState(audio)}")
|
||||||
log.i(
|
log.i(
|
||||||
"probe: background data ${manager?.restrictBackgroundStatus} (1 off, 2 allowed, 3 restricted), " +
|
"probe: background data ${manager?.restrictBackgroundStatus} (1 off, 2 allowed, 3 restricted), " +
|
||||||
"active network ${manager?.activeNetwork}, car network $net",
|
"active network ${manager?.activeNetwork}, car network $net, VPN ${if (vpnActive(context)) "on" else "off"}",
|
||||||
)
|
)
|
||||||
}.onFailure { log.w("probe: couldn't read the link (${it.message})") }
|
}.onFailure { log.w("probe: couldn't read the link (${it.message})") }
|
||||||
for (p in listOf(port, 5288, 5277, 80, 443).distinct()) {
|
for (p in listOf(port, 5288, 5277, 80, 443).distinct()) {
|
||||||
@@ -387,6 +416,12 @@ object WirelessLink {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether a VPN carries this app's traffic. Android then refuses to bind sockets to the car's network (EPERM). */
|
||||||
|
private fun vpnActive(context: Context): Boolean = runCatching {
|
||||||
|
val manager = context.getSystemService(ConnectivityManager::class.java)
|
||||||
|
manager.getNetworkCapabilities(manager.activeNetwork)?.hasTransport(NetworkCapabilities.TRANSPORT_VPN) == true
|
||||||
|
}.getOrDefault(false)
|
||||||
|
|
||||||
private fun profileState(state: Int?) = when (state) {
|
private fun profileState(state: Int?) = when (state) {
|
||||||
BluetoothProfile.STATE_CONNECTED -> "connected"
|
BluetoothProfile.STATE_CONNECTED -> "connected"
|
||||||
BluetoothProfile.STATE_CONNECTING -> "connecting"
|
BluetoothProfile.STATE_CONNECTING -> "connecting"
|
||||||
@@ -408,6 +443,9 @@ object WirelessLink {
|
|||||||
SessionStatus.connecting("Wi-Fi: reaching $name")
|
SessionStatus.connecting("Wi-Fi: reaching $name")
|
||||||
lostNetwork = null
|
lostNetwork = null
|
||||||
var last: Exception? = null
|
var last: Exception? = null
|
||||||
|
// Set once Android refuses to bind to the car's network, which a VPN causes. Plain sockets
|
||||||
|
// then go through the VPN, and VPNs that let local traffic past still reach the car.
|
||||||
|
var bindRefused = false
|
||||||
repeat(TCP_TRIES) { attempt ->
|
repeat(TCP_TRIES) { attempt ->
|
||||||
if (run != tcpRun) {
|
if (run != tcpRun) {
|
||||||
log.i("TCP: stopping the tries on the old network - the car started over")
|
log.i("TCP: stopping the tries on the old network - the car started over")
|
||||||
@@ -423,7 +461,7 @@ object WirelessLink {
|
|||||||
if (main != null && attempt == 0) log.i("TCP: the phone's main Wi-Fi is on the car's network, using it")
|
if (main != null && attempt == 0) log.i("TCP: the phone's main Wi-Fi is on the car's network, using it")
|
||||||
val socket = when {
|
val socket = when {
|
||||||
main != null -> main.socketFactory.createSocket()
|
main != null -> main.socketFactory.createSocket()
|
||||||
lostNetwork == net -> Socket()
|
lostNetwork == net || bindRefused -> Socket()
|
||||||
else -> net.socketFactory.createSocket()
|
else -> net.socketFactory.createSocket()
|
||||||
}
|
}
|
||||||
socket.tcpNoDelay = true
|
socket.tcpNoDelay = true
|
||||||
@@ -441,11 +479,23 @@ object WirelessLink {
|
|||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
last = e
|
last = e
|
||||||
log.w("TCP: try ${attempt + 1} to $ip:$port failed: ${e.message}")
|
log.w("TCP: try ${attempt + 1} to $ip:$port failed: ${e.message}")
|
||||||
|
if (!bindRefused && e.message?.contains("EPERM") == true) {
|
||||||
|
bindRefused = true
|
||||||
|
log.i("TCP: Android won't let Gearslip use the car's network directly; trying the phone's ordinary route")
|
||||||
|
}
|
||||||
if (attempt == 0) probe(context, net, ip, port)
|
if (attempt == 0) probe(context, net, ip, port)
|
||||||
Thread.sleep(1_000)
|
Thread.sleep(1_000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
log.e("TCP: gave up on $ip:$port", last)
|
log.e("TCP: gave up on $ip:$port", last)
|
||||||
|
if (vpnActive(context)) {
|
||||||
|
SessionStatus.failed(
|
||||||
|
"A VPN is blocking the car",
|
||||||
|
"Gearslip joined $name's network, but a VPN on this phone won't let it reach the car. " +
|
||||||
|
"Turn the VPN off, or exclude Gearslip in its settings, then try again.",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
SessionStatus.failed(
|
SessionStatus.failed(
|
||||||
"Car's Wi-Fi joined, but no connection",
|
"Car's Wi-Fi joined, but no connection",
|
||||||
"Gearslip joined $name's network but couldn't reach Android Auto at $ip:$port.",
|
"Gearslip joined $name's network but couldn't reach Android Auto at $ip:$port.",
|
||||||
|
|||||||
@@ -9,4 +9,12 @@
|
|||||||
<item name="android:windowNoTitle">true</item>
|
<item name="android:windowNoTitle">true</item>
|
||||||
<item name="windowActionBar">false</item>
|
<item name="windowActionBar">false</item>
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
<!-- For PermissionActivity: only Android's own permission dialog shows, over whatever was there. -->
|
||||||
|
<style name="Theme.Gearslip.Invisible" parent="Theme.Gearslip">
|
||||||
|
<item name="android:windowIsTranslucent">true</item>
|
||||||
|
<item name="android:windowBackground">@android:color/transparent</item>
|
||||||
|
<item name="android:windowAnimationStyle">@null</item>
|
||||||
|
<item name="android:backgroundDimEnabled">false</item>
|
||||||
|
</style>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package app.seb3thehacker.gearslip
|
package app.seb3thehacker.gearslip
|
||||||
|
|
||||||
|
import android.view.Surface
|
||||||
import java.io.ByteArrayInputStream
|
import java.io.ByteArrayInputStream
|
||||||
import java.io.ByteArrayOutputStream
|
import java.io.ByteArrayOutputStream
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
@@ -7,17 +8,20 @@ import java.io.InputStream
|
|||||||
import java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
import java.util.concurrent.CountDownLatch
|
import java.util.concurrent.CountDownLatch
|
||||||
import java.util.concurrent.ExecutorService
|
import java.util.concurrent.ExecutorService
|
||||||
|
import java.util.concurrent.FutureTask
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import javax.net.ssl.SSLContext
|
import javax.net.ssl.SSLContext
|
||||||
|
import org.junit.Assert.assertArrayEquals
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
class ConnectionLifecycleTest {
|
class ConnectionLifecycleTest {
|
||||||
private val version = message(1, byteArrayOf(0, 1, 0, 3))
|
private val version = message(1, byteArrayOf(0, 1, 0, 3))
|
||||||
|
|
||||||
private fun message(id: Int, body: ByteArray = byteArrayOf()) = Frames.build(
|
private fun message(id: Int, body: ByteArray = byteArrayOf()) = Frames.build(
|
||||||
Frames.CHANNEL_CONTROL, false, byteArrayOf(0, id.toByte()) + body,
|
Frames.CHANNEL_CONTROL, false, byteArrayOf((id ushr 8).toByte(), id.toByte()) + body,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun hello(): ByteArray {
|
private fun hello(): ByteArray {
|
||||||
@@ -41,13 +45,13 @@ class ConnectionLifecycleTest {
|
|||||||
private fun runner(input: InputStream) = GearslipRunner(input, ByteArrayOutputStream(), ::identity)
|
private fun runner(input: InputStream) = GearslipRunner(input, ByteArrayOutputStream(), ::identity)
|
||||||
|
|
||||||
@Test fun `rejected authentication ignores subsequent success and service discovery in the same read`() {
|
@Test fun `rejected authentication ignores subsequent success and service discovery in the same read`() {
|
||||||
for (status in listOf(-2, -3)) {
|
for (status in listOf(-2, -3, -23, -24)) {
|
||||||
runner(ByteArrayInputStream(
|
runner(ByteArrayInputStream(
|
||||||
version + hello() + message(4, Protobuf.varintField(1, status.toLong())) +
|
version + hello() + message(4, Protobuf.varintField(1, status.toLong())) +
|
||||||
message(4, Protobuf.varintField(1, 0)) + message(6) + version,
|
message(4, Protobuf.varintField(1, 0)) + message(6) + version,
|
||||||
)).run()
|
)).run()
|
||||||
assertEquals(
|
assertEquals(
|
||||||
if (status == -2) SessionReport.Category.CERTIFICATE else SessionReport.Category.AUTH,
|
if (status == -3) SessionReport.Category.AUTH else SessionReport.Category.CERTIFICATE,
|
||||||
SessionReport.category,
|
SessionReport.category,
|
||||||
)
|
)
|
||||||
assertEquals(SessionStatus.Phase.FAILED, SessionStatus.state.value.phase)
|
assertEquals(SessionStatus.Phase.FAILED, SessionStatus.state.value.phase)
|
||||||
@@ -56,14 +60,70 @@ class ConnectionLifecycleTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test fun `protocol error terminates setup and ignores later messages`() {
|
||||||
|
for (error in listOf(255, 65535)) {
|
||||||
|
val runner = runner(ByteArrayInputStream(version + message(error) + version + message(6)))
|
||||||
|
runner.run()
|
||||||
|
assertFalse(getField(runner, "running") as Boolean)
|
||||||
|
assertEquals(SessionReport.Category.UNKNOWN, SessionReport.category)
|
||||||
|
assertEquals("Protocol error", SessionStatus.state.value.headline)
|
||||||
|
assertEquals(SessionReport.Stage.VERSIONS, SessionReport.stage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun `head unit can end the session before authentication`() {
|
||||||
|
val output = ByteArrayOutputStream()
|
||||||
|
val runner = GearslipRunner(ByteArrayInputStream(version + message(15, Protobuf.varintField(1, 7))), output, ::identity)
|
||||||
|
runner.run()
|
||||||
|
val parser = Frames.Parser()
|
||||||
|
parser.append(output.toByteArray(), output.size())
|
||||||
|
assertEquals(2, requireNotNull(parser.next()).payload[1].toInt())
|
||||||
|
val reply = requireNotNull(parser.next())
|
||||||
|
assertFalse(reply.encrypted)
|
||||||
|
assertArrayEquals(byteArrayOf(0, 16), reply.payload)
|
||||||
|
assertEquals(SessionReport.Category.BYEBYE, SessionReport.category)
|
||||||
|
assertEquals(SessionStatus.Phase.DISCONNECTED, SessionStatus.state.value.phase)
|
||||||
|
}
|
||||||
|
|
||||||
@Test fun `TLS failure ignores later protocol messages`() {
|
@Test fun `TLS failure ignores later protocol messages`() {
|
||||||
val fatalAlert = message(3, byteArrayOf(21, 3, 3, 0, 2, 2, 42))
|
val fatalAlert = message(3, byteArrayOf(21, 3, 3, 0, 2, 2, 42))
|
||||||
runner(ByteArrayInputStream(version + hello() + fatalAlert + version + message(6))).run()
|
runner(ByteArrayInputStream(version + hello() + fatalAlert + version + message(6))).run()
|
||||||
assertEquals(SessionReport.Category.TLS, SessionReport.category)
|
assertEquals(SessionReport.Category.TLS, SessionReport.category)
|
||||||
assertEquals("Head unit rejected the certificate", SessionStatus.state.value.headline)
|
assertEquals("Secure connection failed", SessionStatus.state.value.headline)
|
||||||
assertTrue(SessionReport.stage < SessionReport.Stage.ACCEPTED)
|
assertTrue(SessionReport.stage < SessionReport.Stage.ACCEPTED)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test fun `local TLS failure ends the session without waiting for peer EOF`() {
|
||||||
|
val input = CountingInput(version + hello())
|
||||||
|
val runner = GearslipRunner(input, ByteArrayOutputStream(), { error("identity unavailable") })
|
||||||
|
runner.run()
|
||||||
|
assertEquals(1, input.reads)
|
||||||
|
assertEquals(SessionReport.Category.TLS, SessionReport.category)
|
||||||
|
assertEquals("Secure connection failed", SessionStatus.state.value.headline)
|
||||||
|
assertTrue((getField(runner, "writer") as ExecutorService).isShutdown)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun `unsupported version sends rejection and ends without waiting for peer EOF`() {
|
||||||
|
val input = CountingInput(message(1, byteArrayOf(0, 0, 0, 9)))
|
||||||
|
val output = ByteArrayOutputStream()
|
||||||
|
val runner = GearslipRunner(input, output, ::identity)
|
||||||
|
runner.run()
|
||||||
|
assertEquals(1, input.reads)
|
||||||
|
val parser = Frames.Parser()
|
||||||
|
parser.append(output.toByteArray(), output.size())
|
||||||
|
val response = requireNotNull(parser.next())
|
||||||
|
assertArrayEquals(byteArrayOf(0, 2) + ProtocolVersion.respond(0, 9).bytes, response.payload)
|
||||||
|
assertEquals("Unsupported protocol", SessionStatus.state.value.headline)
|
||||||
|
}
|
||||||
|
|
||||||
|
private class CountingInput(bytes: ByteArray) : ByteArrayInputStream(bytes) {
|
||||||
|
var reads = 0
|
||||||
|
override fun read(bytes: ByteArray, offset: Int, length: Int): Int {
|
||||||
|
reads++
|
||||||
|
return super.read(bytes, offset, length)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test fun `authentication success before TLS completes is ignored`() {
|
@Test fun `authentication success before TLS completes is ignored`() {
|
||||||
runner(ByteArrayInputStream(version + message(4, Protobuf.varintField(1, 0)))).run()
|
runner(ByteArrayInputStream(version + message(4, Protobuf.varintField(1, 0)))).run()
|
||||||
assertEquals(SessionReport.Category.TLS, SessionReport.category)
|
assertEquals(SessionReport.Category.TLS, SessionReport.category)
|
||||||
@@ -133,4 +193,64 @@ class ConnectionLifecycleTest {
|
|||||||
assertTrue((field.get(runner) as ExecutorService).isShutdown)
|
assertTrue((field.get(runner) as ExecutorService).isShutdown)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@Test fun `ping echoes the full timestamp and optional payload`() {
|
||||||
|
for (payload in listOf(null, byteArrayOf(), byteArrayOf(0, -1, 42))) {
|
||||||
|
val output = ByteArrayOutputStream()
|
||||||
|
val runner = GearslipRunner(ByteArrayInputStream(byteArrayOf()), output, ::identity)
|
||||||
|
val identity = identity()
|
||||||
|
val tls = FutureTask { PhoneTls(identity.keyStore, identity.password) }.apply { run() }
|
||||||
|
setField(runner, "identityTask", tls)
|
||||||
|
val timestamp = 0x1234_5678_9abcL
|
||||||
|
val request = Protobuf.varintField(1, timestamp) + Protobuf.varintField(2, 1) +
|
||||||
|
(payload?.let { Protobuf.bytesField(3, it) } ?: byteArrayOf())
|
||||||
|
val ping = GearslipRunner::class.java.getDeclaredMethod("onPingRequest", ByteArray::class.java)
|
||||||
|
.apply { isAccessible = true }
|
||||||
|
try {
|
||||||
|
ping.invoke(runner, request)
|
||||||
|
val writer = getField(runner, "writer") as ExecutorService
|
||||||
|
writer.shutdown()
|
||||||
|
assertTrue(writer.awaitTermination(2, TimeUnit.SECONDS))
|
||||||
|
val parser = Frames.Parser()
|
||||||
|
parser.append(output.toByteArray(), output.size())
|
||||||
|
val response = requireNotNull(parser.next()).payload
|
||||||
|
assertEquals(12, response[1].toInt())
|
||||||
|
val fields = Wire.fields(response.copyOfRange(2, response.size))
|
||||||
|
assertEquals(timestamp, Wire.varint(fields, 1))
|
||||||
|
assertArrayEquals(payload, Wire.bytes(fields, 2))
|
||||||
|
} finally {
|
||||||
|
runner.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getField(runner: GearslipRunner, name: String): Any? =
|
||||||
|
GearslipRunner::class.java.getDeclaredField(name).apply { isAccessible = true }.get(runner)
|
||||||
|
|
||||||
|
private fun setField(runner: GearslipRunner, name: String, value: Any) {
|
||||||
|
GearslipRunner::class.java.getDeclaredField(name).apply { isAccessible = true }.set(runner, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Test fun `touchpad reports are not injected as touchscreen taps`() {
|
||||||
|
var touches = 0
|
||||||
|
val projection = object : Projection {
|
||||||
|
override fun onSurfaceReady(surface: Surface, width: Int, height: Int, densityDpi: Int) = Unit
|
||||||
|
override fun onTouch(action: Int, actionIndex: Int, points: List<TouchPoint>) { touches++ }
|
||||||
|
override fun onProjectionStopped() = Unit
|
||||||
|
}
|
||||||
|
val runner = GearslipRunner(ByteArrayInputStream(byteArrayOf()), ByteArrayOutputStream(), ::identity, projection)
|
||||||
|
val input = GearslipRunner::class.java.getDeclaredMethod("onInputReport", ByteArray::class.java)
|
||||||
|
.apply { isAccessible = true }
|
||||||
|
val touch = Protobuf.bytesField(1, Protobuf.varintField(1, 100) +
|
||||||
|
Protobuf.varintField(2, 200) + Protobuf.varintField(3, 0))
|
||||||
|
try {
|
||||||
|
input.invoke(runner, Protobuf.varintField(1, 1000) + Protobuf.bytesField(7, touch))
|
||||||
|
assertEquals(0, touches)
|
||||||
|
input.invoke(runner, Protobuf.varintField(1, 1000) + Protobuf.bytesField(3, touch))
|
||||||
|
assertEquals(1, touches)
|
||||||
|
} finally {
|
||||||
|
runner.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package app.seb3thehacker.gearslip
|
package app.seb3thehacker.gearslip
|
||||||
|
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
@@ -42,6 +43,14 @@ class CryptoTest {
|
|||||||
assertTrue(TlsSelfTest.run(identity))
|
assertTrue(TlsSelfTest.run(identity))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `TLS still connects a head unit that presents no certificate, as homemade ones may`() {
|
||||||
|
val generated = SelfSignedCert.generate()
|
||||||
|
val identity = CertProvider.Identity(generated.keyStore, generated.certificate,
|
||||||
|
SelfSignedCert.PASSWORD, "test")
|
||||||
|
assertTrue(TlsSelfTest.run(identity, withClientCertificate = false))
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `frames round-trip through the parser`() {
|
fun `frames round-trip through the parser`() {
|
||||||
val payload = ByteArray(300) { it.toByte() }
|
val payload = ByteArray(300) { it.toByte() }
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package app.seb3thehacker.gearslip
|
||||||
|
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.io.IOException
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class FramingTest {
|
||||||
|
@Test fun `fragmentation survives every byte arriving separately`() {
|
||||||
|
val message = ByteArray(40_000) { it.toByte() }
|
||||||
|
val output = ByteArrayOutputStream()
|
||||||
|
assertEquals(3, Frames.writeMessage(output, 5, false, Frames.MESSAGE_SPECIFIC, message) { error("plaintext") })
|
||||||
|
val parser = Frames.Parser()
|
||||||
|
val assembler = Frames.Assembler()
|
||||||
|
var received: ByteArray? = null
|
||||||
|
for (byte in output.toByteArray()) {
|
||||||
|
parser.append(byteArrayOf(byte), 1)
|
||||||
|
parser.next()?.let { received = assembler.offer(it, it.payload) }
|
||||||
|
}
|
||||||
|
assertArrayEquals(message, received)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun `encrypted fragments count plaintext length and encrypt each chunk`() {
|
||||||
|
val output = ByteArrayOutputStream()
|
||||||
|
val message = ByteArray(20_000) { 42 }
|
||||||
|
Frames.writeMessage(output, 1, true, Frames.MESSAGE_SPECIFIC, message) { byteArrayOf(99) + it }
|
||||||
|
val parser = Frames.Parser()
|
||||||
|
parser.append(output.toByteArray(), output.size())
|
||||||
|
val first = parser.next()!!
|
||||||
|
val last = parser.next()!!
|
||||||
|
assertEquals(20_000, first.totalSize)
|
||||||
|
assertEquals(16_121, first.payload.size)
|
||||||
|
assertEquals(Frames.TYPE_LAST, last.frameType)
|
||||||
|
val assembler = Frames.Assembler()
|
||||||
|
assertNull(assembler.offer(first, first.payload.drop(1).toByteArray()))
|
||||||
|
assertArrayEquals(message, assembler.offer(last, last.payload.drop(1).toByteArray()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun `fragment budget includes the short or extended header`() {
|
||||||
|
for (size in listOf(16_124, 16_125, 32_245)) {
|
||||||
|
val output = ByteArrayOutputStream()
|
||||||
|
Frames.writeMessage(output, 1, false, Frames.MESSAGE_SPECIFIC, ByteArray(size)) { error("plaintext") }
|
||||||
|
val parser = Frames.Parser()
|
||||||
|
parser.append(output.toByteArray(), output.size())
|
||||||
|
val first = parser.next()!!
|
||||||
|
if (size == 16_124) {
|
||||||
|
assertEquals(Frames.TYPE_BULK, first.frameType)
|
||||||
|
assertEquals(16_124, first.payload.size)
|
||||||
|
} else {
|
||||||
|
assertEquals(Frames.TYPE_FIRST, first.frameType)
|
||||||
|
assertEquals(size, first.totalSize)
|
||||||
|
assertEquals(16_120, first.payload.size)
|
||||||
|
val next = parser.next()!!
|
||||||
|
if (size == 32_245) {
|
||||||
|
assertEquals(Frames.TYPE_MIDDLE, next.frameType)
|
||||||
|
assertEquals(16_124, next.payload.size)
|
||||||
|
val last = parser.next()!!
|
||||||
|
assertEquals(Frames.TYPE_LAST, last.frameType)
|
||||||
|
assertEquals(1, last.payload.size)
|
||||||
|
} else {
|
||||||
|
assertEquals(Frames.TYPE_LAST, next.frameType)
|
||||||
|
assertEquals(5, next.payload.size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertNull(parser.next())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun `channel control messages cannot be fragmented`() {
|
||||||
|
assertThrows(IllegalArgumentException::class.java) {
|
||||||
|
Frames.writeMessage(ByteArrayOutputStream(), 1, false, Frames.MESSAGE_CONTROL, ByteArray(16_125)) { it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun `malformed and mismatched fragment runs are rejected`() {
|
||||||
|
val first = Frames.Frame(1, Frames.TYPE_FIRST, true, 0, byteArrayOf(), 4)
|
||||||
|
val last = Frames.Frame(1, Frames.TYPE_LAST, true, 0, byteArrayOf())
|
||||||
|
assertThrows(IOException::class.java) { Frames.Assembler().offer(last, byteArrayOf(1)) }
|
||||||
|
val assembler = Frames.Assembler()
|
||||||
|
assembler.offer(first, byteArrayOf(1, 2))
|
||||||
|
assertThrows(IOException::class.java) { assembler.offer(last, byteArrayOf(3)) }
|
||||||
|
assembler.offer(first, byteArrayOf(1, 2))
|
||||||
|
assertThrows(IOException::class.java) { assembler.offer(last, byteArrayOf(3, 4, 5)) }
|
||||||
|
assembler.offer(first, byteArrayOf(1, 2))
|
||||||
|
assertThrows(IOException::class.java) { assembler.offer(first, byteArrayOf(1, 2)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun `oversized declared message is rejected before allocation`() {
|
||||||
|
val parser = Frames.Parser()
|
||||||
|
val frame = byteArrayOf(1, 1, 0, 1, 0x7f, -1, -1, -1, 0)
|
||||||
|
parser.append(frame, frame.size)
|
||||||
|
assertThrows(IOException::class.java) { parser.next() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package app.seb3thehacker.gearslip
|
||||||
|
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class ProtobufTest {
|
||||||
|
@Test fun `malformed suffix invalidates the whole message`() {
|
||||||
|
for (suffix in listOf(byteArrayOf(0), byteArrayOf(18, -1), byteArrayOf(13, 1), byteArrayOf(17, 1))) {
|
||||||
|
assertTrue(Wire.fields(byteArrayOf(8, 2) + suffix).isEmpty())
|
||||||
|
assertNull(Protobuf.readInt32Field(byteArrayOf(8, 2) + suffix, 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun `lengths cannot wrap when narrowed to int`() {
|
||||||
|
val huge = byteArrayOf(18) + Protobuf.varint(0x1_0000_0001) + byteArrayOf(42)
|
||||||
|
assertTrue(Wire.fields(huge).isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun `last singular field wins and negative int32 keeps its sign`() {
|
||||||
|
assertEquals(2, Protobuf.readInt32Field(byteArrayOf(8, 1, 8, 2), 1))
|
||||||
|
assertEquals(-3, Protobuf.readInt32Field(Protobuf.varintField(1, -3), 1))
|
||||||
|
assertEquals(8_803_621_255L, Protobuf.readInt64Field(Protobuf.varintField(1, 8_803_621_255L), 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package app.seb3thehacker.gearslip
|
||||||
|
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class ProtocolVersionTest {
|
||||||
|
@Test fun `advertises supported version to older and newer 1x peers`() {
|
||||||
|
assertArrayEquals(byteArrayOf(0, 1, 0, 7, 0, 0), ProtocolVersion.respond(1, 3).bytes)
|
||||||
|
assertArrayEquals(byteArrayOf(0, 1, 0, 7, 0, 0), ProtocolVersion.respond(1, 7).bytes)
|
||||||
|
assertArrayEquals(byteArrayOf(0, 1, 0, 7, 0, 0), ProtocolVersion.respond(1, 9).bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun `newer head units get the 1x answer they accept`() {
|
||||||
|
assertArrayEquals(byteArrayOf(0, 1, 0, 7, 0, 0), ProtocolVersion.respond(4, 2).bytes)
|
||||||
|
assertArrayEquals(byteArrayOf(0, 1, 0, 7, 0, 0), ProtocolVersion.respond(6, 1).bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun `major version 0 is refused`() {
|
||||||
|
val response = ProtocolVersion.respond(0, 9)
|
||||||
|
assertFalse(response.supported)
|
||||||
|
assertArrayEquals(byteArrayOf(0, 1, 0, 7, -1, -1), response.bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package app.seb3thehacker.gearslip
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class ServiceDiscoveryChannelTest {
|
||||||
|
private fun service(id: Long, field: Int, payload: ByteArray = byteArrayOf()) =
|
||||||
|
Protobuf.bytesField(1, Protobuf.varintField(1, id) + Protobuf.bytesField(field, payload))
|
||||||
|
|
||||||
|
@Test fun `invalid channel IDs cannot alias the control or another service channel`() {
|
||||||
|
val config = Protobuf.varintField(1, 16000) + Protobuf.varintField(2, 16) + Protobuf.varintField(3, 1)
|
||||||
|
val audio = Protobuf.bytesField(3, config)
|
||||||
|
val microphone = Protobuf.bytesField(2, config)
|
||||||
|
val sensor = Protobuf.bytesField(1, Protobuf.varintField(1, 1))
|
||||||
|
for (id in listOf(-1L, 0L, 256L, 0x1_0000_0001L)) {
|
||||||
|
assertNull(ServiceDiscovery.findMediaStatusChannel(service(id, 9)))
|
||||||
|
assertNull(ServiceDiscovery.findNavStatusService(service(id, 8, Protobuf.varintField(1, 1000))))
|
||||||
|
assertNull(ServiceDiscovery.findVideoService(service(id, 3, Protobuf.bytesField(4, byteArrayOf()))))
|
||||||
|
assertNull(ServiceDiscovery.findInputService(service(id, 4)))
|
||||||
|
assertNull(ServiceDiscovery.findMicService(service(id, 5, microphone)))
|
||||||
|
assertNull(ServiceDiscovery.findSensorService(service(id, 2, sensor)))
|
||||||
|
assertTrue(ServiceDiscovery.findAudioServices(service(id, 3, audio)).isEmpty())
|
||||||
|
}
|
||||||
|
assertEquals(255, ServiceDiscovery.findMediaStatusChannel(service(255, 9)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun `navigation discovery preserves the requested update interval`() {
|
||||||
|
val navigation = requireNotNull(ServiceDiscovery.findNavStatusService(service(6, 8, Protobuf.varintField(1, 1500))))
|
||||||
|
assertEquals(6, navigation.serviceId)
|
||||||
|
assertEquals(1500, navigation.minimumIntervalMs)
|
||||||
|
assertNull(ServiceDiscovery.findNavStatusService(service(6, 8)))
|
||||||
|
assertNull(ServiceDiscovery.findNavStatusService(service(6, 8, Protobuf.varintField(1, -1))))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
|
import app.seb3thehacker.gearslip.Protobuf
|
||||||
|
import java.util.concurrent.CountDownLatch
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class ClusterNavTest {
|
||||||
|
@Test fun `head unit minimum interval limits repeated navigation updates`() {
|
||||||
|
val first = CountDownLatch(1)
|
||||||
|
val next = CountDownLatch(1)
|
||||||
|
val count = AtomicInteger()
|
||||||
|
val nav = ClusterNav(6, 1000) { _, _ ->
|
||||||
|
if (count.incrementAndGet() == 1) first.countDown() else next.countDown()
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
nav.onMessage(8, Protobuf.varintField(1, 0))
|
||||||
|
assertTrue(first.await(2, TimeUnit.SECONDS))
|
||||||
|
nav.onMessage(32769, byteArrayOf())
|
||||||
|
assertFalse("the fixed 500ms timer must not override the head unit", next.await(700, TimeUnit.MILLISECONDS))
|
||||||
|
assertTrue(next.await(2, TimeUnit.SECONDS))
|
||||||
|
} finally {
|
||||||
|
nav.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun `head unit stop pauses updates and start resends current status`() {
|
||||||
|
val messages = mutableListOf<Int>()
|
||||||
|
val nav = ClusterNav(6) { id, _ -> messages += id }
|
||||||
|
// Drive polling synchronously so the test does not depend on a background timer.
|
||||||
|
val running = ClusterNav::class.java.getDeclaredField("running").apply { isAccessible = true }
|
||||||
|
val tick = ClusterNav::class.java.getDeclaredMethod("tick").apply { isAccessible = true }
|
||||||
|
running.set(nav, true)
|
||||||
|
try {
|
||||||
|
tick.invoke(nav)
|
||||||
|
assertEquals(listOf(32771), messages)
|
||||||
|
nav.onMessage(32770, byteArrayOf())
|
||||||
|
tick.invoke(nav)
|
||||||
|
assertEquals(1, messages.size)
|
||||||
|
nav.onMessage(32769, byteArrayOf())
|
||||||
|
tick.invoke(nav)
|
||||||
|
assertEquals(listOf(32771, 32771), messages)
|
||||||
|
nav.stop()
|
||||||
|
tick.invoke(nav)
|
||||||
|
assertEquals(2, messages.size)
|
||||||
|
} finally {
|
||||||
|
nav.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun `missing channel open status does not start publishing`() {
|
||||||
|
val nav = ClusterNav(6) { _, _ -> error("channel was not accepted") }
|
||||||
|
nav.onMessage(8, byteArrayOf())
|
||||||
|
val running = ClusterNav::class.java.getDeclaredField("running").apply { isAccessible = true }
|
||||||
|
assertFalse(running.getBoolean(nav))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package app.seb3thehacker.gearslip.protocol
|
||||||
|
|
||||||
|
import app.seb3thehacker.gearslip.Protobuf
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class InputProtocolTest {
|
||||||
|
private fun pointer(id: Int) = Protobuf.bytesField(1,
|
||||||
|
Protobuf.varintField(1, 100) + Protobuf.varintField(2, 200) + Protobuf.varintField(3, id.toLong()))
|
||||||
|
|
||||||
|
@Test fun `multitouch preserves pointer IDs and action index`() {
|
||||||
|
val body = pointer(7) + pointer(3) + Protobuf.varintField(2, 1) + Protobuf.varintField(3, 5)
|
||||||
|
val touch = requireNotNull(InputProtocol.touch(body, 800 to 480))
|
||||||
|
assertEquals(5, touch.action)
|
||||||
|
assertEquals(1, touch.actionIndex)
|
||||||
|
assertEquals(listOf(7, 3), touch.points.map { it.id })
|
||||||
|
assertEquals(100f, touch.points[1].x)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun `invalid pointer index is rejected instead of targeting a different finger`() {
|
||||||
|
assertNull(InputProtocol.touch(pointer(0) + Protobuf.varintField(2, 1), null))
|
||||||
|
assertNull(InputProtocol.touch(pointer(0) + Protobuf.varintField(2, -1), null))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun `missing required pointer fields and duplicate IDs are rejected`() {
|
||||||
|
assertNull(InputProtocol.touch(Protobuf.bytesField(1, Protobuf.varintField(1, 20)), null))
|
||||||
|
assertNull(InputProtocol.touch(pointer(1) + pointer(1), null))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun `pointer IDs must fit the Android motion event range`() {
|
||||||
|
for (id in listOf(-1L, 32L, 0x1_0000_0000L)) {
|
||||||
|
val body = Protobuf.bytesField(1, Protobuf.varintField(1, 100) +
|
||||||
|
Protobuf.varintField(2, 200) + Protobuf.varintField(3, id))
|
||||||
|
assertNull(InputProtocol.touch(body, null))
|
||||||
|
}
|
||||||
|
assertEquals(31, requireNotNull(InputProtocol.touch(pointer(31), null)).points.single().id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun `oversized pointer sets are rejected before constructing motion events`() {
|
||||||
|
val body = (0..32).fold(byteArrayOf()) { bytes, id -> bytes + pointer(id) }
|
||||||
|
assertNull(InputProtocol.touch(body, null))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun `optional touch fields use down and first pointer defaults`() {
|
||||||
|
val touch = requireNotNull(InputProtocol.touch(pointer(0), 50 to 60))
|
||||||
|
assertEquals(0, touch.action)
|
||||||
|
assertEquals(0, touch.actionIndex)
|
||||||
|
assertEquals(49f, touch.points.single().x)
|
||||||
|
assertEquals(59f, touch.points.single().y)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,13 +8,12 @@ espressoCore = "3.7.0"
|
|||||||
lifecycleRuntimeKtx = "2.10.0"
|
lifecycleRuntimeKtx = "2.10.0"
|
||||||
activityCompose = "1.13.0"
|
activityCompose = "1.13.0"
|
||||||
composeBom = "2026.05.01"
|
composeBom = "2026.05.01"
|
||||||
carApp = "1.7.0"
|
carApp = "1.8.0-rc01"
|
||||||
appcompat = "1.7.1"
|
appcompat = "1.7.1"
|
||||||
material = "1.14.0"
|
material = "1.14.0"
|
||||||
constraintlayout = "2.2.1"
|
constraintlayout = "2.2.1"
|
||||||
zxing = "3.5.4"
|
zxing = "3.5.4"
|
||||||
okhttp = "5.4.0"
|
okhttp = "5.4.0"
|
||||||
commonsCompress = "1.28.0"
|
|
||||||
kotlinxSerializationJson = "1.11.0"
|
kotlinxSerializationJson = "1.11.0"
|
||||||
ossLicenses = "17.5.1"
|
ossLicenses = "17.5.1"
|
||||||
webkit = "1.16.0"
|
webkit = "1.16.0"
|
||||||
@@ -50,7 +49,6 @@ androidx-constraintlayout = { group = "androidx.constraintlayout", name = "const
|
|||||||
google-material = { group = "com.google.android.material", name = "material", version.ref = "material" }
|
google-material = { group = "com.google.android.material", name = "material", version.ref = "material" }
|
||||||
zxing-core = { group = "com.google.zxing", name = "core", version.ref = "zxing" }
|
zxing-core = { group = "com.google.zxing", name = "core", version.ref = "zxing" }
|
||||||
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
|
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
|
||||||
commons-compress = { group = "org.apache.commons", name = "commons-compress", version.ref = "commonsCompress" }
|
|
||||||
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
|
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
|
||||||
google-oss-licenses = { group = "com.google.android.gms", name = "play-services-oss-licenses", version.ref = "ossLicenses" }
|
google-oss-licenses = { group = "com.google.android.gms", name = "play-services-oss-licenses", version.ref = "ossLicenses" }
|
||||||
androidx-webkit = { group = "androidx.webkit", name = "webkit", version.ref = "webkit" }
|
androidx-webkit = { group = "androidx.webkit", name = "webkit", version.ref = "webkit" }
|
||||||
|
|||||||