mirror of
https://github.com/twinksoftware/Gearslip.git
synced 2026-11-11 00:12:24 +01:00
Compare commits
47
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9620dab758 | ||
|
|
e318b651bb | ||
|
|
3ac815eee4 | ||
|
|
10cc17bb66 | ||
|
|
ca09d6adb8 | ||
|
|
af058609dd | ||
|
|
a4ab5b005e | ||
|
|
672a68cd1b | ||
|
|
24d7e4c415 | ||
|
|
1127d4baf7 | ||
|
|
dc550ab735 | ||
|
|
5380cf0660 | ||
|
|
7cc6b64ce2 | ||
|
|
b5ff4ec22b | ||
|
|
302dd6c344 | ||
|
|
62fb8babad | ||
|
|
fe44576b06 | ||
|
|
355eb8bffd | ||
|
|
7d197bf021 | ||
|
|
cf0c324d80 | ||
|
|
5a3bbe82a0 | ||
|
|
b6bd6497b2 | ||
|
|
0eeb486d88 | ||
|
|
1adeb6f7e1 | ||
|
|
bc0d6302d3 | ||
|
|
1341b4a08a | ||
|
|
c6b7f0e178 | ||
|
|
d3592457cf | ||
|
|
024850bf48 | ||
|
|
96d16d048c | ||
|
|
1b44d2d798 | ||
|
|
e4725233dd | ||
|
|
913e4af133 | ||
|
|
07d164ac74 | ||
|
|
01a7ae1930 | ||
|
|
c52a6c32b7 | ||
|
|
f21df41506 | ||
|
|
ff8a6a4cff | ||
|
|
a2d9ed7008 | ||
|
|
3994c9bd2e | ||
|
|
4e4a246a51 | ||
|
|
ebd0ba4add | ||
|
|
42785ce6be | ||
|
|
db205919c8 | ||
|
|
f452099926 | ||
|
|
03b74b1150 | ||
|
|
fbaeb0cc57 |
No files matched your search
@@ -0,0 +1,59 @@
|
|||||||
|
name: Bug report
|
||||||
|
description: Something in Gearslip broke or behaves wrong.
|
||||||
|
labels: ["bug"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Thanks for reporting. Check the [open issues](https://github.com/Seb3thehacker/gearslip/issues) first, in case someone has already reported it.
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: car
|
||||||
|
attributes:
|
||||||
|
label: Car
|
||||||
|
description: Make, model, and year.
|
||||||
|
placeholder: 2019 Honda Civic
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: head-unit
|
||||||
|
attributes:
|
||||||
|
label: Head unit
|
||||||
|
options:
|
||||||
|
- The one the car came with
|
||||||
|
- A factory unit from another car
|
||||||
|
- An aftermarket unit (Pioneer, Kenwood, Alpine, and so on)
|
||||||
|
- A homemade or open-source unit, such as LIVI
|
||||||
|
- I don't know
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: connection
|
||||||
|
attributes:
|
||||||
|
label: Connection
|
||||||
|
options:
|
||||||
|
- USB cable
|
||||||
|
- Wireless dongle
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: what-happened
|
||||||
|
attributes:
|
||||||
|
label: What happened
|
||||||
|
description: What you did, what you expected, and what happened instead.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: log
|
||||||
|
attributes:
|
||||||
|
label: Log
|
||||||
|
description: |
|
||||||
|
On the phone, open Logs from Gearslip's home screen and tap Share log. Drag the file here.
|
||||||
|
The log records your phone, its Android version, the Gearslip version, and why the connection ended.
|
||||||
|
placeholder: Drop the log file here.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
name: Car report
|
||||||
|
description: Tell us whether Gearslip works in your car, so others know before they install.
|
||||||
|
labels: ["car report"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Reports from working cars help as much as reports from broken ones.
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: car
|
||||||
|
attributes:
|
||||||
|
label: Car
|
||||||
|
description: Make, model, and year.
|
||||||
|
placeholder: 2019 Honda Civic
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: head-unit
|
||||||
|
attributes:
|
||||||
|
label: Head unit
|
||||||
|
options:
|
||||||
|
- The one the car came with
|
||||||
|
- A factory unit from another car
|
||||||
|
- An aftermarket unit (Pioneer, Kenwood, Alpine, and so on)
|
||||||
|
- A homemade or open-source unit, such as LIVI
|
||||||
|
- I don't know
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: connection
|
||||||
|
attributes:
|
||||||
|
label: Connection
|
||||||
|
options:
|
||||||
|
- USB cable
|
||||||
|
- Wireless dongle
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: result
|
||||||
|
attributes:
|
||||||
|
label: Does it work?
|
||||||
|
options:
|
||||||
|
- Yes, everything I tried works
|
||||||
|
- Partly. It connects, but something is wrong
|
||||||
|
- No, it never connects
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: notes
|
||||||
|
attributes:
|
||||||
|
label: Notes
|
||||||
|
description: What you tried, and anything that didn't work.
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: log
|
||||||
|
attributes:
|
||||||
|
label: Log
|
||||||
|
description: |
|
||||||
|
Needed if the car doesn't work. On the phone, open Logs from Gearslip's home screen and tap Share log. Drag the file here.
|
||||||
|
placeholder: Drop the log file here.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
name: Feature request
|
||||||
|
description: Suggest something Gearslip should do.
|
||||||
|
labels: ["enhancement"]
|
||||||
|
body:
|
||||||
|
- type: textarea
|
||||||
|
id: problem
|
||||||
|
attributes:
|
||||||
|
label: What do you want to do?
|
||||||
|
description: Describe the problem, not the solution. "I can't see my next turn while music plays" helps more than "add a turn widget."
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: idea
|
||||||
|
attributes:
|
||||||
|
label: How should it work?
|
||||||
|
description: Your idea for a fix, if you have one.
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: apps
|
||||||
|
attributes:
|
||||||
|
label: Apps involved
|
||||||
|
description: Any apps this touches, such as a map or music app.
|
||||||
+18
-16
@@ -6,25 +6,27 @@
|
|||||||
/build
|
/build
|
||||||
local.properties
|
local.properties
|
||||||
|
|
||||||
# Reference checkouts only - never referenced by the build; safe to delete entirely.
|
# Keys
|
||||||
/cloned-repos
|
|
||||||
|
|
||||||
# The head unit certificate. Never committed or shared beyond who Seb sends it to directly.
|
|
||||||
/cert
|
|
||||||
|
|
||||||
# The release signing key and its passwords. Losing these breaks app updates; leaking them
|
|
||||||
# lets anyone sign an APK as Gearslip.
|
|
||||||
*.jks
|
*.jks
|
||||||
*.keystore
|
*.keystore
|
||||||
keystore.properties
|
keystore.properties
|
||||||
|
|
||||||
/docs/cert-sweep.sh
|
|
||||||
archive/
|
|
||||||
CLAUDE.md
|
|
||||||
*.pdf
|
|
||||||
extraction/
|
|
||||||
.claude/
|
|
||||||
todo
|
|
||||||
logs/
|
|
||||||
# Native build cache.
|
# Native build cache.
|
||||||
.cxx/
|
.cxx/
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
/cloned-repos
|
||||||
|
docs-internal/
|
||||||
|
|
||||||
|
# Internal
|
||||||
|
*.pdf
|
||||||
|
todo.md
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# For AI code
|
||||||
|
.claude/
|
||||||
|
CLAUDE.md
|
||||||
|
|
||||||
|
# Other
|
||||||
|
archive/
|
||||||
|
.wrangler/
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# Contributing to Gearslip
|
||||||
|
|
||||||
|
Thanks for helping. Bug reports from real cars are the most useful thing you can send, so
|
||||||
|
start there if you aren't sure where to begin.
|
||||||
|
|
||||||
|
## Reporting a bug
|
||||||
|
|
||||||
|
Open an issue on [GitHub](https://github.com/Seb3thehacker/gearslip/issues) and include:
|
||||||
|
|
||||||
|
- 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 records your phone, its Android version, the Gearslip version, the head unit, and
|
||||||
|
why the connection ended. Most car bugs can't be fixed without it. Head units often report
|
||||||
|
only a part number, so the log can't name your car for you.
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
You need Android Studio, or the Android SDK with JDK 21. Gradle downloads the NDK, CMake,
|
||||||
|
and the speech library on the first build.
|
||||||
|
|
||||||
|
```
|
||||||
|
./gradlew :gearslip:assembleDebug
|
||||||
|
```
|
||||||
|
|
||||||
|
The APK lands at `build/Gearslip-debug.apk`. It installs as Gearslip Dev, next to the
|
||||||
|
regular app.
|
||||||
|
|
||||||
|
## Pull requests
|
||||||
|
|
||||||
|
Work happens on the `experimental` branch, which merges into `main` at each release. Base your
|
||||||
|
branch on `experimental`, and open your pull request against it.
|
||||||
|
|
||||||
|
- Keep each pull request to one change, and explain the problem it solves.
|
||||||
|
- Say how you tested it: which car or head unit, which phone, and which apps.
|
||||||
|
- Code written with AI tools is welcome. Read it, test it, and stand behind it as you would
|
||||||
|
your own.
|
||||||
|
- Gearslip asks for no adb, root, or Shizuku. A feature must work with ordinary Android
|
||||||
|
permissions and on-device consent dialogs.
|
||||||
|
|
||||||
|
## Writing
|
||||||
|
|
||||||
|
Text that users read, such as screens, release notes, and docs, follows Strunk's
|
||||||
|
[*The Elements of Style*](https://www.gutenberg.org/ebooks/37134). In short: use the active
|
||||||
|
voice, state things positively, cut needless words, and use the serial comma.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Gearslip is licensed under AGPL-3.0, and your contributions are too. The author also sells a
|
||||||
|
commercial license, described in [NOTICE.md](NOTICE.md). By opening a pull request, you
|
||||||
|
agree that your contribution may be offered under that license as well.
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
# Gearslip
|
# Gearslip
|
||||||
|
|
||||||
Bring any app to your car screen.
|
An Android Auto alternative built for everyone.
|
||||||
|
|
||||||
## What Gearslip is
|
## What Gearslip is
|
||||||
|
|
||||||
@@ -13,20 +13,38 @@ and Gearslip renders that template natively on the car screen. Nothing is mirror
|
|||||||
the phone. For apps without a car interface, Gearslip offers a separate screen-sharing
|
the phone. For apps without a car interface, Gearslip offers a separate screen-sharing
|
||||||
mode.
|
mode.
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
|  |  |
|
||||||
|
| Apps sorted by kind. A check marks the ones that work. | 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. |
|
||||||
|
|  |  |
|
||||||
|
| The weather, your next event, and what you missed. | Browse the web while parked. |
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Phone calls.** Dial from a keypad, or search your contacts, then confirm before the
|
- **Phone calls.** Dial from a keypad, or search your contacts, then confirm before the
|
||||||
call goes out.
|
call goes out.
|
||||||
- **Navigation.** Gearslip hosts real Car App Library map apps on the car screen; Organic
|
- **Navigation.** Gearslip hosts real Car App Library map apps on the car screen.
|
||||||
Maps and OsmAnd both work today.
|
Organic Maps, OsmAnd, and Flitsmeister work today. Vela runs, with rough
|
||||||
|
edges.
|
||||||
- **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.
|
Playing view, an Up Next queue, and lyrics. Type a search, and apps that accept one
|
||||||
|
play the best match.
|
||||||
- **The web.** A browser template with its own on-screen keyboard, for anything that
|
- **The web.** A browser template with its own on-screen keyboard, for anything that
|
||||||
doesn't ship a car interface of its own.
|
doesn't ship a car interface of its own.
|
||||||
- **Weather and the day ahead.** A dashboard screen shows the week's calendar next to
|
- **Weather and the day ahead.** A dashboard screen shows the week's calendar next to
|
||||||
the current and coming weather.
|
the current and coming weather.
|
||||||
- **Notifications.** Read and reply to notifications from the car screen, using the same
|
- **Messages.** New messages appear as a small card on the car screen. Gearslip reads
|
||||||
on-screen keyboard.
|
each one aloud, and you reply by voice or with a quick reply. Music pauses while
|
||||||
|
Gearslip speaks or listens. Speech runs on the phone, so it needs no Google account.
|
||||||
|
- **Notifications.** Only messages, missed calls, alarms, and reminders interrupt you.
|
||||||
|
The rest wait on the dashboard.
|
||||||
|
- **Your apps, your order.** The launcher groups apps by kind: phone, maps, music,
|
||||||
|
messaging, and the rest. Press and hold an app to move it.
|
||||||
|
- **Car controls.** Steering wheel buttons, control knobs, and D-pads all work.
|
||||||
|
- **Themes.** Choose flat buttons or skeuomorphic ones that look like real keys.
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
@@ -38,16 +56,44 @@ mode.
|
|||||||
- **Templated apps work.** Gearslip renders all 16 template types that the Car App
|
- **Templated apps work.** Gearslip renders all 16 template types that the Car App
|
||||||
Library defines, and it draws its own keyboard for search, sign-in, and text fields.
|
Library defines, and it draws its own keyboard for search, sign-in, and text fields.
|
||||||
It has been tested against several real navigation and media apps.
|
It has been tested against several real navigation and media apps.
|
||||||
- **Gearslip works with [LIVI](https://github.com/f-io/LIVI),** LIVI, used in open source car dashboards, is fully supported by Gearslip.
|
- **Gearslip works with [LIVI](https://github.com/f-io/LIVI),** the open-source head unit
|
||||||
|
used in homemade car dashboards.
|
||||||
|
- **Some apps refuse Gearslip.** Spotify, Waze, and Google Messages use a version of
|
||||||
|
Google's car library that accepts only Android Auto itself. Spotify still plays through
|
||||||
|
Gearslip's own player.
|
||||||
|
- **Gearslip works best with cars from before 2020.** Some newer head units reject its
|
||||||
|
certificate.
|
||||||
|
- **Calls use the car's Bluetooth,** as they do in Android Auto. Pair the phone with the
|
||||||
|
car to hear calls through its speakers.
|
||||||
- **Audio reaches the car by capturing playback, not by mirroring the screen.** Android
|
- **Audio reaches the car by capturing playback, not by mirroring the screen.** Android
|
||||||
requires a screen-share style consent dialog and indicator for this, even though
|
shows a screen-share consent dialog for this, even though Gearslip never reads the
|
||||||
Gearslip never reads the screen; that grant is the only way a non-privileged app can
|
screen; it is the only way an ordinary app can capture another app's audio. The dialog
|
||||||
tap another app's audio. The consent normally repeats every drive; two optional adb
|
appears once per drive.
|
||||||
commands in the setup guide reduce it to a one-time grant.
|
|
||||||
- **Gearslip needs no adb, root, or Shizuku.** Every feature relies on an ordinary
|
- **Gearslip needs no adb, root, or Shizuku.** Every feature relies on an ordinary
|
||||||
Android permission or an on-device consent dialog. The two adb commands mentioned
|
Android permission or an on-device consent dialog. The setup guide offers two optional
|
||||||
above, for audio, are the sole exception: both are optional, and both only remove a
|
adb commands: one removes the audio dialog for good, and the other keeps notifications
|
||||||
dialog that the driver would otherwise see once per drive.
|
visible while audio plays.
|
||||||
|
|
||||||
|
## Usage notes
|
||||||
|
|
||||||
|
Gearslip can send short notes that show which cars it works in. They stay off unless you
|
||||||
|
turn them on, in setup or in Settings.
|
||||||
|
|
||||||
|
- **When they go out.** When you open Gearslip, at most once a day, and after each drive.
|
||||||
|
Nothing runs in the background.
|
||||||
|
- **What they carry.** The Gearslip version, plus whatever you tick: the Android version
|
||||||
|
and whether the phone runs GrapheneOS, the phone model, whether a car connected that
|
||||||
|
day, and each car's head unit, model, year, screen size, and connection result.
|
||||||
|
Settings shows the exact note.
|
||||||
|
- **What they leave out.** Your name, accounts, location, contacts, messages, and
|
||||||
|
anything you type or play.
|
||||||
|
- **How phones are counted.** A random number counts each phone once. The server keeps
|
||||||
|
only a salted hash of it.
|
||||||
|
- **Where they go.** To a Cloudflare Worker, whose code is in
|
||||||
|
[stats-worker/](stats-worker/). Cloudflare sees your IP address in delivering a note,
|
||||||
|
as it would for any website; the Worker doesn't store it.
|
||||||
|
|
||||||
|
Turn the notes off, and they stop at once.
|
||||||
|
|
||||||
## Building
|
## Building
|
||||||
|
|
||||||
@@ -67,6 +113,12 @@ keystore. Without them, the debug build still works.
|
|||||||
[docs/BUILDING_APPS.md](docs/BUILDING_APPS.md) specifies how to write apps that Gearslip
|
[docs/BUILDING_APPS.md](docs/BUILDING_APPS.md) specifies how to write apps that Gearslip
|
||||||
can render: templated apps, media apps, and the Gearslip app protocol.
|
can render: templated apps, media apps, and the Gearslip app protocol.
|
||||||
|
|
||||||
|
## Trademarks
|
||||||
|
|
||||||
|
Android, Android Auto, Google Messages, and Waze are trademarks of Google LLC. Spotify is
|
||||||
|
a trademark of Spotify AB. Gearslip is not affiliated with, endorsed by, or sponsored by
|
||||||
|
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 is licensed under AGPL-3.0; see [LICENSE](LICENSE). The author offers a separate
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
# Gearslip 0.1.0-alpha
|
|
||||||
|
|
||||||
This release lets you read and answer messages by voice, and the speech runs on your phone with no Google services.
|
|
||||||
|
|
||||||
## Messages and voice
|
|
||||||
|
|
||||||
- Messaging apps such as Signal and Molly open to your recent chats.
|
|
||||||
- Gearslip reads a message aloud, and you reply by voice. Say "cancel" or "change it" before it sends, or tap a quick reply such as your arrival time.
|
|
||||||
- Voice works on any phone, GrapheneOS included. Gearslip turns speech into text itself, so it needs no Google account.
|
|
||||||
- New messages show as a small card at the top right, even while you drive. A bar shows how long until the card closes or the reply sends.
|
|
||||||
- Only messages, missed calls, alarms, and reminders pop up. Everything else waits on the dashboard.
|
|
||||||
|
|
||||||
## Map
|
|
||||||
|
|
||||||
- The map stays loaded when you switch screens, so it no longer flashes black.
|
|
||||||
- Map apps draw more reliably and keep their connection when the phone is locked.
|
|
||||||
- The Home button shows your map app, so it no longer appears twice.
|
|
||||||
- The next turn shows in the nav bar when you leave the map.
|
|
||||||
|
|
||||||
## Controls and settings
|
|
||||||
|
|
||||||
- Steering wheel buttons, control knobs, and D-pads now work.
|
|
||||||
- To see the whole song title and artist in the nav bar, choose Settings > Player in the nav bar > Song title.
|
|
||||||
- Choose miles or kilometres under Settings > Units.
|
|
||||||
- "Button style" is now called "Theme".
|
|
||||||
- Try the voice assistant and the vehicle data screen under Settings > Experimental features.
|
|
||||||
- Logs now say why a car ended the connection.
|
|
||||||
|
|
||||||
## Known issues
|
|
||||||
|
|
||||||
- Spotify, Waze, and Google Messages updated to a version of Google's car library that only accepts Android Auto itself. Their own car screens won't open in Gearslip. Spotify still plays through the Gearslip player, which has the green check.
|
|
||||||
- Calls go through the car's Bluetooth, as they do in Android Auto. Pair your phone with the car to hear calls on its speakers.
|
|
||||||
|
|
||||||
## Install
|
|
||||||
|
|
||||||
Download `Gearslip-release.apk` below and open it on your phone. It updates 0.0.05-alpha in place and keeps your settings.
|
|
||||||
|
|
||||||
Found a bug, or a car where the buttons don't work? [Open an issue](https://github.com/Seb3thehacker/gearslip/issues) and share the log from the Logs screen in the phone app.
|
|
||||||
+65
-9
@@ -1,11 +1,12 @@
|
|||||||
# Building apps for Gearslip
|
# Building apps for Gearslip
|
||||||
|
|
||||||
Gearslip renders three kinds of app on a car screen. This page specifies all three.
|
Gearslip renders four kinds of app on a car screen. This page specifies all four.
|
||||||
|
|
||||||
| Kind | What it is | Status |
|
| Kind | What it is | Status |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| [Templated apps](#templated-apps) | An AndroidX Car App Library app. Gearslip hosts it. | Works today |
|
| [Templated apps](#templated-apps) | An AndroidX Car App Library app. Gearslip hosts it. | Works today |
|
||||||
| [Media apps](#media-apps) | A `MediaBrowserService` that opted in to Android Auto. | Works today |
|
| [Media apps](#media-apps) | A `MediaBrowserService` that opted in to Android Auto. | Works today |
|
||||||
|
| [Messaging apps](#messaging-apps) | An app whose notifications opted in to Android Auto. | Works today |
|
||||||
| [Gearslip apps](#gearslip-apps) | A native Gearslip screen, described over binder. | Specified here; not yet implemented |
|
| [Gearslip apps](#gearslip-apps) | A native Gearslip screen, described over binder. | Specified here; not yet implemented |
|
||||||
|
|
||||||
Write a templated app when your app fits the Car App Library's templates, because it runs today
|
Write a templated app when your app fits the Car App Library's templates, because it runs today
|
||||||
@@ -29,11 +30,11 @@ any other host.
|
|||||||
Your app decides which hosts may render it, through the `HostValidator` your `CarAppService`
|
Your app decides which hosts may render it, through the `HostValidator` your `CarAppService`
|
||||||
returns.
|
returns.
|
||||||
|
|
||||||
Gearslip declares `android.car.permission.TEMPLATE_RENDERER`, and the library's own
|
Gearslip declares `android.car.permission.TEMPLATE_RENDERER`. Car App Library 1.8.0-beta01
|
||||||
`HostValidator` accepts any host holding it. If you build your validator with
|
and earlier, and 1.9.0-alpha01, accept any host holding it, so Gearslip passes the default
|
||||||
`HostValidator.Builder` and no allowlist, Gearslip already passes, and you need nothing below.
|
validator. From 1.8.0-rc01 and 1.9.0-alpha02 the library checks that permission only on
|
||||||
Add an allowlist entry when you wrote a custom validator, or when you want to name Gearslip
|
Android Automotive, so on a phone Gearslip passes only when your allowlist names it. Add this
|
||||||
explicitly:
|
entry:
|
||||||
|
|
||||||
```xml
|
```xml
|
||||||
<!-- res/values/arrays.xml -->
|
<!-- res/values/arrays.xml -->
|
||||||
@@ -143,7 +144,14 @@ existing video pipe. Gearslip never reads those pixels back.
|
|||||||
|
|
||||||
Gearslip then reports which part of that surface the driver can see. Treat the visible area as
|
Gearslip then reports which part of that surface the driver can see. Treat the visible area as
|
||||||
the region safe for your own controls, and the stable area as the region that never gets
|
the region safe for your own controls, and the stable area as the region that never gets
|
||||||
covered. Gearslip keeps its chrome to a top bar, so both areas are the surface minus that bar.
|
covered. Gearslip reports the same area for both, and it depends on the template:
|
||||||
|
|
||||||
|
| Template | What Gearslip covers |
|
||||||
|
| --- | --- |
|
||||||
|
| `NavigationTemplate` | A strip along the bottom for the trip estimate |
|
||||||
|
| `MapWithContentTemplate`, `MapTemplate`, and the place list and route preview templates | A content pane on the left |
|
||||||
|
|
||||||
|
The turn card and map buttons float over the visible area, as they do in Android Auto.
|
||||||
|
|
||||||
Your app receives four gestures, never raw touches: `onClick`, `onScroll`, `onFling` and
|
Your app receives four gestures, never raw touches: `onClick`, `onScroll`, `onFling` and
|
||||||
`onScale`. Coordinates are surface pixels. Scroll distances follow `GestureDetector`'s
|
`onScale`. Coordinates are surface pixels. Scroll distances follow `GestureDetector`'s
|
||||||
@@ -197,6 +205,53 @@ can start it playing on connection.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Messaging apps
|
||||||
|
|
||||||
|
Gearslip shows conversations from the notifications your app already posts, as Android Auto
|
||||||
|
does. Your app needs no car screen of its own.
|
||||||
|
|
||||||
|
Gearslip lists a messaging app when its car descriptor declares notifications:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<!-- res/xml/automotive_app_desc.xml -->
|
||||||
|
<automotiveApp>
|
||||||
|
<uses name="notification" />
|
||||||
|
</automotiveApp>
|
||||||
|
```
|
||||||
|
|
||||||
|
Reference it from the same `com.google.android.gms.car.application` meta-data that a media app
|
||||||
|
uses. Opening your tile on the car screen shows your recent conversations.
|
||||||
|
|
||||||
|
### Posting messages
|
||||||
|
|
||||||
|
Post each conversation as a `MessagingStyle` notification. Gearslip reads the conversation
|
||||||
|
title, each message's sender and text, and whether the chat is a group, and shows the last eight
|
||||||
|
messages. Leave the sender empty on the phone owner's own messages; Gearslip treats a message
|
||||||
|
with no sender as one the driver sent.
|
||||||
|
|
||||||
|
Gearslip reads new messages aloud and lets the driver reply by voice or with a quick reply. For
|
||||||
|
that, attach two actions, either to the notification or to its `WearableExtender`:
|
||||||
|
|
||||||
|
- **Reply:** an action carrying a `RemoteInput` that allows free-form input. Gearslip fills it
|
||||||
|
with the driver's reply and fires the action's intent.
|
||||||
|
- **Mark as read:** an action with `SEMANTIC_ACTION_MARK_AS_READ`. Gearslip shows a Mark read
|
||||||
|
button when you provide it.
|
||||||
|
|
||||||
|
### Which notifications interrupt the driver
|
||||||
|
|
||||||
|
Gearslip pops up a card for a conversation, a notification with a reply action, or a
|
||||||
|
notification in the `msg`, `missed_call`, `alarm`, `reminder` or `event` category. A channel
|
||||||
|
below default importance never pops up. Every other notification waits quietly on the
|
||||||
|
dashboard.
|
||||||
|
|
||||||
|
Gearslip drops these altogether:
|
||||||
|
|
||||||
|
- ongoing notifications and group summaries;
|
||||||
|
- the `transport`, `progress`, `service`, `sys` and `navigation` categories; and
|
||||||
|
- media notifications, because the player already shows them.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Gearslip apps
|
## Gearslip apps
|
||||||
|
|
||||||
This section specifies protocol version 1. Gearslip does not yet implement it.
|
This section specifies protocol version 1. Gearslip does not yet implement it.
|
||||||
@@ -376,7 +431,7 @@ long strings. Send a screen that fits, rather than one Gearslip has to cut.
|
|||||||
"densityDpi": 160,
|
"densityDpi": 160,
|
||||||
"batteryPercent": 82,
|
"batteryPercent": 82,
|
||||||
"charging": true,
|
"charging": true,
|
||||||
"vehicle": "Uconnect"
|
"vehicle": "Honda"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -416,6 +471,7 @@ home-screen pane comes later. Anything that needs its own rendering belongs in a
|
|||||||
|
|
||||||
## Further reading
|
## Further reading
|
||||||
|
|
||||||
- [README.md](../README.md) - what Gearslip is and how to build it.
|
- [README.md](../README.md) - what Gearslip is.
|
||||||
|
- [CONTRIBUTING.md](../CONTRIBUTING.md) - how to build Gearslip and report bugs.
|
||||||
- [Android for Cars App Library](https://developer.android.com/training/cars/apps) - the
|
- [Android for Cars App Library](https://developer.android.com/training/cars/apps) - the
|
||||||
reference for templated apps.
|
reference for templated apps.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 109 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 211 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 390 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 161 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 219 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 86 KiB |
@@ -32,9 +32,12 @@ android {
|
|||||||
applicationId = "app.seb3thehacker.gearslip"
|
applicationId = "app.seb3thehacker.gearslip"
|
||||||
minSdk = 31
|
minSdk = 31
|
||||||
targetSdk = 37
|
targetSdk = 37
|
||||||
versionCode = 6
|
versionCode = 8
|
||||||
versionName = "0.1.0-alpha"
|
versionName = "0.2.00-alpha"
|
||||||
manifestPlaceholders["appLabel"] = "Gearslip"
|
manifestPlaceholders["appLabel"] = "Gearslip"
|
||||||
|
// Where opted-in phones send their daily usage note: the stats-worker/ Worker's address,
|
||||||
|
// set as gearslipStatsUrl in gradle.properties. Left empty, nothing is ever sent.
|
||||||
|
buildConfigField("String", "STATS_URL", "\"${project.findProperty("gearslipStatsUrl") ?: ""}\"")
|
||||||
}
|
}
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
|
|||||||
@@ -74,6 +74,8 @@
|
|||||||
LAUNCHER query below to find, so the setup guide's Android Auto check needs this to see
|
LAUNCHER query below to find, so the setup guide's Android Auto check needs this to see
|
||||||
whether the package is installed at all. -->
|
whether the package is installed at all. -->
|
||||||
<package android:name="com.google.android.projection.gearhead" />
|
<package android:name="com.google.android.projection.gearhead" />
|
||||||
|
<!-- Every GrapheneOS install has this; the opt-in usage note uses it to say "GrapheneOS". -->
|
||||||
|
<package android:name="app.grapheneos.setupwizard" />
|
||||||
<intent>
|
<intent>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
@@ -123,6 +125,7 @@
|
|||||||
android:name=".GearslipActivity"
|
android:name=".GearslipActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:launchMode="singleTop"
|
android:launchMode="singleTop"
|
||||||
|
android:windowSoftInputMode="adjustResize"
|
||||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden|uiMode|density|locale|layoutDirection|fontScale">
|
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden|uiMode|density|locale|layoutDirection|fontScale">
|
||||||
|
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package app.seb3thehacker.gearslip
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Who has the car's screen: Gearslip, or the car's own interface. Android Auto's "Exit" hands the
|
||||||
|
* screen back to the car with the session still running, so music keeps playing; the car's own
|
||||||
|
* Android Auto button brings Gearslip back. [GearslipRunner] fills in [request] while connected.
|
||||||
|
*/
|
||||||
|
object CarFocus {
|
||||||
|
|
||||||
|
private val projectedFlow = MutableStateFlow(true)
|
||||||
|
|
||||||
|
/** False while the car shows its own interface. */
|
||||||
|
val projected: StateFlow<Boolean> = projectedFlow
|
||||||
|
|
||||||
|
@Volatile internal var request: (() -> Unit)? = null
|
||||||
|
|
||||||
|
/** Asks the car to show its own interface. Does nothing when no car is connected. */
|
||||||
|
fun exitToCar() {
|
||||||
|
request?.invoke()
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun set(projected: Boolean) {
|
||||||
|
projectedFlow.value = projected
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,7 +47,10 @@ object CertProvider {
|
|||||||
val kind: Kind = Kind.SELF_SIGNED,
|
val kind: Kind = Kind.SELF_SIGNED,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Loaded fresh each call, so a newly imported or pushed cert takes effect on the next connect. */
|
/**
|
||||||
|
* The identity for the next connection. A newly imported or pushed cert takes effect on the
|
||||||
|
* next connect: see [loadFromFiles] for how the cache notices.
|
||||||
|
*/
|
||||||
fun load(context: Context): Identity = loadFromFiles(context) ?: run {
|
fun load(context: Context): Identity = loadFromFiles(context) ?: run {
|
||||||
val generated = SelfSignedCert.generate()
|
val generated = SelfSignedCert.generate()
|
||||||
Identity(
|
Identity(
|
||||||
@@ -65,7 +68,38 @@ object CertProvider {
|
|||||||
*/
|
*/
|
||||||
fun loadSupplied(context: Context): Identity? = loadFromFiles(context)
|
fun loadSupplied(context: Context): Identity? = loadFromFiles(context)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unlocking a PKCS#12 file takes about a second on a Pixel 6, which a head unit waiting for
|
||||||
|
* its first reply may not wait out. So the unlocked identity is kept, keyed on the files it
|
||||||
|
* could come from: importing, downloading, removing or pushing a cert changes the key.
|
||||||
|
*/
|
||||||
|
@Volatile private var cached: Pair<String, Identity>? = null
|
||||||
|
|
||||||
|
private fun sourcesKey(context: Context): String {
|
||||||
|
val dir = context.getExternalFilesDir(null)
|
||||||
|
val files = listOf(
|
||||||
|
importedFile(context, P12_NAME),
|
||||||
|
downloadedFile(context, P12_NAME),
|
||||||
|
dir?.let { File(it, P12_NAME) },
|
||||||
|
dir?.let { File(it, PASS_NAME) },
|
||||||
|
)
|
||||||
|
return files.joinToString("|") { f -> f?.let { "${it.path}:${it.lastModified()}:${it.length()}" } ?: "-" }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unlocks the certificate ahead of time, so the first connection doesn't wait on it. */
|
||||||
|
fun warm(context: Context) {
|
||||||
|
runCatching { loadFromFiles(context) }
|
||||||
|
}
|
||||||
|
|
||||||
private fun loadFromFiles(context: Context): Identity? {
|
private fun loadFromFiles(context: Context): Identity? {
|
||||||
|
val key = sourcesKey(context)
|
||||||
|
cached?.let { (k, identity) -> if (k == key) return identity }
|
||||||
|
val identity = unlockFromFiles(context) ?: return null
|
||||||
|
cached = key to identity
|
||||||
|
return identity
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun unlockFromFiles(context: Context): Identity? {
|
||||||
val imported = importedFile(context, P12_NAME)
|
val imported = importedFile(context, P12_NAME)
|
||||||
if (imported.isFile) {
|
if (imported.isFile) {
|
||||||
runCatching {
|
runCatching {
|
||||||
|
|||||||
@@ -10,6 +10,44 @@ object Changelog {
|
|||||||
class Release(val versionCode: Int, val versionName: String, val changes: List<String>)
|
class Release(val versionCode: Int, val versionName: String, val changes: List<String>)
|
||||||
|
|
||||||
val releases = listOf(
|
val releases = listOf(
|
||||||
|
Release(
|
||||||
|
8, "0.2.00-alpha",
|
||||||
|
listOf(
|
||||||
|
"Exit takes you to the car's own screen. Tap Android Auto on the car to come back.",
|
||||||
|
"Gearslip listens through the car's microphone when the car has one, so voice works with the phone in a pocket.",
|
||||||
|
"A voice reply sends when its bar runs out, even over road noise or the radio. Say \"cancel\" or \"change it\" before then.",
|
||||||
|
"Maps turn dark when the car switches to night mode.",
|
||||||
|
"A moving map no longer makes the car screen fall behind your taps.",
|
||||||
|
"Gearslip asks to send sound to the car as soon as you connect, so videos in the web browser play in the car too.",
|
||||||
|
"Cars that offer more than one kind of music audio, such as the 2022 Dacia Jogger, play sound again.",
|
||||||
|
"When the car shows a keyboard, you can type on the phone instead. The car's keyboard is taller too.",
|
||||||
|
"Every car screen has a back button.",
|
||||||
|
"App icons show whether an app works in Gearslip. Metrolist works and shows the green check.",
|
||||||
|
"LIVI connects again, and touch works on cars with a second screen.",
|
||||||
|
"The phone's Settings screen has a new layout, with What's new and the setup guide at the top.",
|
||||||
|
"You can choose to send short usage notes that help the project. They hold no name, account, or location, and they're off unless you turn them on.",
|
||||||
|
"Searching inside a media app moved to Settings > Experimental features.",
|
||||||
|
"Under Settings > Media audio, the choices are now Phone audio and Car audio.",
|
||||||
|
"Logs leave out your location and the car's serial number.",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Release(
|
||||||
|
7, "0.1.10-alpha",
|
||||||
|
listOf(
|
||||||
|
"The launcher groups your apps: Phone, Maps, Music, Messaging, Web, Screen sharing, and Settings.",
|
||||||
|
"To move an app, press and hold it on the launcher, then tap Move left or Move right.",
|
||||||
|
"MapQuest and Flitsmeister work in Gearslip and show the green check.",
|
||||||
|
"Spotify has one tile. If Spotify isn't open on the phone, tap Open Spotify to start it.",
|
||||||
|
"Flick a map and it glides on, as it does in Android Auto.",
|
||||||
|
"The map picks up where it left off when you come back from another screen.",
|
||||||
|
"Search boxes in map apps are larger and easier to read.",
|
||||||
|
"Map apps keep the right icons on their buttons after they update.",
|
||||||
|
"When Gearslip asks \"Do you want to reply?\", a bar shows how long it will listen. The bar stops while you speak.",
|
||||||
|
"Gearslip fits its picture to wide car screens and keeps it clear of the edges the car crops.",
|
||||||
|
"Gearslip answers the car's connection checks correctly. Some cars, Audis among them, hung up after a second without this.",
|
||||||
|
"Google Maps no longer shows up as a messaging app.",
|
||||||
|
),
|
||||||
|
),
|
||||||
Release(
|
Release(
|
||||||
6, "0.1.0-alpha",
|
6, "0.1.0-alpha",
|
||||||
listOf(
|
listOf(
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import app.seb3thehacker.gearslip.car.CarEnvironment
|
|||||||
import app.seb3thehacker.gearslip.car.CarSettings
|
import app.seb3thehacker.gearslip.car.CarSettings
|
||||||
import app.seb3thehacker.gearslip.car.CarUi
|
import app.seb3thehacker.gearslip.car.CarUi
|
||||||
import app.seb3thehacker.gearslip.car.Prefetch
|
import app.seb3thehacker.gearslip.car.Prefetch
|
||||||
|
import app.seb3thehacker.gearslip.stats.UsageStats
|
||||||
import app.seb3thehacker.gearslip.ui.GearslipApp
|
import app.seb3thehacker.gearslip.ui.GearslipApp
|
||||||
import app.seb3thehacker.gearslip.ui.GearslipTheme
|
import app.seb3thehacker.gearslip.ui.GearslipTheme
|
||||||
import java.io.FileInputStream
|
import java.io.FileInputStream
|
||||||
@@ -40,6 +41,8 @@ import java.io.FileOutputStream
|
|||||||
* rotation or theme change from recreating it and dropping the connection.
|
* rotation or theme change from recreating it and dropping the connection.
|
||||||
*/
|
*/
|
||||||
class GearslipActivity : ComponentActivity(), Projection {
|
class GearslipActivity : ComponentActivity(), Projection {
|
||||||
|
private val mainHandler = android.os.Handler(android.os.Looper.getMainLooper())
|
||||||
|
|
||||||
|
|
||||||
private var descriptor: ParcelFileDescriptor? = null
|
private var descriptor: ParcelFileDescriptor? = null
|
||||||
private var runner: GearslipRunner? = null
|
private var runner: GearslipRunner? = null
|
||||||
@@ -148,6 +151,7 @@ class GearslipActivity : ComponentActivity(), Projection {
|
|||||||
override fun onStart() {
|
override fun onStart() {
|
||||||
super.onStart()
|
super.onStart()
|
||||||
Prefetch.warm(this)
|
Prefetch.warm(this)
|
||||||
|
UsageStats.onAppOpened(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onNewIntent(intent: Intent) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
@@ -323,7 +327,9 @@ class GearslipActivity : ComponentActivity(), Projection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onTouch(action: Int, actionIndex: Int, points: List<TouchPoint>) {
|
override fun onTouch(action: Int, actionIndex: Int, points: List<TouchPoint>) {
|
||||||
runOnUiThread {
|
// To the front of the main thread's queue: a touch waiting behind other work is lag the
|
||||||
|
// driver feels, while the work it jumps ahead of can wait a frame.
|
||||||
|
mainHandler.postAtFrontOfQueue {
|
||||||
// Mirroring drives one phone touch at a time, so it follows the first finger only.
|
// Mirroring drives one phone touch at a time, so it follows the first finger only.
|
||||||
if (PhoneMirror.active.value) points.firstOrNull()?.let { relayTouch(action, it.x, it.y) }
|
if (PhoneMirror.active.value) points.firstOrNull()?.let { relayTouch(action, it.x, it.y) }
|
||||||
else screenProjector?.dispatchTouch(action, actionIndex, points)
|
else screenProjector?.dispatchTouch(action, actionIndex, points)
|
||||||
|
|||||||
@@ -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.CarMic
|
||||||
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
|
||||||
@@ -10,6 +11,7 @@ import app.seb3thehacker.gearslip.car.CarServices
|
|||||||
import app.seb3thehacker.gearslip.car.CarSettings
|
import app.seb3thehacker.gearslip.car.CarSettings
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.io.OutputStream
|
import java.io.OutputStream
|
||||||
|
import java.util.concurrent.FutureTask
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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:
|
||||||
@@ -38,7 +40,13 @@ class GearslipRunner(
|
|||||||
|
|
||||||
private val parser = Frames.Parser()
|
private val parser = Frames.Parser()
|
||||||
private val assembler = Frames.Assembler()
|
private val assembler = Frames.Assembler()
|
||||||
private lateinit var tls: PhoneTls
|
private lateinit var identityTask: FutureTask<PhoneTls>
|
||||||
|
|
||||||
|
/** Sends every message, in order, so no other thread ever blocks on the USB link. See [send]. */
|
||||||
|
private val writer = java.util.concurrent.Executors.newSingleThreadExecutor { Thread(it, "gearslip-writer") }
|
||||||
|
|
||||||
|
/** Waits for the certificate only when TLS is first needed; see [run]. */
|
||||||
|
private val tls: PhoneTls by lazy { identityTask.get() }
|
||||||
|
|
||||||
@Volatile private var state = State.WAIT_VERSION
|
@Volatile private var state = State.WAIT_VERSION
|
||||||
@Volatile private var lastProgress = System.currentTimeMillis()
|
@Volatile private var lastProgress = System.currentTimeMillis()
|
||||||
@@ -51,9 +59,12 @@ class GearslipRunner(
|
|||||||
@Volatile private var videoStarted = false
|
@Volatile private var videoStarted = false
|
||||||
private var videoSource: VideoSource? = null
|
private var videoSource: VideoSource? = 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. */
|
||||||
|
private val ackLock = Object()
|
||||||
|
/** Time the encoder spent waiting for acks since the last stats line. */
|
||||||
|
@Volatile private var ackWaitMs = 0L
|
||||||
@Volatile private var maxUnacked = 4
|
@Volatile private var maxUnacked = 4
|
||||||
@Volatile private var framesSent = 0
|
@Volatile private var framesSent = 0
|
||||||
@Volatile private var dropped = 0
|
|
||||||
@Volatile private var inputChannelId: Int = -1
|
@Volatile private var inputChannelId: Int = -1
|
||||||
@Volatile private var inputKeycodes: List<Int> = emptyList()
|
@Volatile private var inputKeycodes: List<Int> = emptyList()
|
||||||
@Volatile private var sensorChannelId: Int = -1
|
@Volatile private var sensorChannelId: Int = -1
|
||||||
@@ -76,6 +87,10 @@ class GearslipRunner(
|
|||||||
SessionStatus.connecting("Connected to the head unit")
|
SessionStatus.connecting("Connected to the head unit")
|
||||||
log.i("device=${Build.MANUFACTURER} ${Build.MODEL} android=${Build.VERSION.RELEASE} (sdk ${Build.VERSION.SDK_INT})")
|
log.i("device=${Build.MANUFACTURER} ${Build.MODEL} android=${Build.VERSION.RELEASE} (sdk ${Build.VERSION.SDK_INT})")
|
||||||
|
|
||||||
|
// The head unit speaks first, and some give up if the phone doesn't read within about a
|
||||||
|
// second. The version exchange needs no certificate, so read at once and unlock the
|
||||||
|
// certificate alongside; the TLS handshake waits for it.
|
||||||
|
identityTask = FutureTask {
|
||||||
val identity = identityProvider()
|
val identity = identityProvider()
|
||||||
val cert = identity.certificate
|
val cert = identity.certificate
|
||||||
log.i("phone certificate source: ${identity.source}")
|
log.i("phone certificate source: ${identity.source}")
|
||||||
@@ -83,7 +98,8 @@ class GearslipRunner(
|
|||||||
log.i(" subject = ${cert.subjectX500Principal}")
|
log.i(" subject = ${cert.subjectX500Principal}")
|
||||||
log.i(" issuer = ${cert.issuerX500Principal}")
|
log.i(" issuer = ${cert.issuerX500Principal}")
|
||||||
log.i(" serial = ${cert.serialNumber} valid ${cert.notBefore}..${cert.notAfter}")
|
log.i(" serial = ${cert.serialNumber} valid ${cert.notBefore}..${cert.notAfter}")
|
||||||
tls = PhoneTls(identity.keyStore, identity.password)
|
PhoneTls(identity.keyStore, identity.password)
|
||||||
|
}.also { Thread(it, "gearslip-identity").start() }
|
||||||
|
|
||||||
startWatchdog()
|
startWatchdog()
|
||||||
|
|
||||||
@@ -115,11 +131,18 @@ class GearslipRunner(
|
|||||||
|
|
||||||
fun stop() {
|
fun stop() {
|
||||||
running = false
|
running = false
|
||||||
|
writer.shutdownNow()
|
||||||
|
CarFocus.request = null
|
||||||
|
CarFocus.set(true)
|
||||||
|
synchronized(ackLock) { ackLock.notifyAll() } // free an encoder waiting on an ack
|
||||||
videoSource?.stop()
|
videoSource?.stop()
|
||||||
videoSource = null
|
videoSource = null
|
||||||
audioLink?.close()
|
audioLink?.close()
|
||||||
audioLink = null
|
audioLink = null
|
||||||
CarSensors.clear()
|
CarSensors.clear()
|
||||||
|
CarMic.current = null
|
||||||
|
lastSensorNight = null
|
||||||
|
CarEnvironment.setSensorNight(null) // back to the clock until a car says otherwise
|
||||||
projection?.onProjectionStopped()
|
projection?.onProjectionStopped()
|
||||||
SessionStatus.disconnected()
|
SessionStatus.disconnected()
|
||||||
}
|
}
|
||||||
@@ -153,6 +176,8 @@ class GearslipRunner(
|
|||||||
onInputMessage(messageId, body)
|
onInputMessage(messageId, body)
|
||||||
} else if (frame.channel == sensorChannelId) {
|
} else if (frame.channel == sensorChannelId) {
|
||||||
onSensorMessage(messageId, body)
|
onSensorMessage(messageId, body)
|
||||||
|
} else if (frame.channel == CarMic.current?.channelId) {
|
||||||
|
CarMic.current?.onMessage(messageId, body)
|
||||||
} else if (frame.channel == audioLink?.channelId) {
|
} else if (frame.channel == audioLink?.channelId) {
|
||||||
// The audio channel is the one still being reverse-engineered, and its first few
|
// The audio channel is the one still being reverse-engineered, and its first few
|
||||||
// replies are what tell us why. Raw bytes for those, then quiet.
|
// replies are what tell us why. Raw bytes for those, then quiet.
|
||||||
@@ -327,7 +352,12 @@ class GearslipRunner(
|
|||||||
|
|
||||||
private fun onServiceDiscoveryResponse(body: ByteArray) {
|
private fun onServiceDiscoveryResponse(body: ByteArray) {
|
||||||
log.i("<- ServiceDiscoveryResponse (${body.size} bytes, decrypted successfully)")
|
log.i("<- ServiceDiscoveryResponse (${body.size} bytes, decrypted successfully)")
|
||||||
log.i("head unit describes itself as:\n" + Protobuf.describe(body))
|
// 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.
|
||||||
|
val described = Protobuf.describe(body)
|
||||||
|
.replace(Regex("(?m)^ #5 string = .*$"), " #5 string = (car serial, left out)")
|
||||||
|
.replace(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}"), "(Bluetooth address, left out)")
|
||||||
|
log.i("head unit describes itself as:\n" + described)
|
||||||
log.verdict(
|
log.verdict(
|
||||||
"VIABLE",
|
"VIABLE",
|
||||||
"The head unit accepted the presented phone certificate, completed TLS, and is " +
|
"The head unit accepted the presented phone certificate, completed TLS, and is " +
|
||||||
@@ -338,6 +368,7 @@ class GearslipRunner(
|
|||||||
val info = ServiceDiscovery.findHeadUnitInfo(body)
|
val info = ServiceDiscovery.findHeadUnitInfo(body)
|
||||||
log.i("head unit: $info")
|
log.i("head unit: $info")
|
||||||
SessionReport.headUnit(info.toString())
|
SessionReport.headUnit(info.toString())
|
||||||
|
SessionReport.headUnitInfo = info
|
||||||
vehicleProfile = runCatching { vehicleProfileFor(info) }.getOrNull()
|
vehicleProfile = runCatching { vehicleProfileFor(info) }.getOrNull()
|
||||||
val profile = vehicleProfile
|
val profile = vehicleProfile
|
||||||
SessionReport.profile(profile?.name)
|
SessionReport.profile(profile?.name)
|
||||||
@@ -374,7 +405,7 @@ class GearslipRunner(
|
|||||||
videoChannelId = video.serviceId
|
videoChannelId = video.serviceId
|
||||||
videoConfigs = video.configs
|
videoConfigs = video.configs
|
||||||
|
|
||||||
log.i("video service: channel=${video.serviceId} codec=${video.codecName}")
|
log.i("video service: channel=${video.serviceId} codec=${video.codecName} display=${video.displayId}")
|
||||||
video.configs.forEachIndexed { index, config ->
|
video.configs.forEachIndexed { index, config ->
|
||||||
log.i(" config[$index] = $config")
|
log.i(" config[$index] = $config")
|
||||||
}
|
}
|
||||||
@@ -385,8 +416,9 @@ class GearslipRunner(
|
|||||||
openChannel(video.serviceId)
|
openChannel(video.serviceId)
|
||||||
startAudioChannel(serviceDiscoveryResponse)
|
startAudioChannel(serviceDiscoveryResponse)
|
||||||
startSensorChannel(serviceDiscoveryResponse)
|
startSensorChannel(serviceDiscoveryResponse)
|
||||||
|
startMicChannel(serviceDiscoveryResponse)
|
||||||
|
|
||||||
val input = ServiceDiscovery.findInputService(serviceDiscoveryResponse)
|
val input = ServiceDiscovery.findInputService(serviceDiscoveryResponse, video.displayId)
|
||||||
if (input == null) {
|
if (input == null) {
|
||||||
log.w("no input service advertised - projection will be output-only")
|
log.w("no input service advertised - projection will be output-only")
|
||||||
} else {
|
} else {
|
||||||
@@ -411,13 +443,16 @@ class GearslipRunner(
|
|||||||
sink.configs.joinToString(prefix = "[", postfix = "]") { "${it.sampleRate}Hz/${it.bits}bit/x${it.channels}" },
|
sink.configs.joinToString(prefix = "[", postfix = "]") { "${it.sampleRate}Hz/${it.bits}bit/x${it.channels}" },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val media = sinks.firstOrNull { it.streamType == STREAM_MEDIA } ?: run {
|
val media = ServiceDiscovery.pickMediaSink(sinks) ?: run {
|
||||||
log.w("no media audio sink advertised - media apps will play on the phone only")
|
log.w("no media audio sink advertised - media apps will play on the phone only")
|
||||||
SessionReport.audio(
|
SessionReport.audio(
|
||||||
"no MEDIA sink; head unit offered ${sinks.joinToString { it.streamName }.ifEmpty { "none" }}",
|
"no MEDIA sink; head unit offered ${sinks.joinToString { it.streamName }.ifEmpty { "none" }}",
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (media.codecType != ServiceDiscovery.CODEC_PCM) {
|
||||||
|
log.w("the car offers no PCM media sink, only ${media.codecName}; it may play our PCM as silence")
|
||||||
|
}
|
||||||
val first = media.configs.firstOrNull()
|
val first = media.configs.firstOrNull()
|
||||||
SessionReport.audio(
|
SessionReport.audio(
|
||||||
"MEDIA sink on channel ${media.serviceId}, ${media.codecName}, " +
|
"MEDIA sink on channel ${media.serviceId}, ${media.codecName}, " +
|
||||||
@@ -431,6 +466,18 @@ class GearslipRunner(
|
|||||||
openChannel(media.serviceId)
|
openChannel(media.serviceId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The car's microphone, for voice: opened now, recording only while something listens. */
|
||||||
|
private fun startMicChannel(serviceDiscoveryResponse: ByteArray) {
|
||||||
|
val mic = ServiceDiscovery.findMicService(serviceDiscoveryResponse) ?: run {
|
||||||
|
log.i("no microphone advertised - voice uses the phone's")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val config = mic.configs.first()
|
||||||
|
log.i("car microphone: channel=${mic.serviceId} ${mic.codecName} ${config.sampleRate}Hz/${config.bits}bit/x${config.channels}")
|
||||||
|
CarMic.current = CarMic(mic) { id, body -> send(id, body, encrypted = true, channel = mic.serviceId) }
|
||||||
|
openChannel(mic.serviceId)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every sensor type the head unit advertised, subscribed to at once - there is no screen
|
* Every sensor type the head unit advertised, subscribed to at once - there is no screen
|
||||||
* to pick and choose yet, this is meant to show everything reachable. [CarSensors] answers
|
* to pick and choose yet, this is meant to show everything reachable. [CarSensors] answers
|
||||||
@@ -451,6 +498,48 @@ class GearslipRunner(
|
|||||||
openChannel(sensors.serviceId)
|
openChannel(sensors.serviceId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The car may stream sensors many times a second, on the same thread that reads its audio and
|
||||||
|
* video messages. Log each type's first reading in full, then only a count every
|
||||||
|
* [SENSOR_LOG_EVERY_MS], so the log can never hold that thread up.
|
||||||
|
*/
|
||||||
|
private val sensorTypesLogged = HashSet<Int>()
|
||||||
|
private var sensorEventsSinceLog = 0
|
||||||
|
private var sensorLoggedAt = 0L
|
||||||
|
|
||||||
|
private fun logSensorEvent(body: ByteArray) {
|
||||||
|
val types = Wire.fields(body).map { it.number }.toSet()
|
||||||
|
if (sensorTypesLogged.addAll(types)) {
|
||||||
|
// Type 1 is the car's GPS position; logs get shared, so say it arrived and nothing more.
|
||||||
|
if (SENSOR_LOCATION in types) log.i("<- SensorEvent (first of its kind): types=$types, location left out")
|
||||||
|
else log.i("<- SensorEvent (first of its kind):\n" + Protobuf.describe(body))
|
||||||
|
}
|
||||||
|
sensorEventsSinceLog++
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
if (sensorLoggedAt == 0L) sensorLoggedAt = now
|
||||||
|
if (now - sensorLoggedAt >= SENSOR_LOG_EVERY_MS) {
|
||||||
|
log.i("<- $sensorEventsSinceLog sensor events in the last ${(now - sensorLoggedAt) / 1000}s")
|
||||||
|
sensorEventsSinceLog = 0
|
||||||
|
sensorLoggedAt = now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The car's own day/night signal (its headlights or light sensor): NightData { bool is_night = 1; }.
|
||||||
|
* It replaces the clock guess, so maps go dark when the car does.
|
||||||
|
*/
|
||||||
|
private fun onNightSensor(body: ByteArray) {
|
||||||
|
val night = Wire.bytes(Wire.fields(body), SENSOR_NIGHT) ?: return
|
||||||
|
val isNight = Wire.varint(Wire.fields(night), 1) == 1L
|
||||||
|
if (isNight != lastSensorNight) {
|
||||||
|
lastSensorNight = isNight
|
||||||
|
log.i("car says it's ${if (isNight) "night" else "day"}")
|
||||||
|
CarEnvironment.setSensorNight(isNight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var lastSensorNight: Boolean? = null
|
||||||
|
|
||||||
private fun onSensorMessage(messageId: Int, body: ByteArray) {
|
private fun onSensorMessage(messageId: Int, body: ByteArray) {
|
||||||
when (messageId) {
|
when (messageId) {
|
||||||
MSG_CHANNEL_OPEN_RESPONSE -> {
|
MSG_CHANNEL_OPEN_RESPONSE -> {
|
||||||
@@ -460,10 +549,12 @@ class GearslipRunner(
|
|||||||
log.w("head unit refused the sensor channel (status=$status)")
|
log.w("head unit refused the sensor channel (status=$status)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// SensorRequest { required int32 sensor_type = 1; }
|
// SensorRequest { required SensorType type = 1; required int64 min_update_period = 2; }
|
||||||
|
// A period of 0 asks for every update the car sends.
|
||||||
sensorTypes.forEach { type ->
|
sensorTypes.forEach { type ->
|
||||||
send(
|
send(
|
||||||
MSG_SENSOR_START_REQUEST, Protobuf.varintField(1, type.toLong()),
|
MSG_SENSOR_START_REQUEST,
|
||||||
|
Protobuf.varintField(1, type.toLong()) + Protobuf.varintField(2, 0L),
|
||||||
encrypted = true, channel = sensorChannelId,
|
encrypted = true, channel = sensorChannelId,
|
||||||
)
|
)
|
||||||
log.i("-> SensorStartRequest(sensor_type=$type)")
|
log.i("-> SensorStartRequest(sensor_type=$type)")
|
||||||
@@ -475,9 +566,15 @@ class GearslipRunner(
|
|||||||
|
|
||||||
MSG_SENSOR_EVENT_INDICATION -> {
|
MSG_SENSOR_EVENT_INDICATION -> {
|
||||||
CarSensors.onEvent(body)
|
CarSensors.onEvent(body)
|
||||||
log.i("<- SensorEvent:\n" + Protobuf.describe(body))
|
logSensorEvent(body)
|
||||||
|
onNightSensor(body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SensorError { required SensorType sensor_type = 1; required SensorErrorType sensor_error_type = 2; }
|
||||||
|
MSG_SENSOR_ERROR -> log.w(
|
||||||
|
"<- SensorError: type=${Protobuf.readInt32Field(body, 1)} error=${Protobuf.readInt32Field(body, 2)}",
|
||||||
|
)
|
||||||
|
|
||||||
else -> log.i("<- unhandled sensor message id=$messageId (${body.size} bytes)")
|
else -> log.i("<- unhandled sensor message id=$messageId (${body.size} bytes)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -518,6 +615,12 @@ class GearslipRunner(
|
|||||||
|
|
||||||
MSG_INPUT_REPORT -> {
|
MSG_INPUT_REPORT -> {
|
||||||
val report = Wire.fields(body)
|
val report = Wire.fields(body)
|
||||||
|
// The 2018 Uconnect agrees to Exit but keeps showing our last frame and sending
|
||||||
|
// touches, which looked like a crash. A touch means our screen is still up there.
|
||||||
|
if (videoStarted && !videoProjected && Wire.bytes(report, 3) != null) {
|
||||||
|
log.w("the car said it took its screen back but still sends touches - showing ours again")
|
||||||
|
onVideoFocus(VIDEO_FOCUS_PROJECTED)
|
||||||
|
}
|
||||||
Wire.bytes(report, 4)?.let(::onKeyEvent)
|
Wire.bytes(report, 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)
|
val touch = Wire.bytes(report, 3) ?: Wire.bytes(report, 7)
|
||||||
@@ -547,6 +650,8 @@ class GearslipRunner(
|
|||||||
val at = points[actionIndex]
|
val at = points[actionIndex]
|
||||||
log.i("<- Touch action=$action at (${at.x.toInt()},${at.y.toInt()}) pointers=${points.size}")
|
log.i("<- Touch action=$action at (${at.x.toInt()},${at.y.toInt()}) pointers=${points.size}")
|
||||||
}
|
}
|
||||||
|
// Time the next frame against this tap: see [reportTouchLatency].
|
||||||
|
if (action == ACTION_DOWN || action == ACTION_UP) touchAtUs = System.nanoTime() / 1000
|
||||||
projection?.onTouch(action, actionIndex, points)
|
projection?.onTouch(action, actionIndex, points)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -631,15 +736,11 @@ class GearslipRunner(
|
|||||||
"configuration_indices=$indices",
|
"configuration_indices=$indices",
|
||||||
)
|
)
|
||||||
if (maxUnackedFromConfig != null) maxUnacked = maxOf(1, maxUnackedFromConfig)
|
if (maxUnackedFromConfig != null) maxUnacked = maxOf(1, maxUnackedFromConfig)
|
||||||
// Prefer the smallest offered resolution for the first attempt: fewer pixels,
|
|
||||||
// fewer ways for a first encode to go wrong.
|
|
||||||
val offered = indices.map { it.toInt() }
|
val offered = indices.map { it.toInt() }
|
||||||
val preferred = vehicleProfile?.resolution?.let { want ->
|
val preferred = vehicleProfile?.resolution?.let { want ->
|
||||||
offered.firstOrNull { videoConfigs.getOrNull(it)?.resolutionName == want }
|
offered.firstOrNull { videoConfigs.getOrNull(it)?.resolutionName == want }
|
||||||
}
|
}
|
||||||
selectedConfigIndex = preferred ?: offered.minByOrNull { idx ->
|
selectedConfigIndex = preferred ?: fitToScreen(offered) ?: offered.firstOrNull()
|
||||||
videoConfigs.getOrNull(idx)?.pixelCount() ?: Int.MAX_VALUE
|
|
||||||
} ?: offered.firstOrNull()
|
|
||||||
log.i("selected config index $selectedConfigIndex from offered $offered")
|
log.i("selected config index $selectedConfigIndex from offered $offered")
|
||||||
SessionReport.video(
|
SessionReport.video(
|
||||||
"${videoConfigs.getOrNull(selectedConfigIndex ?: -1)?.resolutionName ?: "?"} " +
|
"${videoConfigs.getOrNull(selectedConfigIndex ?: -1)?.resolutionName ?: "?"} " +
|
||||||
@@ -653,22 +754,28 @@ class GearslipRunner(
|
|||||||
}
|
}
|
||||||
|
|
||||||
MSG_VIDEO_FOCUS_NOTIFICATION -> {
|
MSG_VIDEO_FOCUS_NOTIFICATION -> {
|
||||||
|
// VideoFocusNotification { focus = 1; unsolicited = 2 }
|
||||||
val fields = Wire.fields(body)
|
val fields = Wire.fields(body)
|
||||||
log.i("<- VideoFocusNotification: mode=${Wire.varint(fields, 1)}")
|
val mode = Wire.varint(fields, 1)?.toInt() ?: VIDEO_FOCUS_PROJECTED
|
||||||
// The head unit repeats this while it waits for frames; only act once.
|
log.i("<- VideoFocusNotification: mode=$mode unsolicited=${Wire.varint(fields, 2)}")
|
||||||
if (!videoStarted) {
|
onVideoFocus(mode)
|
||||||
videoStarted = true
|
|
||||||
log.i("PHASE A COMPLETE - video focus granted; starting video source")
|
|
||||||
startVideoSource()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Not in aasdk's direction of travel, but LIVI sends it: log what it asks for.
|
||||||
|
MSG_VIDEO_FOCUS_REQUEST -> {
|
||||||
|
val fields = Wire.fields(body)
|
||||||
|
log.i("<- VideoFocusRequest from the head unit: mode=${Wire.varint(fields, 2)} reason=${Wire.varint(fields, 3)}")
|
||||||
}
|
}
|
||||||
|
|
||||||
MSG_MEDIA_ACK -> {
|
MSG_MEDIA_ACK -> {
|
||||||
val fields = Wire.fields(body)
|
val fields = Wire.fields(body)
|
||||||
val acked = Wire.varint(fields, 2)?.toInt() ?: 1
|
val acked = Wire.varint(fields, 2)?.toInt() ?: 1
|
||||||
|
synchronized(ackLock) {
|
||||||
inFlight = maxOf(0, inFlight - maxOf(1, acked))
|
inFlight = maxOf(0, inFlight - maxOf(1, acked))
|
||||||
acksSeen++
|
acksSeen++
|
||||||
lastAckAt = System.currentTimeMillis()
|
lastAckAt = System.currentTimeMillis()
|
||||||
|
ackLock.notifyAll()
|
||||||
|
}
|
||||||
if (framesSent <= 3 || framesSent % 60 == 0) {
|
if (framesSent <= 3 || framesSent % 60 == 0) {
|
||||||
log.i("<- MediaAck(ack=$acked) inFlight=$inFlight after $framesSent frames")
|
log.i("<- MediaAck(ack=$acked) inFlight=$inFlight after $framesSent frames")
|
||||||
}
|
}
|
||||||
@@ -685,6 +792,20 @@ class GearslipRunner(
|
|||||||
// -> Data (8-byte big-endian timestamp, then the H.264 access unit)
|
// -> Data (8-byte big-endian timestamp, then the H.264 access unit)
|
||||||
// <- Ack { session_id, ack } - at most max_unacked frames may be outstanding
|
// <- Ack { session_id, ack } - at most max_unacked frames may be outstanding
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The offered config that best fills the car's screen: the smallest whose visible width
|
||||||
|
* reaches most of the touchscreen's, else the largest there is. Always taking the smallest
|
||||||
|
* put 800x480 on a 1540x720 Audi screen and stretched it. With no touchscreen size to go
|
||||||
|
* by, the smallest is still the safe choice.
|
||||||
|
*/
|
||||||
|
private fun fitToScreen(offered: List<Int>): Int? {
|
||||||
|
val known = offered.filter { videoConfigs.getOrNull(it)?.pixelSize() != null }
|
||||||
|
fun pixels(idx: Int) = videoConfigs[idx].pixelCount()
|
||||||
|
if (touchWidth <= 0) return known.minByOrNull(::pixels)
|
||||||
|
val wide = known.filter { (videoConfigs[it].visibleSize()?.first ?: 0) >= touchWidth * 8 / 10 }
|
||||||
|
return wide.minByOrNull(::pixels) ?: known.maxByOrNull(::pixels)
|
||||||
|
}
|
||||||
|
|
||||||
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
|
||||||
@@ -697,11 +818,19 @@ class GearslipRunner(
|
|||||||
log.e("unsupported resolution ${config.resolutionName}")
|
log.e("unsupported resolution ${config.resolutionName}")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
config.visibleSize()?.let { (w, h) -> SessionReport.carScreen("${w}x$h", config.density) }
|
||||||
|
|
||||||
val start = Protobuf.varintField(1, SESSION_ID.toLong()) +
|
val start = Protobuf.varintField(1, SESSION_ID.toLong()) +
|
||||||
Protobuf.varintField(2, index.toLong())
|
Protobuf.varintField(2, index.toLong())
|
||||||
send(MSG_MEDIA_START, start, encrypted = true, channel = videoChannelId)
|
send(MSG_MEDIA_START, start, encrypted = true, channel = videoChannelId)
|
||||||
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
|
||||||
|
// profile has its own tuned insets.
|
||||||
|
if (vehicleProfile?.insets.let { it == null || it == Insets.NONE }) {
|
||||||
|
val insets = config.marginInsets()
|
||||||
|
CarEnvironment.setInsets(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
|
||||||
videoSource = VideoSource(
|
videoSource = VideoSource(
|
||||||
@@ -710,6 +839,7 @@ class GearslipRunner(
|
|||||||
frameRate = if (config.frameRate == 1) 60 else 30,
|
frameRate = if (config.frameRate == 1) 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,
|
||||||
onCodecConfig = { csd ->
|
onCodecConfig = { csd ->
|
||||||
|
lastCodecConfig = csd
|
||||||
send(MSG_MEDIA_CODEC_CONFIG, csd, encrypted = true, channel = videoChannelId)
|
send(MSG_MEDIA_CODEC_CONFIG, csd, encrypted = true, channel = videoChannelId)
|
||||||
log.i("-> CodecConfig (${csd.size} bytes SPS/PPS)")
|
log.i("-> CodecConfig (${csd.size} bytes SPS/PPS)")
|
||||||
log.hex(" csd", csd, limit = 64)
|
log.hex(" csd", csd, limit = 64)
|
||||||
@@ -729,39 +859,34 @@ class GearslipRunner(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flow control: the head unit allows max_unacked frames in flight (LIVI allows one). When
|
||||||
|
* the window is full this waits for an ack instead of dropping the frame. A dropped frame
|
||||||
|
* breaks the decoder's chain, so it cost a keyframe to repair; keyframes are twenty times
|
||||||
|
* the size, so they caused more drops, and the stream sank into a storm of them (345 in a
|
||||||
|
* minute on LIVI, at 5-12 fps). Waiting here holds the encoder back instead, which simply
|
||||||
|
* makes it produce fewer frames, each one whole.
|
||||||
|
*/
|
||||||
private fun sendVideoFrame(data: ByteArray, presentationTimeUs: Long, keyFrame: Boolean) {
|
private fun sendVideoFrame(data: ByteArray, presentationTimeUs: Long, keyFrame: Boolean) {
|
||||||
// Flow control: the head unit told us max_unacked in its Config reply.
|
// The car is showing its own screen and would throw these away.
|
||||||
if (inFlight >= maxUnacked) {
|
if (!videoProjected) return
|
||||||
// A dropped or never-sent ack would otherwise wedge the stream forever, which
|
synchronized(ackLock) {
|
||||||
// looks identical to a frozen picture. Assume the window cleared and carry on.
|
val waitStart = System.currentTimeMillis()
|
||||||
|
while (running && inFlight >= maxUnacked) {
|
||||||
|
// A lost ack would otherwise hold the stream forever, which looks like a frozen
|
||||||
|
// picture. After a long silence, assume the window cleared and carry on.
|
||||||
val since = System.currentTimeMillis() - lastAckAt
|
val since = System.currentTimeMillis() - lastAckAt
|
||||||
if (since > ACK_STALL_MS) {
|
if (since > ACK_STALL_MS) {
|
||||||
log.w("no ack for ${since}ms with $inFlight in flight - resetting the window")
|
log.w("no ack for ${since}ms with $inFlight in flight - resetting the window")
|
||||||
inFlight = 0
|
inFlight = 0
|
||||||
} else {
|
lastAckAt = System.currentTimeMillis() // one warning per stall, not one per frame
|
||||||
dropped++
|
break
|
||||||
if (dropped % 30 == 1) {
|
|
||||||
log.w("dropping frame - $inFlight in flight (max $maxUnacked)")
|
|
||||||
}
|
}
|
||||||
// A dropped frame breaks the decoder's reference chain on the head unit -
|
ackLock.wait(ACK_STALL_MS - since + 1)
|
||||||
// every frame after it is a P-frame assuming a picture that never arrived.
|
|
||||||
// Without a resync the picture free-runs on corrupted state indefinitely,
|
|
||||||
// which is the smeared/ghosted look.
|
|
||||||
//
|
|
||||||
// Ask on EVERY drop, not just the first in a streak: the resync keyframe we
|
|
||||||
// asked for is itself just another frame, and can be dropped by this exact
|
|
||||||
// same check. A one-shot request that gets swallowed by the burst that
|
|
||||||
// provoked it leaves the stream corrupted for good, with no further request
|
|
||||||
// ever made - confirmed on the road (docs/spike/phase-c-stuck-resync-2026-09-17.log,
|
|
||||||
// dropped=27 in one burst, video dead at 0fps for 100+s afterward). Repeated
|
|
||||||
// setParameters(REQUEST_SYNC_FRAME) calls before one lands are harmless - it
|
|
||||||
// just keeps the "next frame is a keyframe" flag set.
|
|
||||||
resyncRequested = true
|
|
||||||
resyncAttempts++
|
|
||||||
videoSource?.requestSyncFrame()
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
ackWaitMs += System.currentTimeMillis() - waitStart
|
||||||
}
|
}
|
||||||
|
if (!running) return
|
||||||
val timestamp = ByteArray(8)
|
val timestamp = ByteArray(8)
|
||||||
for (i in 0 until 8) {
|
for (i in 0 until 8) {
|
||||||
timestamp[i] = ((presentationTimeUs shr ((7 - i) * 8)) and 0xFF).toByte()
|
timestamp[i] = ((presentationTimeUs shr ((7 - i) * 8)) and 0xFF).toByte()
|
||||||
@@ -774,15 +899,11 @@ class GearslipRunner(
|
|||||||
resyncAttempts = 0
|
resyncAttempts = 0
|
||||||
lastKeyframeSentAt = System.currentTimeMillis()
|
lastKeyframeSentAt = System.currentTimeMillis()
|
||||||
}
|
}
|
||||||
try {
|
// Counted before it's queued: the writer may send it and the ack come back before this
|
||||||
|
// thread runs again, and an ack counted ahead of its frame would leave the window full.
|
||||||
|
synchronized(ackLock) { inFlight++ }
|
||||||
send(MSG_MEDIA_DATA, timestamp + data, encrypted = true, channel = videoChannelId)
|
send(MSG_MEDIA_DATA, timestamp + data, encrypted = true, channel = videoChannelId)
|
||||||
} catch (e: java.io.IOException) {
|
reportTouchLatency(presentationTimeUs)
|
||||||
if (running) log.w("video send failed, stopping the encoder: ${e.message}")
|
|
||||||
running = false
|
|
||||||
videoSource?.stop()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
inFlight++
|
|
||||||
framesSent++
|
framesSent++
|
||||||
if (framesSent <= 3 || keyFrame && framesSent % 30 == 0) {
|
if (framesSent <= 3 || keyFrame && framesSent % 30 == 0) {
|
||||||
log.i(
|
log.i(
|
||||||
@@ -792,6 +913,84 @@ class GearslipRunner(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether the car is showing Gearslip; false while it shows its own interface. */
|
||||||
|
@Volatile private var videoProjected = true
|
||||||
|
/** The encoder's SPS/PPS, resent when the car hands its screen back. */
|
||||||
|
@Volatile private var lastCodecConfig: ByteArray? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The car says who has its screen. The first grant starts the video. After that the car may
|
||||||
|
* take its screen back (our Exit, or its own buttons) and later return it: while it has the
|
||||||
|
* screen nothing is sent, and on return the stream restarts from the codec config and a fresh
|
||||||
|
* keyframe, since the car's decoder may have been reset in between.
|
||||||
|
*/
|
||||||
|
private fun onVideoFocus(mode: Int) {
|
||||||
|
val projected = mode == VIDEO_FOCUS_PROJECTED || mode == VIDEO_FOCUS_PROJECTED_NO_INPUT
|
||||||
|
if (!videoStarted) {
|
||||||
|
// The head unit repeats this while it waits for frames; only start once.
|
||||||
|
if (!projected) return
|
||||||
|
videoStarted = true
|
||||||
|
CarFocus.request = ::requestNativeFocus
|
||||||
|
log.i("PHASE A COMPLETE - video focus granted; starting video source")
|
||||||
|
startVideoSource()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (projected == videoProjected) return
|
||||||
|
videoProjected = projected
|
||||||
|
CarFocus.set(projected)
|
||||||
|
if (projected) {
|
||||||
|
log.i("the car handed its screen back - restarting the stream")
|
||||||
|
// Frames sent just before the car took over may never be acked.
|
||||||
|
synchronized(ackLock) {
|
||||||
|
inFlight = 0
|
||||||
|
ackLock.notifyAll()
|
||||||
|
}
|
||||||
|
val start = Protobuf.varintField(1, SESSION_ID.toLong()) +
|
||||||
|
Protobuf.varintField(2, (selectedConfigIndex ?: 0).toLong())
|
||||||
|
send(MSG_MEDIA_START, start, encrypted = true, channel = videoChannelId)
|
||||||
|
log.i("-> Start(session_id=$SESSION_ID)")
|
||||||
|
lastCodecConfig?.let { send(MSG_MEDIA_CODEC_CONFIG, it, encrypted = true, channel = videoChannelId) }
|
||||||
|
videoSource?.requestSyncFrame()
|
||||||
|
} else {
|
||||||
|
// A phone ends the stream when the car takes its screen. LIVI switches on the focus
|
||||||
|
// answer alone, but the 2018 Uconnect kept showing our last frame until it got Stop.
|
||||||
|
send(MSG_MEDIA_STOP, Protobuf.varintField(1, SESSION_ID.toLong()), encrypted = true, channel = videoChannelId)
|
||||||
|
log.i("the car is showing its own screen - video stopped, audio carries on")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Android Auto's Exit: asks the car for its own interface, leaving the session running. */
|
||||||
|
private fun requestNativeFocus() {
|
||||||
|
if (!videoStarted) return
|
||||||
|
// VideoFocusRequestNotification { disp_channel_id = 1; mode = 2; reason = 3 }. The
|
||||||
|
// display's channel is deprecated, but the 2018 Uconnect (protocol 1.3) predates that:
|
||||||
|
// without it, it agreed to show its own screen and never did. LIVI ignores the field.
|
||||||
|
val request = Protobuf.varintField(1, videoChannelId.toLong()) +
|
||||||
|
Protobuf.varintField(2, VIDEO_FOCUS_NATIVE.toLong()) +
|
||||||
|
Protobuf.varintField(3, VIDEO_FOCUS_REASON_LAUNCH_NATIVE.toLong())
|
||||||
|
send(MSG_VIDEO_FOCUS_REQUEST, request, encrypted = true, channel = videoChannelId)
|
||||||
|
log.i("-> VideoFocusRequest(display=$videoChannelId, mode=NATIVE, reason=LAUNCH_NATIVE)")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Arrival time of the last tap still waiting to be seen in a frame; 0 when none is. */
|
||||||
|
@Volatile private var touchAtUs = 0L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long a tap takes to reach the car, split where the phone can see it: the UI drawing a
|
||||||
|
* frame after the tap, the encoder compressing it, and the send. A frame's timestamp is when
|
||||||
|
* the UI finished drawing it, on the same clock as [touchAtUs]. The head unit's own decode
|
||||||
|
* and display come on top and can't be seen from here.
|
||||||
|
*/
|
||||||
|
private fun reportTouchLatency(presentationTimeUs: Long) {
|
||||||
|
val touchAt = touchAtUs
|
||||||
|
if (touchAt == 0L || presentationTimeUs <= touchAt) return
|
||||||
|
touchAtUs = 0L
|
||||||
|
val nowUs = System.nanoTime() / 1000
|
||||||
|
val draw = (presentationTimeUs - touchAt) / 1000
|
||||||
|
val encode = (nowUs - presentationTimeUs) / 1000
|
||||||
|
log.i("touch -> frame: ${draw + encode}ms (draw ${draw}ms, encode and wait ${encode}ms)")
|
||||||
|
}
|
||||||
|
|
||||||
private fun clamp(value: Int, limit: Int?): Float = when {
|
private fun clamp(value: Int, limit: Int?): Float = when {
|
||||||
limit == null -> value.toFloat()
|
limit == null -> value.toFloat()
|
||||||
value < 0 -> 0f
|
value < 0 -> 0f
|
||||||
@@ -800,9 +999,11 @@ class GearslipRunner(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun onPingRequest(body: ByteArray) {
|
private fun onPingRequest(body: ByteArray) {
|
||||||
val timestamp = Protobuf.readInt32Field(body, 1)?.toLong() ?: System.nanoTime()
|
// The timestamp is an int64 and has to come back unchanged. Read as an int32 it was cut
|
||||||
|
// short, and a car that checks the echo (an Audi MIB2+) gave up at its second ping.
|
||||||
|
val timestamp = Protobuf.readInt64Field(body, 1) ?: System.nanoTime()
|
||||||
send(MSG_PING_RESPONSE, Protobuf.varintField(1, timestamp), encrypted = tls.handshakeComplete)
|
send(MSG_PING_RESPONSE, Protobuf.varintField(1, timestamp), encrypted = tls.handshakeComplete)
|
||||||
log.i("<- PingRequest / -> PingResponse (keeping the session alive)")
|
log.i("<- PingRequest(timestamp=$timestamp) / -> PingResponse (keeping the session alive)")
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun send(
|
private fun send(
|
||||||
@@ -816,7 +1017,13 @@ class GearslipRunner(
|
|||||||
((messageId shr 8) and 0xFF).toByte(), (messageId and 0xFF).toByte(),
|
((messageId shr 8) and 0xFF).toByte(), (messageId and 0xFF).toByte(),
|
||||||
) + body
|
) + body
|
||||||
|
|
||||||
synchronized(output) {
|
// Every write goes through one thread, in order. The reading thread must never wait on
|
||||||
|
// a write: a head unit like LIVI writes its ack before it reads on, so a reader stuck
|
||||||
|
// behind a big video frame (to answer a ping) and a head unit stuck writing that ack
|
||||||
|
// held each other up until something timed out, about two seconds a time.
|
||||||
|
try {
|
||||||
|
writer.execute {
|
||||||
|
try {
|
||||||
if (payload.size <= Frames.MAX_FRAME_PAYLOAD) {
|
if (payload.size <= Frames.MAX_FRAME_PAYLOAD) {
|
||||||
val framePayload = if (encrypted) tls.encrypt(payload) else payload
|
val framePayload = if (encrypted) tls.encrypt(payload) else payload
|
||||||
output.write(Frames.build(channel, encrypted, framePayload, messageType))
|
output.write(Frames.build(channel, encrypted, framePayload, messageType))
|
||||||
@@ -845,6 +1052,14 @@ class GearslipRunner(
|
|||||||
splitMessages++
|
splitMessages++
|
||||||
}
|
}
|
||||||
output.flush()
|
output.flush()
|
||||||
|
} catch (e: java.io.IOException) {
|
||||||
|
if (running) log.w("send failed, stopping: ${e.message}")
|
||||||
|
running = false
|
||||||
|
videoSource?.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: java.util.concurrent.RejectedExecutionException) {
|
||||||
|
// The session is over; nothing more goes out.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -915,9 +1130,13 @@ class GearslipRunner(
|
|||||||
val sent = framesSent
|
val sent = framesSent
|
||||||
val fps = (sent - lastFrames) / 5.0
|
val fps = (sent - lastFrames) / 5.0
|
||||||
lastFrames = sent
|
lastFrames = sent
|
||||||
SessionReport.stream("${"%.1f".format(fps)} fps, sent=$sent dropped=$dropped acked=$acksSeen")
|
// Time the encoder spent held back waiting for acks: near 5000ms means the
|
||||||
|
// head unit's acks, not the phone, set the frame rate.
|
||||||
|
val waited = ackWaitMs
|
||||||
|
ackWaitMs = 0
|
||||||
|
SessionReport.stream("${"%.1f".format(fps)} fps, sent=$sent acked=$acksSeen")
|
||||||
log.i(
|
log.i(
|
||||||
"video: ${"%.1f".format(fps)} fps over 5s | sent=$sent dropped=$dropped " +
|
"video: ${"%.1f".format(fps)} fps over 5s | sent=$sent waited=${waited}ms " +
|
||||||
"acked=$acksSeen inFlight=$inFlight split=$splitMessages",
|
"acked=$acksSeen inFlight=$inFlight split=$splitMessages",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -969,13 +1188,21 @@ class GearslipRunner(
|
|||||||
|
|
||||||
// aap_protobuf/service/sensor/... - same "first specific message on this channel type
|
// aap_protobuf/service/sensor/... - same "first specific message on this channel type
|
||||||
// starts at 0x8000" numbering as media/input above.
|
// starts at 0x8000" numbering as media/input above.
|
||||||
const val MSG_SENSOR_START_REQUEST = 32768
|
// aasdk SensorMessageId: REQUEST, RESPONSE, BATCH, ERROR.
|
||||||
const val MSG_SENSOR_START_RESPONSE = 32769
|
const val MSG_SENSOR_START_REQUEST = 32769
|
||||||
const val MSG_SENSOR_EVENT_INDICATION = 32770
|
const val MSG_SENSOR_START_RESPONSE = 32770
|
||||||
|
const val MSG_SENSOR_EVENT_INDICATION = 32771
|
||||||
|
const val MSG_SENSOR_ERROR = 32772
|
||||||
|
private const val SENSOR_LOCATION = 1
|
||||||
|
private const val SENSOR_NIGHT = 10
|
||||||
|
private const val SENSOR_LOG_EVERY_MS = 10_000L
|
||||||
|
|
||||||
const val STREAM_MEDIA = 3
|
const val STREAM_MEDIA = 3
|
||||||
const val CODEC_H264_BP = 3
|
const val CODEC_H264_BP = 3
|
||||||
const val VIDEO_FOCUS_PROJECTED = 1
|
const val VIDEO_FOCUS_PROJECTED = 1
|
||||||
|
const val VIDEO_FOCUS_NATIVE = 2
|
||||||
|
const val VIDEO_FOCUS_PROJECTED_NO_INPUT = 4
|
||||||
|
const val VIDEO_FOCUS_REASON_LAUNCH_NATIVE = 2
|
||||||
const val SESSION_ID = 1
|
const val SESSION_ID = 1
|
||||||
const val MSG_INPUT_REPORT = 32769
|
const val MSG_INPUT_REPORT = 32769
|
||||||
const val MSG_KEY_BINDING_REQUEST = 32770
|
const val MSG_KEY_BINDING_REQUEST = 32770
|
||||||
|
|||||||
@@ -35,7 +35,10 @@ object Protobuf {
|
|||||||
varint((field shl 3).toLong()) + varint(value)
|
varint((field shl 3).toLong()) + varint(value)
|
||||||
|
|
||||||
/** Reads the single int32 in AuthResponse. Negative values are 10-byte varints. */
|
/** Reads the single int32 in AuthResponse. Negative values are 10-byte varints. */
|
||||||
fun readInt32Field(data: ByteArray, field: Int): Int? {
|
fun readInt32Field(data: ByteArray, field: Int): Int? = readInt64Field(data, field)?.toInt()
|
||||||
|
|
||||||
|
/** Reads a varint field whole, for int64s such as a ping's timestamp. */
|
||||||
|
fun readInt64Field(data: ByteArray, field: Int): Long? {
|
||||||
var pos = 0
|
var pos = 0
|
||||||
while (pos < data.size) {
|
while (pos < data.size) {
|
||||||
val (tag, afterTag) = readVarint(data, pos) ?: return null
|
val (tag, afterTag) = readVarint(data, pos) ?: return null
|
||||||
@@ -45,7 +48,7 @@ object Protobuf {
|
|||||||
0 -> {
|
0 -> {
|
||||||
val (value, afterValue) = readVarint(data, pos) ?: return null
|
val (value, afterValue) = readVarint(data, pos) ?: return null
|
||||||
pos = afterValue
|
pos = afterValue
|
||||||
if (number == field) return value.toInt()
|
if (number == field) return value
|
||||||
}
|
}
|
||||||
2 -> {
|
2 -> {
|
||||||
val (len, afterLen) = readVarint(data, pos) ?: return null
|
val (len, afterLen) = readVarint(data, pos) ?: return null
|
||||||
|
|||||||
@@ -45,7 +45,19 @@ class ScreenProjector(private val context: Context) {
|
|||||||
/** Tracks the synthetic gesture so injected MotionEvents have a coherent downTime. */
|
/** Tracks the synthetic gesture so injected MotionEvents have a coherent downTime. */
|
||||||
private var downTime = 0L
|
private var downTime = 0L
|
||||||
|
|
||||||
fun start(surface: Surface, width: Int, height: Int, densityDpi: Int, content: @Composable () -> Unit) {
|
/**
|
||||||
|
* [name] names the virtual display so the two callers are told apart from outside the app
|
||||||
|
* (`dumpsys display`): the real car session keeps "gearslip-projection", the on-phone bench
|
||||||
|
* preview passes "gearslip-preview".
|
||||||
|
*/
|
||||||
|
fun start(
|
||||||
|
surface: Surface,
|
||||||
|
width: Int,
|
||||||
|
height: Int,
|
||||||
|
densityDpi: Int,
|
||||||
|
name: String = "gearslip-projection",
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
stop()
|
stop()
|
||||||
|
|
||||||
val displayManager = context.getSystemService(DisplayManager::class.java)
|
val displayManager = context.getSystemService(DisplayManager::class.java)
|
||||||
@@ -57,7 +69,7 @@ class ScreenProjector(private val context: Context) {
|
|||||||
// driver on every connect.
|
// driver on every connect.
|
||||||
val display = try {
|
val display = try {
|
||||||
displayManager.createVirtualDisplay(
|
displayManager.createVirtualDisplay(
|
||||||
"gearslip-projection", width, height, densityDpi, surface,
|
name, width, height, densityDpi, surface,
|
||||||
DisplayManager.VIRTUAL_DISPLAY_FLAG_PRESENTATION or
|
DisplayManager.VIRTUAL_DISPLAY_FLAG_PRESENTATION or
|
||||||
DisplayManager.VIRTUAL_DISPLAY_FLAG_OWN_CONTENT_ONLY,
|
DisplayManager.VIRTUAL_DISPLAY_FLAG_OWN_CONTENT_ONLY,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ object ServiceDiscovery {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* [widthMargin]/[heightMargin] are the head unit's own numbers (VideoConfiguration fields 3
|
* [widthMargin]/[heightMargin] are the head unit's own numbers (VideoConfiguration fields 3
|
||||||
* and 4): pixels of the frame it does not treat as usable UI. Logged, and available to a
|
* and 4): pixels of the frame it crops away, split evenly between the two edges, to fit the
|
||||||
* vehicle profile, but not applied automatically - what they mean varies by unit.
|
* frame to a screen of a different shape. A matched vehicle profile's insets override them.
|
||||||
*/
|
*/
|
||||||
class VideoConfig(
|
class VideoConfig(
|
||||||
val resolution: Int,
|
val resolution: Int,
|
||||||
@@ -38,11 +38,21 @@ object ServiceDiscovery {
|
|||||||
|
|
||||||
fun pixelCount(): Int = pixelSize()?.let { it.first * it.second } ?: Int.MAX_VALUE
|
fun pixelCount(): Int = pixelSize()?.let { it.first * it.second } ?: Int.MAX_VALUE
|
||||||
|
|
||||||
|
/** What's left on the car's screen once the margins are cropped off. */
|
||||||
|
fun visibleSize(): Pair<Int, Int>? = pixelSize()?.let { (w, h) -> (w - widthMargin) to (h - heightMargin) }
|
||||||
|
|
||||||
|
/** The margins as the strips of the frame the car UI keeps clear. */
|
||||||
|
fun marginInsets(): Insets = Insets(
|
||||||
|
top = heightMargin / 2, bottom = heightMargin - heightMargin / 2,
|
||||||
|
left = widthMargin / 2, right = widthMargin - widthMargin / 2,
|
||||||
|
)
|
||||||
|
|
||||||
override fun toString() =
|
override fun toString() =
|
||||||
"$resolutionName @ $frameRateName, density=$density, margin=${widthMargin}x$heightMargin"
|
"$resolutionName @ $frameRateName, density=$density, margin=${widthMargin}x$heightMargin"
|
||||||
}
|
}
|
||||||
|
|
||||||
class VideoService(val serviceId: Int, val codecType: Int, val configs: List<VideoConfig>) {
|
/** [displayId] is MediaSinkService field 6: 0, the default, is the main screen. */
|
||||||
|
class VideoService(val serviceId: Int, val codecType: Int, val configs: List<VideoConfig>, val displayId: Int = 0) {
|
||||||
val codecName: String get() = CODECS[codecType] ?: "unknown($codecType)"
|
val codecName: String get() = CODECS[codecType] ?: "unknown($codecType)"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,6 +99,48 @@ object ServiceDiscovery {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The media sink to stream to. Gearslip sends raw PCM, so a PCM sink wins: the 2022 Dacia
|
||||||
|
* Jogger (LGE ULC 4.5) lists an AAC media sink on channel 3 before a PCM one on channel 5,
|
||||||
|
* and PCM pushed into the AAC sink was acked but played as silence. With no PCM sink, the
|
||||||
|
* first media sink is the best there is.
|
||||||
|
*/
|
||||||
|
fun pickMediaSink(sinks: List<AudioService>): AudioService? {
|
||||||
|
val media = sinks.filter { it.streamType == STREAM_MEDIA }
|
||||||
|
return media.firstOrNull { it.codecType == CODEC_PCM } ?: media.firstOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The car's microphone, from MediaSourceService (Service field 5):
|
||||||
|
* MediaSourceService { MediaCodecType available_type = 1; AudioConfiguration audio_config = 2;
|
||||||
|
* bool available_while_in_call = 3; }
|
||||||
|
* Null when the head unit has none.
|
||||||
|
*/
|
||||||
|
fun findMicService(response: ByteArray): AudioService? {
|
||||||
|
for (channel in Wire.allBytes(Wire.fields(response), 1)) {
|
||||||
|
val service = Wire.fields(channel)
|
||||||
|
val id = Wire.varint(service, 1)?.toInt() ?: continue
|
||||||
|
val source = Wire.fields(Wire.bytes(service, 5) ?: continue)
|
||||||
|
val f = Wire.fields(Wire.bytes(source, 2) ?: continue)
|
||||||
|
return AudioService(
|
||||||
|
serviceId = id,
|
||||||
|
streamType = 0,
|
||||||
|
codecType = Wire.varint(source, 1)?.toInt() ?: CODEC_PCM,
|
||||||
|
configs = listOf(
|
||||||
|
AudioConfig(
|
||||||
|
sampleRate = Wire.varint(f, 1)?.toInt() ?: 16_000,
|
||||||
|
bits = Wire.varint(f, 2)?.toInt() ?: 16,
|
||||||
|
channels = Wire.varint(f, 3)?.toInt() ?: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const val STREAM_MEDIA = 3
|
||||||
|
const val CODEC_PCM = 1
|
||||||
|
|
||||||
private val STREAMS = mapOf(0 to "NONE", 1 to "GUIDANCE", 2 to "SYSTEM_AUDIO", 3 to "MEDIA", 4 to "TELEPHONY")
|
private val STREAMS = mapOf(0 to "NONE", 1 to "GUIDANCE", 2 to "SYSTEM_AUDIO", 3 to "MEDIA", 4 to "TELEPHONY")
|
||||||
|
|
||||||
private val RESOLUTIONS = mapOf(
|
private val RESOLUTIONS = mapOf(
|
||||||
@@ -122,9 +174,23 @@ object ServiceDiscovery {
|
|||||||
val touchWidth: Int,
|
val touchWidth: Int,
|
||||||
val touchHeight: Int,
|
val touchHeight: Int,
|
||||||
val keycodesSupported: List<Int> = emptyList(),
|
val keycodesSupported: List<Int> = emptyList(),
|
||||||
|
/** InputSourceService field 5: which screen this input belongs to, 0 for the main one. */
|
||||||
|
val displayId: Int = 0,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun findInputService(response: ByteArray): InputService? {
|
/**
|
||||||
|
* The input for the screen Gearslip projects to ([displayId]). A head unit with two screens
|
||||||
|
* offers an input service for each: LIVI lists an empty one for its second screen first,
|
||||||
|
* and taking that one left every touch on the real touchscreen ignored.
|
||||||
|
*/
|
||||||
|
fun findInputService(response: ByteArray, displayId: Int = 0): InputService? {
|
||||||
|
val inputs = inputServices(response)
|
||||||
|
return inputs.firstOrNull { it.displayId == displayId }
|
||||||
|
?: inputs.firstOrNull { it.touchWidth > 0 && it.touchHeight > 0 }
|
||||||
|
?: inputs.firstOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = Wire.varint(service, 1)?.toInt() ?: continue
|
||||||
@@ -138,9 +204,8 @@ object ServiceDiscovery {
|
|||||||
val keycodes = inputFields.filter { it.number == 1 }.flatMap { f ->
|
val keycodes = inputFields.filter { it.number == 1 }.flatMap { f ->
|
||||||
f.bytes?.let(::packedVarints) ?: listOf(f.varint.toInt())
|
f.bytes?.let(::packedVarints) ?: listOf(f.varint.toInt())
|
||||||
}
|
}
|
||||||
return InputService(id, width, height, keycodes)
|
add(InputService(id, width, height, keycodes, Wire.varint(inputFields, 5)?.toInt() ?: 0))
|
||||||
}
|
}
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Decodes a `[packed=true]` repeated varint field's raw bytes into individual values. */
|
/** Decodes a `[packed=true]` repeated varint field's raw bytes into individual values. */
|
||||||
@@ -160,7 +225,16 @@ object ServiceDiscovery {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
fun findVideoService(response: ByteArray): VideoService? {
|
fun findVideoService(response: ByteArray): VideoService? {
|
||||||
|
val sinks = videoServices(response)
|
||||||
|
return sinks.firstOrNull { it.displayId == 0 } ?: sinks.firstOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = Wire.varint(service, 1)?.toInt() ?: continue
|
||||||
@@ -181,15 +255,14 @@ object ServiceDiscovery {
|
|||||||
heightMargin = Wire.varint(f, 4)?.toInt() ?: 0,
|
heightMargin = Wire.varint(f, 4)?.toInt() ?: 0,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return VideoService(id, codecType, configs)
|
add(VideoService(id, codecType, configs, Wire.varint(sinkFields, 6)?.toInt() ?: 0))
|
||||||
}
|
}
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The head unit's sensor feed, from SensorSourceService (Service field 5):
|
* The head unit's sensor feed, from SensorSourceService (Service field 2; field 5 is the car's mic):
|
||||||
* SensorSourceService { repeated SensorType sensors = 1; }
|
* SensorSourceService { repeated Sensor sensors = 1; }
|
||||||
* SensorType { required int32 type = 1; }
|
* Sensor { required SensorType sensor_type = 1; }
|
||||||
*
|
*
|
||||||
* Field numbers for what actually rides inside each SensorEvent once subscribed are not
|
* Field numbers for what actually rides inside each SensorEvent once subscribed are not
|
||||||
* pinned down here - unlike video/audio/input, there's no independent source to check them
|
* pinned down here - unlike video/audio/input, there's no independent source to check them
|
||||||
@@ -201,7 +274,7 @@ object ServiceDiscovery {
|
|||||||
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 = Wire.varint(service, 1)?.toInt() ?: continue
|
||||||
val sensorSource = Wire.bytes(service, 5) ?: 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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package app.seb3thehacker.gearslip
|
package app.seb3thehacker.gearslip
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import app.seb3thehacker.gearslip.stats.UsageStats
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -48,7 +49,10 @@ object SessionReport {
|
|||||||
@Volatile private var lastState = ""
|
@Volatile private var lastState = ""
|
||||||
private var printed = false
|
private var printed = false
|
||||||
|
|
||||||
|
private var appContext: Context? = null
|
||||||
|
|
||||||
fun init(context: Context) {
|
fun init(context: Context) {
|
||||||
|
appContext = context.applicationContext
|
||||||
val info = runCatching { context.packageManager.getPackageInfo(context.packageName, 0) }.getOrNull()
|
val info = runCatching { context.packageManager.getPackageInfo(context.packageName, 0) }.getOrNull()
|
||||||
appVersion = info?.versionName ?: "?"
|
appVersion = info?.versionName ?: "?"
|
||||||
appCode = info?.longVersionCode ?: 0
|
appCode = info?.longVersionCode ?: 0
|
||||||
@@ -59,6 +63,8 @@ object SessionReport {
|
|||||||
category = Category.NONE
|
category = Category.NONE
|
||||||
failure = ""; protocolVersion = ""; certSource = ""; certIssuer = ""
|
failure = ""; protocolVersion = ""; certSource = ""; certIssuer = ""
|
||||||
tlsProtocol = ""; tlsCipher = ""; authStatus = null
|
tlsProtocol = ""; tlsCipher = ""; authStatus = null
|
||||||
|
headUnitInfo = null
|
||||||
|
carScreen = ""; carDpi = 0
|
||||||
headUnit = ""; profile = ""; video = ""; lastStream = ""; lastState = ""
|
headUnit = ""; profile = ""; video = ""; lastStream = ""; lastState = ""
|
||||||
printed = false
|
printed = false
|
||||||
}
|
}
|
||||||
@@ -68,6 +74,14 @@ object SessionReport {
|
|||||||
fun tls(protocol: String, cipher: String) { tlsProtocol = protocol; tlsCipher = cipher }
|
fun tls(protocol: String, cipher: String) { tlsProtocol = protocol; tlsCipher = cipher }
|
||||||
fun auth(status: Int) { authStatus = status }
|
fun auth(status: Int) { authStatus = status }
|
||||||
fun headUnit(info: String) { headUnit = info }
|
fun headUnit(info: String) { headUnit = info }
|
||||||
|
|
||||||
|
/** Who the car said it is, for the opt-in usage note; null until service discovery. */
|
||||||
|
@Volatile var headUnitInfo: ServiceDiscovery.HeadUnitInfo? = null
|
||||||
|
|
||||||
|
/** The car screen's usable size once its margins are cropped, and its density; blank before video starts. */
|
||||||
|
@Volatile var carScreen = ""; private set
|
||||||
|
@Volatile var carDpi = 0; private set
|
||||||
|
fun carScreen(size: String, dpi: Int) { carScreen = size; carDpi = dpi }
|
||||||
fun profile(name: String?) { profile = name ?: "none matched" }
|
fun profile(name: String?) { profile = name ?: "none matched" }
|
||||||
fun video(summary: String) { video = summary }
|
fun video(summary: String) { video = summary }
|
||||||
fun audio(summary: String) { audio = summary }
|
fun audio(summary: String) { audio = summary }
|
||||||
@@ -121,6 +135,18 @@ object SessionReport {
|
|||||||
if (printed || startedAt == 0L) return
|
if (printed || startedAt == 0L) return
|
||||||
printed = true
|
printed = true
|
||||||
render().lines().forEach { GearslipLog.i(it) }
|
render().lines().forEach { GearslipLog.i(it) }
|
||||||
|
appContext?.let { context ->
|
||||||
|
// Video started means the car showed Gearslip: that's a working car, and unplugging or
|
||||||
|
// switching the car off at the end shows up as a USB or ByeBye "failure" that isn't one.
|
||||||
|
val reached = carScreen.isNotEmpty()
|
||||||
|
val outcome = when {
|
||||||
|
reached && category in setOf(Category.NONE, Category.USB, Category.BYEBYE) -> "connected"
|
||||||
|
reached -> "connected, then ${category.label}"
|
||||||
|
category == Category.NONE -> "ended before video"
|
||||||
|
else -> category.label
|
||||||
|
}
|
||||||
|
runCatching { UsageStats.recordSession(context, headUnitInfo, protocolVersion, outcome, carScreen, carDpi) }
|
||||||
|
}
|
||||||
GearslipLog.flush()
|
GearslipLog.flush()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,19 @@ class VideoSource(
|
|||||||
// Drops already ask for a fresh keyframe, so a long interval costs nothing in recovery
|
// Drops already ask for a fresh keyframe, so a long interval costs nothing in recovery
|
||||||
// and spares the link a big frame every second.
|
// and spares the link a big frame every second.
|
||||||
setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, I_FRAME_INTERVAL_S)
|
setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, I_FRAME_INTERVAL_S)
|
||||||
|
if (mode == Mode.SURFACE) {
|
||||||
|
// A surface encoder only makes a frame when the screen changes, and many decoders
|
||||||
|
// show a frame only once the next one arrives. So the last change before the
|
||||||
|
// screen goes still (a menu closing) sat in the car's decoder until something
|
||||||
|
// else moved. Repeating the last frame after a short pause pushes it through;
|
||||||
|
// a repeat of an unchanged screen costs a few bytes. scrcpy does the same.
|
||||||
|
setLong(MediaFormat.KEY_REPEAT_PREVIOUS_FRAME_AFTER, REPEAT_AFTER_US)
|
||||||
|
// An app that draws nonstop (a moving map) renders at the phone's refresh rate,
|
||||||
|
// faster than the car acks. Every extra frame queued inside the encoder, and the
|
||||||
|
// car's screen fell 25 seconds behind. Capping the input drops the extras before
|
||||||
|
// they're encoded, so the car always gets the newest frame.
|
||||||
|
setFloat(MediaFormat.KEY_MAX_FPS_TO_ENCODER, frameRate.toFloat())
|
||||||
|
}
|
||||||
setInteger(MediaFormat.KEY_PROFILE, MediaCodecInfo.CodecProfileLevel.AVCProfileBaseline)
|
setInteger(MediaFormat.KEY_PROFILE, MediaCodecInfo.CodecProfileLevel.AVCProfileBaseline)
|
||||||
setInteger(MediaFormat.KEY_LEVEL, MediaCodecInfo.CodecProfileLevel.AVCLevel31)
|
setInteger(MediaFormat.KEY_LEVEL, MediaCodecInfo.CodecProfileLevel.AVCLevel31)
|
||||||
if (tuned) {
|
if (tuned) {
|
||||||
@@ -215,6 +228,7 @@ class VideoSource(
|
|||||||
const val MIN_BIT_RATE = 4_000_000
|
const val MIN_BIT_RATE = 4_000_000
|
||||||
const val MAX_BIT_RATE = 12_000_000
|
const val MAX_BIT_RATE = 12_000_000
|
||||||
const val I_FRAME_INTERVAL_S = 5
|
const val I_FRAME_INTERVAL_S = 5
|
||||||
|
private const val REPEAT_AFTER_US = 100_000L
|
||||||
|
|
||||||
/** (Y, Cb, Cr) - white, yellow, cyan, green, magenta, red, blue. */
|
/** (Y, Cb, Cr) - white, yellow, cyan, green, magenta, red, blue. */
|
||||||
val BAR_COLOURS = listOf(
|
val BAR_COLOURS = listOf(
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
package app.seb3thehacker.gearslip.audio
|
||||||
|
|
||||||
|
import app.seb3thehacker.gearslip.GearslipLog
|
||||||
|
import app.seb3thehacker.gearslip.Protobuf
|
||||||
|
import app.seb3thehacker.gearslip.ServiceDiscovery
|
||||||
|
import app.seb3thehacker.gearslip.Wire
|
||||||
|
import java.util.concurrent.LinkedBlockingQueue
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The car's own microphone, which most head units offer over the same link as the screen:
|
||||||
|
*
|
||||||
|
* -> CHANNEL_OPEN_REQUEST <- CHANNEL_OPEN_RESPONSE
|
||||||
|
* -> Setup { config_index } <- Config
|
||||||
|
* -> MicrophoneRequest { open } <- MicrophoneResponse { status, session_id }
|
||||||
|
* <- Data { 8-byte timestamp, PCM }... -> Ack { session_id, ack }
|
||||||
|
* -> MicrophoneRequest { open = false }
|
||||||
|
*
|
||||||
|
* The channel opens with the session, but the car only records between [start] and [stop].
|
||||||
|
* Speech wants 16 kHz mono; a car that records at a multiple of that is averaged down to it, and
|
||||||
|
* one that doesn't isn't used, so voice falls back to the phone's microphone.
|
||||||
|
*/
|
||||||
|
class CarMic(
|
||||||
|
private val service: ServiceDiscovery.AudioService,
|
||||||
|
private val sendOnChannel: (messageId: Int, body: ByteArray) -> Unit,
|
||||||
|
) {
|
||||||
|
val channelId get() = service.serviceId
|
||||||
|
|
||||||
|
private val config = service.configs.first()
|
||||||
|
private val step = config.sampleRate / RATE
|
||||||
|
private val usable = service.codecType == ServiceDiscovery.CODEC_PCM && config.bits == 16 &&
|
||||||
|
config.sampleRate % RATE == 0 && step >= 1 && config.channels in 1..2
|
||||||
|
|
||||||
|
@Volatile private var ready = false
|
||||||
|
@Volatile private var listening = false
|
||||||
|
@Volatile private var session = 0
|
||||||
|
private var packets = 0
|
||||||
|
private val chunks = LinkedBlockingQueue<ShortArray>()
|
||||||
|
|
||||||
|
fun onMessage(messageId: Int, body: ByteArray) {
|
||||||
|
when (messageId) {
|
||||||
|
MSG_CHANNEL_OPEN_RESPONSE -> {
|
||||||
|
val status = Protobuf.readInt32Field(body, 1) ?: 0
|
||||||
|
GearslipLog.i("mic: <- ChannelOpenResponse: status=$status")
|
||||||
|
if (status != 0) return
|
||||||
|
sendOnChannel(MSG_SETUP, Protobuf.varintField(1, 0L))
|
||||||
|
ready = usable
|
||||||
|
if (!usable) {
|
||||||
|
GearslipLog.w(
|
||||||
|
"mic: the car records ${service.codecName} ${config.sampleRate}Hz/${config.bits}bit/x${config.channels}, " +
|
||||||
|
"which speech can't use - voice stays on the phone's microphone",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MSG_CONFIG -> GearslipLog.i("mic: <- Config status=${Wire.varint(Wire.fields(body), 1)}")
|
||||||
|
|
||||||
|
MSG_MIC_RESPONSE -> {
|
||||||
|
val fields = Wire.fields(body)
|
||||||
|
session = Wire.varint(fields, 2)?.toInt() ?: 0
|
||||||
|
GearslipLog.i("mic: <- MicrophoneResponse status=${Wire.varint(fields, 1)} session=$session")
|
||||||
|
}
|
||||||
|
|
||||||
|
MSG_DATA -> {
|
||||||
|
sendOnChannel(MSG_ACK, Protobuf.varintField(1, session.toLong()) + Protobuf.varintField(2, 1L))
|
||||||
|
if (!listening || body.size <= TIMESTAMP_BYTES) return
|
||||||
|
if (packets++ == 0) GearslipLog.i("mic: first audio from the car (${body.size - TIMESTAMP_BYTES} bytes)")
|
||||||
|
chunks.offer(toSpeechRate(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> GearslipLog.i("mic: <- unhandled message id=$messageId (${body.size} bytes)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Little-endian PCM after the timestamp, mixed to mono and averaged down to [RATE]. */
|
||||||
|
private fun toSpeechRate(body: ByteArray): ShortArray {
|
||||||
|
val frameBytes = 2 * config.channels
|
||||||
|
val frames = (body.size - TIMESTAMP_BYTES) / frameBytes
|
||||||
|
val out = ShortArray(frames / step)
|
||||||
|
for (i in out.indices) {
|
||||||
|
var sum = 0
|
||||||
|
for (s in 0 until step) {
|
||||||
|
val at = TIMESTAMP_BYTES + (i * step + s) * frameBytes
|
||||||
|
for (c in 0 until config.channels) {
|
||||||
|
val o = at + 2 * c
|
||||||
|
sum += (body[o].toInt() and 0xFF) or (body[o + 1].toInt() shl 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out[i] = (sum / (step * config.channels)).toShort()
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Asks the car to start recording. False when it can't, so the phone's microphone is used. */
|
||||||
|
fun start(): Boolean {
|
||||||
|
if (!ready) return false
|
||||||
|
chunks.clear()
|
||||||
|
packets = 0
|
||||||
|
listening = true
|
||||||
|
sendOnChannel(MSG_MIC_REQUEST, Protobuf.varintField(1, 1L) + Protobuf.varintField(4, 1L))
|
||||||
|
GearslipLog.i("mic: -> MicrophoneRequest(open)")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The next piece of audio, or null if the car sent nothing for [timeoutMs]. */
|
||||||
|
fun read(timeoutMs: Long): ShortArray? = chunks.poll(timeoutMs, TimeUnit.MILLISECONDS)
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
if (!listening) return
|
||||||
|
listening = false
|
||||||
|
sendOnChannel(MSG_MIC_REQUEST, Protobuf.varintField(1, 0L))
|
||||||
|
GearslipLog.i("mic: -> MicrophoneRequest(close) after $packets packets")
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** The connected car's microphone, or null with no car or one that has none. */
|
||||||
|
@Volatile var current: CarMic? = null
|
||||||
|
|
||||||
|
const val RATE = 16_000
|
||||||
|
private const val TIMESTAMP_BYTES = 8
|
||||||
|
private const val MSG_CHANNEL_OPEN_RESPONSE = 8
|
||||||
|
private const val MSG_DATA = 0
|
||||||
|
private const val MSG_SETUP = 32768
|
||||||
|
private const val MSG_CONFIG = 32771
|
||||||
|
private const val MSG_ACK = 32772
|
||||||
|
private const val MSG_MIC_REQUEST = 32773
|
||||||
|
private const val MSG_MIC_RESPONSE = 32774
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,11 @@ import android.view.SurfaceHolder
|
|||||||
import android.view.SurfaceView
|
import android.view.SurfaceView
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
import androidx.compose.foundation.gestures.detectTransformGestures
|
import androidx.compose.foundation.gestures.detectTransformGestures
|
||||||
|
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||||
|
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||||
|
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||||
|
import androidx.compose.ui.input.pointer.util.VelocityTracker
|
||||||
|
import kotlin.math.hypot
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
@@ -125,6 +130,30 @@ private fun SurfaceHost(
|
|||||||
if (pan != Offset.Zero) connection.surfaceScroll(-pan.x, -pan.y)
|
if (pan != Offset.Zero) connection.surfaceScroll(-pan.x, -pan.y)
|
||||||
if (zoom != 1f) connection.surfaceScale(centroid.x, centroid.y, zoom)
|
if (zoom != 1f) connection.surfaceScale(centroid.x, centroid.y, zoom)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
.pointerInput(connection) {
|
||||||
|
// detectTransformGestures has no velocity, so a fling is measured here alongside
|
||||||
|
// it: one finger lifting while still moving lets the map glide on, as on a car.
|
||||||
|
// Velocity is the finger's own, which is GestureDetector's onFling convention.
|
||||||
|
awaitEachGesture {
|
||||||
|
val down = awaitFirstDown(requireUnconsumed = false)
|
||||||
|
val tracker = VelocityTracker()
|
||||||
|
tracker.addPosition(down.uptimeMillis, down.position)
|
||||||
|
var pinched = false
|
||||||
|
while (true) {
|
||||||
|
val event = awaitPointerEvent(PointerEventPass.Final)
|
||||||
|
if (event.changes.size > 1) pinched = true
|
||||||
|
val change = event.changes.firstOrNull { it.id == down.id } ?: break
|
||||||
|
tracker.addPosition(change.uptimeMillis, change.position)
|
||||||
|
if (!change.pressed) {
|
||||||
|
val v = tracker.calculateVelocity()
|
||||||
|
if (!pinched && hypot(v.x, v.y) >= FLING_MIN_PX_S) {
|
||||||
|
connection.surfaceFling(v.x, v.y)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
factory = { context ->
|
factory = { context ->
|
||||||
SurfaceView(context).apply {
|
SurfaceView(context).apply {
|
||||||
@@ -188,3 +217,6 @@ private fun ChromeBar(
|
|||||||
|
|
||||||
/** Height of [ChromeBar]; the template's own chrome starts below it. */
|
/** Height of [ChromeBar]; the template's own chrome starts below it. */
|
||||||
private val CHROME_BAR = 44.dp
|
private val CHROME_BAR = 44.dp
|
||||||
|
|
||||||
|
/** Slower than this when the finger lifts and it was a drag that stopped, not a fling. */
|
||||||
|
private const val FLING_MIN_PX_S = 400f
|
||||||
@@ -15,6 +15,7 @@ import androidx.core.content.ContextCompat
|
|||||||
import app.seb3thehacker.gearslip.call.CallUiState
|
import app.seb3thehacker.gearslip.call.CallUiState
|
||||||
import app.seb3thehacker.gearslip.call.CarCalls
|
import app.seb3thehacker.gearslip.call.CarCalls
|
||||||
import app.seb3thehacker.gearslip.speech.SpeechEngine
|
import app.seb3thehacker.gearslip.speech.SpeechEngine
|
||||||
|
import app.seb3thehacker.gearslip.speech.VoiceFocus
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
@@ -119,15 +120,19 @@ object CarAssistant {
|
|||||||
_state.value = _state.value.copy(phase = Phase.IDLE)
|
_state.value = _state.value.copy(phase = Phase.IDLE)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
appContext?.let(VoiceFocus::acquire)
|
||||||
engine.setOnUtteranceProgressListener(object : UtteranceProgressListener() {
|
engine.setOnUtteranceProgressListener(object : UtteranceProgressListener() {
|
||||||
override fun onStart(utteranceId: String?) {}
|
override fun onStart(utteranceId: String?) {}
|
||||||
override fun onDone(utteranceId: String?) {
|
override fun onDone(utteranceId: String?) {
|
||||||
|
VoiceFocus.release()
|
||||||
_state.value = _state.value.copy(phase = Phase.IDLE)
|
_state.value = _state.value.copy(phase = Phase.IDLE)
|
||||||
}
|
}
|
||||||
@Deprecated("Deprecated in Java, no replacement on this API level")
|
@Deprecated("Deprecated in Java, no replacement on this API level")
|
||||||
override fun onError(utteranceId: String?) {
|
override fun onError(utteranceId: String?) {
|
||||||
|
VoiceFocus.release()
|
||||||
_state.value = _state.value.copy(phase = Phase.IDLE)
|
_state.value = _state.value.copy(phase = Phase.IDLE)
|
||||||
}
|
}
|
||||||
|
override fun onStop(utteranceId: String?, interrupted: Boolean) { VoiceFocus.release() }
|
||||||
})
|
})
|
||||||
engine.speak(text, TextToSpeech.QUEUE_FLUSH, null, "gearslip-assistant")
|
engine.speak(text, TextToSpeech.QUEUE_FLUSH, null, "gearslip-assistant")
|
||||||
}
|
}
|
||||||
@@ -138,17 +143,19 @@ object CarAssistant {
|
|||||||
*/
|
*/
|
||||||
fun say(text: String, onDone: () -> Unit) {
|
fun say(text: String, onDone: () -> Unit) {
|
||||||
val engine = tts
|
val engine = tts
|
||||||
if (engine == null || !ttsReady || text.isBlank()) {
|
val context = appContext
|
||||||
|
if (engine == null || context == null || !ttsReady || text.isBlank()) {
|
||||||
mainHandler.post(onDone)
|
mainHandler.post(onDone)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
VoiceFocus.acquire(context)
|
||||||
engine.setOnUtteranceProgressListener(object : UtteranceProgressListener() {
|
engine.setOnUtteranceProgressListener(object : UtteranceProgressListener() {
|
||||||
override fun onStart(utteranceId: String?) {}
|
override fun onStart(utteranceId: String?) {}
|
||||||
override fun onDone(utteranceId: String?) { mainHandler.post(onDone) }
|
override fun onDone(utteranceId: String?) { VoiceFocus.release(); mainHandler.post(onDone) }
|
||||||
@Deprecated("Deprecated in Java, no replacement on this API level")
|
@Deprecated("Deprecated in Java, no replacement on this API level")
|
||||||
override fun onError(utteranceId: String?) { mainHandler.post(onDone) }
|
override fun onError(utteranceId: String?) { VoiceFocus.release(); mainHandler.post(onDone) }
|
||||||
// Stopped part way: whoever stopped it has already moved on.
|
// Stopped part way: whoever stopped it has already moved on.
|
||||||
override fun onStop(utteranceId: String?, interrupted: Boolean) {}
|
override fun onStop(utteranceId: String?, interrupted: Boolean) { VoiceFocus.release() }
|
||||||
})
|
})
|
||||||
engine.speak(text, TextToSpeech.QUEUE_FLUSH, null, "gearslip-say")
|
engine.speak(text, TextToSpeech.QUEUE_FLUSH, null, "gearslip-say")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ fun CarDashboardScreen() {
|
|||||||
.padding(horizontal = 24.dp, vertical = 16.dp),
|
.padding(horizontal = 24.dp, vertical = 16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||||
) {
|
) {
|
||||||
Text("Today", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
|
ScreenHeader("Today")
|
||||||
WeatherCard(context)
|
WeatherCard(context)
|
||||||
AgendaCard(context)
|
AgendaCard(context)
|
||||||
NotificationsSection()
|
NotificationsSection()
|
||||||
|
|||||||
@@ -83,12 +83,13 @@ fun CarHome() {
|
|||||||
val navigator = LocalCarNavigator.current
|
val navigator = LocalCarNavigator.current
|
||||||
val panel = navigator.sidePanel
|
val panel = navigator.sidePanel
|
||||||
|
|
||||||
// Coming back to the map from another screen: make sure the nav app is drawing into a live
|
// Coming back to the map from another screen: the map stayed loaded underneath, so its surface
|
||||||
// surface. The first composition is skipped, since the surface is only just being lent.
|
// is only replaced if it died meanwhile. The first composition is skipped, since the surface
|
||||||
|
// is only just being lent.
|
||||||
val onHome = navigator.current == CarScreen.Home
|
val onHome = navigator.current == CarScreen.Home
|
||||||
val seenHome = remember { booleanArrayOf(false) }
|
val seenHome = remember { booleanArrayOf(false) }
|
||||||
LaunchedEffect(onHome) {
|
LaunchedEffect(onHome) {
|
||||||
if (onHome && seenHome[0]) CarServices.nav.refreshSurface()
|
if (onHome && seenHome[0]) CarServices.nav.replaceDeadSurface()
|
||||||
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
|
||||||
|
|||||||
@@ -27,6 +27,13 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
|||||||
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.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.compositionLocalOf
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.coerceIn
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether a [CarKeyboard] is on screen right now, so [CarUi] can give it the row of height the
|
* Whether a [CarKeyboard] is on screen right now, so [CarUi] can give it the row of height the
|
||||||
@@ -72,8 +79,20 @@ fun CarKeyboard(
|
|||||||
onDispose { CarKeyboardVisibility.state.value = false }
|
onDispose { CarKeyboardVisibility.state.value = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
val rows = if (symbols) SYMBOL_ROWS else LETTER_ROWS
|
// The same field on the phone, for typing on its own keyboard: see [PhoneTyping].
|
||||||
|
val latestChange by rememberUpdatedState(onTextChange)
|
||||||
|
val latestSubmit by rememberUpdatedState(onSubmit)
|
||||||
|
val latestDismiss by rememberUpdatedState(onDismiss)
|
||||||
|
val phoneField = remember {
|
||||||
|
PhoneTyping.open(text, { latestChange(it) }, { latestSubmit() }, onDismiss?.let { { latestDismiss?.invoke() } })
|
||||||
|
}
|
||||||
|
DisposableEffect(phoneField) { onDispose { PhoneTyping.close(phoneField) } }
|
||||||
|
LaunchedEffect(text) { PhoneTyping.carText(phoneField, text) }
|
||||||
|
|
||||||
|
val rows = if (symbols) SYMBOL_ROWS else LETTER_ROWS
|
||||||
|
val keyHeight = keyHeight(LocalConfiguration.current.screenHeightDp)
|
||||||
|
|
||||||
|
CompositionLocalProvider(LocalKeyHeight provides keyHeight) {
|
||||||
Surface(
|
Surface(
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
modifier = modifier.fillMaxWidth(),
|
modifier = modifier.fillMaxWidth(),
|
||||||
@@ -134,13 +153,14 @@ fun CarKeyboard(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun Key(label: String, modifier: Modifier = Modifier, onClick: () -> Unit) {
|
private fun Key(label: String, modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||||
GsIconBox(
|
GsIconBox(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
modifier = modifier.height(KEY_HEIGHT),
|
modifier = modifier.height(LocalKeyHeight.current),
|
||||||
colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.colorScheme.onSurface),
|
colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.colorScheme.onSurface),
|
||||||
shape = RoundedCornerShape(8.dp),
|
shape = RoundedCornerShape(8.dp),
|
||||||
) {
|
) {
|
||||||
@@ -163,7 +183,7 @@ private fun Key(label: String, modifier: Modifier = Modifier, onClick: () -> Uni
|
|||||||
private fun DismissKey(onClick: () -> Unit, modifier: Modifier = Modifier) {
|
private fun DismissKey(onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||||
GsIconBox(
|
GsIconBox(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
modifier = modifier.height(KEY_HEIGHT),
|
modifier = modifier.height(LocalKeyHeight.current),
|
||||||
colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.colorScheme.onSurfaceVariant),
|
colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.colorScheme.onSurfaceVariant),
|
||||||
shape = RoundedCornerShape(8.dp),
|
shape = RoundedCornerShape(8.dp),
|
||||||
) {
|
) {
|
||||||
@@ -181,7 +201,7 @@ private fun DismissKey(onClick: () -> Unit, modifier: Modifier = Modifier) {
|
|||||||
private fun SubmitKey(modifier: Modifier, search: Boolean, image: ImageVector?, onClick: () -> Unit) {
|
private fun SubmitKey(modifier: Modifier, search: Boolean, image: ImageVector?, onClick: () -> Unit) {
|
||||||
GsIconBox(
|
GsIconBox(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
modifier = modifier.height(KEY_HEIGHT),
|
modifier = modifier.height(LocalKeyHeight.current),
|
||||||
colors = GsColors(MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.onPrimary),
|
colors = GsColors(MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.onPrimary),
|
||||||
shape = RoundedCornerShape(8.dp),
|
shape = RoundedCornerShape(8.dp),
|
||||||
) {
|
) {
|
||||||
@@ -199,10 +219,12 @@ private fun SubmitKey(modifier: Modifier, search: Boolean, image: ImageVector?,
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Big enough to hit on a bumpy road, small enough that four rows plus a field plus a few
|
* Big enough to hit on a bumpy road, small enough that four rows plus a field plus a few results
|
||||||
* results all fit a 480px-tall screen - which is the real constraint here, not the key size.
|
* still fit. A 480-tall screen sets the floor; a taller one (LIVI's 1280x720) gets bigger keys.
|
||||||
*/
|
*/
|
||||||
private val KEY_HEIGHT = 34.dp
|
private fun keyHeight(screenHeightDp: Int): Dp = (screenHeightDp * 0.075f).dp.coerceIn(36.dp, 52.dp)
|
||||||
|
|
||||||
|
private val LocalKeyHeight = compositionLocalOf { 36.dp }
|
||||||
|
|
||||||
private val LETTER_ROWS = listOf(
|
private val LETTER_ROWS = listOf(
|
||||||
"qwertyuiop".map { it.toString() },
|
"qwertyuiop".map { it.toString() },
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
|||||||
import androidx.compose.foundation.lazy.grid.items
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Info
|
import androidx.compose.material.icons.filled.Info
|
||||||
|
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -47,6 +48,8 @@ import androidx.compose.foundation.layout.width
|
|||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||||
import androidx.compose.material.icons.filled.Check
|
import androidx.compose.material.icons.filled.Check
|
||||||
@@ -133,7 +136,13 @@ internal object LauncherCache {
|
|||||||
// own Messages screen does the same job, so a messenger gets only that tile.
|
// own Messages screen does the same job, so a messenger gets only that tile.
|
||||||
val messagingPackages = messagingApps.map { it.packageName }.toSet()
|
val messagingPackages = messagingApps.map { it.packageName }.toSet()
|
||||||
|
|
||||||
val nav = navApps.filter { it.component.packageName !in messagingPackages }.map {
|
val nav = navApps
|
||||||
|
.filter { it.component.packageName !in messagingPackages }
|
||||||
|
// An app whose template screen is rejected but whose player works through Gearslip
|
||||||
|
// (Spotify) shows only its player tile - a "· Browse" tile beside it would just report
|
||||||
|
// that the car screen isn't supported.
|
||||||
|
.filter { !(KnownApps.screenBroken(it.component.packageName) && KnownApps.playerWorks(it.component.packageName)) }
|
||||||
|
.map {
|
||||||
val label = if (it.component.packageName in mediaPackages) "${it.label} · Browse" else it.label
|
val label = if (it.component.packageName in mediaPackages) "${it.label} · Browse" else it.label
|
||||||
Entry(label, iconOf(it.component.packageName), template = it)
|
Entry(label, iconOf(it.component.packageName), template = it)
|
||||||
}
|
}
|
||||||
@@ -157,6 +166,51 @@ internal fun rememberAllEntries(): List<Entry> {
|
|||||||
return remember(installed) { BuiltInApps.entries + installed.orEmpty() }
|
return remember(installed) { BuiltInApps.entries + installed.orEmpty() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The launcher's groups, in the order they're laid out. */
|
||||||
|
private enum class AppGroup { PHONE, MAPS, MUSIC, MESSAGING, WEB, SCREEN_SHARING, OTHER, SETTINGS, EXIT }
|
||||||
|
|
||||||
|
private fun groupOf(entry: Entry, mediaPackages: Set<String>): AppGroup {
|
||||||
|
entry.builtIn?.let {
|
||||||
|
return when (it) {
|
||||||
|
BuiltInApps.Phone -> AppGroup.PHONE
|
||||||
|
BuiltInApps.Web -> AppGroup.WEB
|
||||||
|
BuiltInApps.ScreenSharing -> AppGroup.SCREEN_SHARING
|
||||||
|
BuiltInApps.Exit -> AppGroup.EXIT
|
||||||
|
else -> AppGroup.SETTINGS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (entry.media != null) return AppGroup.MUSIC
|
||||||
|
if (entry.messaging != null) return AppGroup.MESSAGING
|
||||||
|
val app = entry.template ?: return AppGroup.OTHER
|
||||||
|
// A media app's "· Browse" tile sits next to its player.
|
||||||
|
if (app.component.packageName in mediaPackages) return AppGroup.MUSIC
|
||||||
|
return when (app.kind) {
|
||||||
|
"calling" -> AppGroup.PHONE
|
||||||
|
"navigation", "poi", "parking", "charging" -> AppGroup.MAPS
|
||||||
|
"messaging" -> AppGroup.MESSAGING
|
||||||
|
"settings" -> AppGroup.SETTINGS
|
||||||
|
else -> AppGroup.OTHER
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The launcher's tiles in the order they're drawn: grouped by [AppGroup], alphabetical inside a
|
||||||
|
* group with Settings last of all, then with the driver's own moves from [custom] laid over it.
|
||||||
|
* Moved apps only trade places among themselves, so an app installed later still lands in its group.
|
||||||
|
*/
|
||||||
|
internal fun launcherOrder(all: List<Entry>, showVehicleData: Boolean, custom: List<String>): List<Entry> {
|
||||||
|
val shown = all.filter { it.builtIn !== BuiltInApps.VehicleData || showVehicleData }
|
||||||
|
val mediaPackages = shown.mapNotNull { it.media?.component?.packageName }.toSet()
|
||||||
|
val grouped = shown.sortedWith(
|
||||||
|
compareBy<Entry>({ groupOf(it, mediaPackages) }, { it.builtIn === BuiltInApps.Settings }, { it.label.lowercase() }),
|
||||||
|
)
|
||||||
|
if (custom.isEmpty()) return grouped
|
||||||
|
val rank = custom.withIndex().associate { (i, id) -> id to i }
|
||||||
|
fun Entry.rank() = componentId?.let { rank[it] }
|
||||||
|
val moved = grouped.filter { it.rank() != null }.sortedBy { it.rank() }.iterator()
|
||||||
|
return grouped.map { if (it.rank() != null) moved.next() else it }
|
||||||
|
}
|
||||||
|
|
||||||
/** An app by the id a pin or the running list knows it by, built-in or installed. */
|
/** An app by the id a pin or the running list knows it by, built-in or installed. */
|
||||||
internal fun findEntry(id: String): Entry? =
|
internal fun findEntry(id: String): Entry? =
|
||||||
BuiltInApps.entries.find { it.componentId == id } ?: LauncherCache.entries?.find { it.componentId == id }
|
BuiltInApps.entries.find { it.componentId == id } ?: LauncherCache.entries?.find { it.componentId == id }
|
||||||
@@ -184,7 +238,7 @@ internal fun launchEntry(entry: Entry, navigator: CarNavigator, frame: CarEnviro
|
|||||||
entry.messaging?.let { navigator.messages(it.packageName, it.label) }
|
entry.messaging?.let { navigator.messages(it.packageName, it.label) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The app launcher: Web and Screen sharing, every car app the phone has, then Settings, as an icon grid. */
|
/** 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 navigator = LocalCarNavigator.current
|
val navigator = LocalCarNavigator.current
|
||||||
@@ -203,18 +257,15 @@ 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,
|
||||||
verified = pkg != null && (KnownApps.works(pkg) || (entry.media != null && KnownApps.playerWorks(pkg))),
|
verified = pkg != null && !KnownApps.isPartial(pkg) && (KnownApps.works(pkg) || (entry.media != null && KnownApps.playerWorks(pkg))),
|
||||||
broken = pkg != null && (KnownApps.isBroken(pkg) || (entry.template != null && KnownApps.screenBroken(pkg))),
|
broken = pkg != null && (KnownApps.isBroken(pkg) || (entry.template != null && KnownApps.screenBroken(pkg))),
|
||||||
partial = pkg != null && KnownApps.isPartial(pkg),
|
partial = pkg != null && KnownApps.isPartial(pkg),
|
||||||
onLongClick = id?.let { { navigator.showAppMenu(it) } },
|
onLongClick = id?.let { { navigator.showAppMenu(it) } },
|
||||||
) { openApp(entry, id?.let { running[it] }, navigator, frame) }
|
) { openApp(entry, id?.let { running[it] }, navigator, frame) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gearslip's own apps first, then everything installed, then Settings last where it's expected.
|
val appOrder by CarSettings.appOrder.collectAsState()
|
||||||
val builtIns = all.filter { it.builtIn != null && (it.builtIn !== BuiltInApps.VehicleData || showVehicleData) }
|
val tiles = launcherOrder(all, showVehicleData, appOrder).map(::tileOf)
|
||||||
val tiles = builtIns.filter { it.builtIn !== BuiltInApps.Settings }.map(::tileOf) +
|
|
||||||
all.filter { it.builtIn == null }.map(::tileOf) +
|
|
||||||
builtIns.filter { it.builtIn === BuiltInApps.Settings }.map(::tileOf)
|
|
||||||
|
|
||||||
LazyVerticalGrid(
|
LazyVerticalGrid(
|
||||||
columns = GridCells.Adaptive(minSize = 108.dp),
|
columns = GridCells.Adaptive(minSize = 108.dp),
|
||||||
@@ -303,16 +354,28 @@ private fun PartialBadge(size: androidx.compose.ui.unit.Dp, modifier: Modifier =
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Trial: where a tile says whether its app works. See [AppTileBody]. */
|
||||||
|
private enum class BadgeStyle { ON_ICON, OUTLINE }
|
||||||
|
private val BADGE_STYLE = BadgeStyle.ON_ICON
|
||||||
|
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun AppTile(tile: Tile) {
|
private fun AppTile(tile: Tile) {
|
||||||
|
val status = when {
|
||||||
|
tile.verified -> Color(0xFF43A047)
|
||||||
|
tile.partial -> Color(0xFFF9A825)
|
||||||
|
tile.broken -> Color(0xFFD32F2F)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
val shape = MaterialTheme.shapes.extraLarge
|
val shape = MaterialTheme.shapes.extraLarge
|
||||||
// A long press opens the app's menu (pin, close) - the same gesture everywhere apps are
|
// A long press opens the app's menu (pin, close) - the same gesture everywhere apps are
|
||||||
// shown, here and on the nav bar.
|
// shown, here and on the nav bar. GsIconBox marks it in the theme's style.
|
||||||
GsIconBox(
|
GsIconBox(
|
||||||
onClick = tile.onClick,
|
onClick = tile.onClick,
|
||||||
onLongClick = tile.onLongClick,
|
onLongClick = tile.onLongClick,
|
||||||
modifier = Modifier.height(120.dp),
|
modifier = Modifier.height(120.dp).then(
|
||||||
|
if (BADGE_STYLE == BadgeStyle.OUTLINE && status != null) Modifier.border(2.5.dp, status, shape) else Modifier,
|
||||||
|
),
|
||||||
colors = GsColors(MaterialTheme.colorScheme.surfaceVariant, MaterialTheme.colorScheme.onSurfaceVariant),
|
colors = GsColors(MaterialTheme.colorScheme.surfaceVariant, MaterialTheme.colorScheme.onSurfaceVariant),
|
||||||
shape = shape,
|
shape = shape,
|
||||||
) {
|
) {
|
||||||
@@ -322,29 +385,44 @@ private fun AppTile(tile: Tile) {
|
|||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.Center,
|
verticalArrangement = Arrangement.Center,
|
||||||
) {
|
) {
|
||||||
|
Box {
|
||||||
|
TileIcon(tile)
|
||||||
|
if (BADGE_STYLE == BadgeStyle.ON_ICON) {
|
||||||
|
// Pinned to the icon's lower corner, ringed in the tile's colour so it
|
||||||
|
// reads as sitting on top of the icon.
|
||||||
|
val ring = Modifier.align(Alignment.BottomEnd).offset(x = 6.dp, y = 4.dp)
|
||||||
|
.border(2.dp, MaterialTheme.colorScheme.surfaceVariant, CircleShape)
|
||||||
when {
|
when {
|
||||||
tile.icon != null -> Image(tile.icon, contentDescription = null, modifier = Modifier.size(52.dp))
|
tile.verified -> VerifiedBadge(22.dp, ring)
|
||||||
tile.glyph != null -> Icon(tile.glyph, contentDescription = null, modifier = Modifier.size(52.dp))
|
tile.broken -> BrokenBadge(22.dp, ring)
|
||||||
|
tile.partial -> PartialBadge(22.dp, ring)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
Text(
|
TileLabel(tile)
|
||||||
tile.label,
|
|
||||||
style = MaterialTheme.typography.labelLarge,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
maxLines = 2,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (tile.verified) {
|
|
||||||
VerifiedBadge(24.dp, Modifier.align(Alignment.TopEnd).padding(8.dp))
|
|
||||||
}
|
|
||||||
if (tile.broken) {
|
|
||||||
BrokenBadge(24.dp, Modifier.align(Alignment.TopEnd).padding(8.dp))
|
|
||||||
}
|
|
||||||
if (tile.partial) {
|
|
||||||
PartialBadge(24.dp, Modifier.align(Alignment.TopEnd).padding(8.dp))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TileIcon(tile: Tile) {
|
||||||
|
when {
|
||||||
|
tile.icon != null -> Image(tile.icon, contentDescription = null, modifier = Modifier.size(52.dp))
|
||||||
|
tile.glyph != null -> Icon(tile.glyph, contentDescription = null, modifier = Modifier.size(52.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TileLabel(tile: Tile, maxLines: Int = 2, modifier: Modifier = Modifier) {
|
||||||
|
Text(
|
||||||
|
tile.label,
|
||||||
|
modifier = modifier,
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
maxLines = maxLines,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -87,8 +87,8 @@ object SensorType {
|
|||||||
18 to "Tire pressure",
|
18 to "Tire pressure",
|
||||||
19 to "Accelerometer",
|
19 to "Accelerometer",
|
||||||
20 to "Gyroscope",
|
20 to "Gyroscope",
|
||||||
21 to "Gravity",
|
21 to "GPS satellites",
|
||||||
22 to "Wheel tick / distance",
|
22 to "Toll card",
|
||||||
)
|
)
|
||||||
|
|
||||||
fun name(id: Int): String = NAMES[id]?.let { "$it (#$id)" } ?: "Sensor #$id"
|
fun name(id: Int): String = NAMES[id]?.let { "$it (#$id)" } ?: "Sensor #$id"
|
||||||
|
|||||||
@@ -34,9 +34,11 @@ object CarSettings {
|
|||||||
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"
|
||||||
|
private const val KEY_APP_ORDER = "car_app_order"
|
||||||
private const val KEY_EXPERIMENTAL_FEATURES = "car_experimental_features"
|
private const val KEY_EXPERIMENTAL_FEATURES = "car_experimental_features"
|
||||||
private const val KEY_VOICE_ASSISTANT = "car_voice_assistant"
|
private const val KEY_VOICE_ASSISTANT = "car_voice_assistant"
|
||||||
private const val KEY_CAR_SENSORS = "car_sensors_enabled"
|
private const val KEY_CAR_SENSORS = "car_sensors_enabled"
|
||||||
|
private const val KEY_MEDIA_SEARCH = "car_media_search_enabled"
|
||||||
private const val KEY_SEEN_SAFETY_WARNING = "car_seen_safety_warning"
|
private const val KEY_SEEN_SAFETY_WARNING = "car_seen_safety_warning"
|
||||||
private const val KEY_UNITS = "car_units"
|
private const val KEY_UNITS = "car_units"
|
||||||
private const val KEY_WHATS_NEW = "car_whats_new_after_updates"
|
private const val KEY_WHATS_NEW = "car_whats_new_after_updates"
|
||||||
@@ -66,9 +68,11 @@ object CarSettings {
|
|||||||
/** Id of the [GsTheme] controls are drawn in; an unknown id falls back to the default. */
|
/** Id of the [GsTheme] controls are drawn in; an unknown id falls back to the default. */
|
||||||
val uiTheme: StateFlow<String> = uiThemeFlow
|
val uiTheme: StateFlow<String> = uiThemeFlow
|
||||||
private val pinnedFlow = MutableStateFlow<Set<String>>(emptySet())
|
private val pinnedFlow = MutableStateFlow<Set<String>>(emptySet())
|
||||||
|
private val appOrderFlow = MutableStateFlow<List<String>>(emptyList())
|
||||||
private val experimentalFeaturesFlow = MutableStateFlow(false)
|
private val experimentalFeaturesFlow = MutableStateFlow(false)
|
||||||
private val voiceAssistantFlow = MutableStateFlow(false)
|
private val voiceAssistantFlow = MutableStateFlow(false)
|
||||||
private val carSensorsFlow = MutableStateFlow(false)
|
private val carSensorsFlow = MutableStateFlow(false)
|
||||||
|
private val mediaSearchFlow = MutableStateFlow(false)
|
||||||
private val seenSafetyWarningFlow = MutableStateFlow(false)
|
private val seenSafetyWarningFlow = MutableStateFlow(false)
|
||||||
private val unitsFlow = MutableStateFlow(Units.AUTO)
|
private val unitsFlow = MutableStateFlow(Units.AUTO)
|
||||||
|
|
||||||
@@ -120,6 +124,9 @@ object CarSettings {
|
|||||||
/** Flattened component names of the apps long-pressed onto the nav bar as shortcuts. */
|
/** Flattened component names of the apps long-pressed onto the nav bar as shortcuts. */
|
||||||
val pinnedApps: StateFlow<Set<String>> = pinnedFlow
|
val pinnedApps: StateFlow<Set<String>> = pinnedFlow
|
||||||
|
|
||||||
|
/** The launcher's order once the driver has moved a tile; empty keeps the grouped default. */
|
||||||
|
val appOrder: StateFlow<List<String>> = appOrderFlow
|
||||||
|
|
||||||
/** Flattened component names of the map and media apps used last, brought back on connect. */
|
/** Flattened component names of the map and media apps used last, brought back on connect. */
|
||||||
val lastNav: StateFlow<String?> = lastNavFlow
|
val lastNav: StateFlow<String?> = lastNavFlow
|
||||||
val lastMedia: StateFlow<String?> = lastMediaFlow
|
val lastMedia: StateFlow<String?> = lastMediaFlow
|
||||||
@@ -166,6 +173,13 @@ object CarSettings {
|
|||||||
*/
|
*/
|
||||||
val carSensorsEnabled: StateFlow<Boolean> = carSensorsFlow
|
val carSensorsEnabled: StateFlow<Boolean> = carSensorsFlow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Searching inside a media app is experimental and off by default: many apps claim to take
|
||||||
|
* searches and then ignore them or play something else. The media screen's search button
|
||||||
|
* shows only once this and [experimentalFeaturesEnabled] are both on.
|
||||||
|
*/
|
||||||
|
val mediaSearchEnabled: StateFlow<Boolean> = mediaSearchFlow
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the car screen's own "drive safely" warning has already been shown and dismissed
|
* Whether the car screen's own "drive safely" warning has already been shown and dismissed
|
||||||
* once, from this same install. Separate from the phone side's own compat/safety dialogs -
|
* once, from this same install. Separate from the phone side's own compat/safety dialogs -
|
||||||
@@ -197,9 +211,12 @@ object CarSettings {
|
|||||||
}.getOrDefault(NightMode.AUTO)
|
}.getOrDefault(NightMode.AUTO)
|
||||||
pinnedFlow.value = AppSettings.getString(app, KEY_PINNED, "")
|
pinnedFlow.value = AppSettings.getString(app, KEY_PINNED, "")
|
||||||
.split(",").filter { it.isNotEmpty() }.toSet()
|
.split(",").filter { it.isNotEmpty() }.toSet()
|
||||||
|
appOrderFlow.value = AppSettings.getString(app, KEY_APP_ORDER, "")
|
||||||
|
.split(",").filter { it.isNotEmpty() }
|
||||||
experimentalFeaturesFlow.value = AppSettings.getString(app, KEY_EXPERIMENTAL_FEATURES, "false") == "true"
|
experimentalFeaturesFlow.value = AppSettings.getString(app, KEY_EXPERIMENTAL_FEATURES, "false") == "true"
|
||||||
voiceAssistantFlow.value = AppSettings.getString(app, KEY_VOICE_ASSISTANT, "false") == "true"
|
voiceAssistantFlow.value = AppSettings.getString(app, KEY_VOICE_ASSISTANT, "false") == "true"
|
||||||
carSensorsFlow.value = AppSettings.getString(app, KEY_CAR_SENSORS, "false") == "true"
|
carSensorsFlow.value = AppSettings.getString(app, KEY_CAR_SENSORS, "false") == "true"
|
||||||
|
mediaSearchFlow.value = AppSettings.getString(app, KEY_MEDIA_SEARCH, "false") == "true"
|
||||||
seenSafetyWarningFlow.value = AppSettings.getString(app, KEY_SEEN_SAFETY_WARNING, "false") == "true"
|
seenSafetyWarningFlow.value = AppSettings.getString(app, KEY_SEEN_SAFETY_WARNING, "false") == "true"
|
||||||
unitsFlow.value = runCatching {
|
unitsFlow.value = runCatching {
|
||||||
Units.valueOf(AppSettings.getString(app, KEY_UNITS, Units.AUTO.name))
|
Units.valueOf(AppSettings.getString(app, KEY_UNITS, Units.AUTO.name))
|
||||||
@@ -268,6 +285,11 @@ object CarSettings {
|
|||||||
AppSettings.putString(app, KEY_CAR_SENSORS, on.toString())
|
AppSettings.putString(app, KEY_CAR_SENSORS, on.toString())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setMediaSearchEnabled(on: Boolean) {
|
||||||
|
mediaSearchFlow.value = on
|
||||||
|
AppSettings.putString(app, KEY_MEDIA_SEARCH, on.toString())
|
||||||
|
}
|
||||||
|
|
||||||
fun setNightMode(mode: NightMode) {
|
fun setNightMode(mode: NightMode) {
|
||||||
nightFlow.value = mode
|
nightFlow.value = mode
|
||||||
AppSettings.putString(app, KEY_NIGHT, mode.name)
|
AppSettings.putString(app, KEY_NIGHT, mode.name)
|
||||||
@@ -286,4 +308,9 @@ object CarSettings {
|
|||||||
pinnedFlow.value = next
|
pinnedFlow.value = next
|
||||||
AppSettings.putString(app, KEY_PINNED, next.joinToString(","))
|
AppSettings.putString(app, KEY_PINNED, next.joinToString(","))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setAppOrder(ids: List<String>) {
|
||||||
|
appOrderFlow.value = ids
|
||||||
|
AppSettings.putString(app, KEY_APP_ORDER, ids.joinToString(","))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -60,6 +60,7 @@ fun CarSettingsScreen() {
|
|||||||
val experimentalFeatures by CarSettings.experimentalFeaturesEnabled.collectAsState()
|
val experimentalFeatures by CarSettings.experimentalFeaturesEnabled.collectAsState()
|
||||||
val voiceAssistant by CarSettings.voiceAssistantEnabled.collectAsState()
|
val voiceAssistant by CarSettings.voiceAssistantEnabled.collectAsState()
|
||||||
val carSensors by CarSettings.carSensorsEnabled.collectAsState()
|
val carSensors by CarSettings.carSensorsEnabled.collectAsState()
|
||||||
|
val mediaSearch by CarSettings.mediaSearchEnabled.collectAsState()
|
||||||
val display by CarEnvironment.display.collectAsState()
|
val display by CarEnvironment.display.collectAsState()
|
||||||
val vehicle by CarEnvironment.vehicle.collectAsState()
|
val vehicle by CarEnvironment.vehicle.collectAsState()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@@ -74,7 +75,7 @@ fun CarSettingsScreen() {
|
|||||||
.padding(horizontal = 24.dp, vertical = 16.dp),
|
.padding(horizontal = 24.dp, vertical = 16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(20.dp),
|
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||||
) {
|
) {
|
||||||
Text("Settings", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
|
ScreenHeader("Settings")
|
||||||
|
|
||||||
ChoiceRow(
|
ChoiceRow(
|
||||||
"Open on connect",
|
"Open on connect",
|
||||||
@@ -126,14 +127,13 @@ fun CarSettingsScreen() {
|
|||||||
|
|
||||||
ChoiceRow(
|
ChoiceRow(
|
||||||
"Media audio",
|
"Media audio",
|
||||||
listOf(false to "Phone output", true to "Through Gearslip"),
|
listOf(false to "Phone audio", true to "Car audio"),
|
||||||
pipeAudio,
|
pipeAudio,
|
||||||
) { CarSettings.setPipeAudio(it) }
|
) { CarSettings.setPipeAudio(it) }
|
||||||
Text(
|
Text(
|
||||||
"Through Gearslip sends the sound over the USB link. Android asks for consent the " +
|
"Car audio sends the phone's sound to the car over USB. Android asks for permission " +
|
||||||
"first time a media app plays in the car, and some apps refuse to be captured at " +
|
"when the car connects, and a few apps block it. Phone audio plays as the phone " +
|
||||||
"all. Phone output needs no permission: the app plays as it normally would, for " +
|
"normally would, for example over Bluetooth to the car.",
|
||||||
"example over Bluetooth to the car.",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
@@ -148,7 +148,7 @@ fun CarSettingsScreen() {
|
|||||||
experimentalFeatures,
|
experimentalFeatures,
|
||||||
) { CarSettings.setExperimentalFeaturesEnabled(it) }
|
) { CarSettings.setExperimentalFeaturesEnabled(it) }
|
||||||
if (experimentalFeatures) {
|
if (experimentalFeatures) {
|
||||||
ExperimentalFeaturesList(voiceAssistant, carSensors)
|
ExperimentalFeaturesList(voiceAssistant, carSensors, mediaSearch)
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
@@ -205,7 +205,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(voiceAssistant: Boolean, carSensors: Boolean) {
|
private fun ExperimentalFeaturesList(voiceAssistant: Boolean, carSensors: Boolean, mediaSearch: Boolean) {
|
||||||
Column(
|
Column(
|
||||||
Modifier.fillMaxWidth(),
|
Modifier.fillMaxWidth(),
|
||||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
@@ -228,6 +228,13 @@ private fun ExperimentalFeaturesList(voiceAssistant: Boolean, carSensors: Boolea
|
|||||||
"guess, not a fact, until you've watched one change for yourself.",
|
"guess, not a fact, until you've watched one change for yourself.",
|
||||||
checked = carSensors,
|
checked = carSensors,
|
||||||
) { CarSettings.setCarSensorsEnabled(it) }
|
) { CarSettings.setCarSensorsEnabled(it) }
|
||||||
|
|
||||||
|
ExperimentalFeatureRow(
|
||||||
|
title = "Media search",
|
||||||
|
description = "Adds a search button to the media screen. Some apps ignore the search " +
|
||||||
|
"or play something else.",
|
||||||
|
checked = mediaSearch,
|
||||||
|
) { CarSettings.setMediaSearchEnabled(it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -122,6 +122,10 @@ fun CarUi() {
|
|||||||
}
|
}
|
||||||
remember(appContext) { Prefetch.warm(appContext, force = true); CarServices.init(appContext) }
|
remember(appContext) { Prefetch.warm(appContext, force = true); CarServices.init(appContext) }
|
||||||
DisposableEffect(Unit) { onDispose { CarServices.shutdown() } }
|
DisposableEffect(Unit) { onDispose { CarServices.shutdown() } }
|
||||||
|
// Ask for the phone's sound as soon as the car connects, not only when a media app opens:
|
||||||
|
// a video in the web browser or a map's voice needs it too. Asks once, after the car offers
|
||||||
|
// somewhere to play it.
|
||||||
|
LaunchedEffect(Unit) { if (CarSettings.pipeAudio.value) app.seb3thehacker.gearslip.audio.CarAudio.request() }
|
||||||
// The map follows the light outside, whatever the app's own theme is set to.
|
// The map follows the light outside, whatever the app's own theme is set to.
|
||||||
LaunchedEffect(darkOutside) { CarServices.nav.pushConfiguration() }
|
LaunchedEffect(darkOutside) { CarServices.nav.pushConfiguration() }
|
||||||
|
|
||||||
@@ -262,13 +266,15 @@ fun CarUi() {
|
|||||||
@Composable
|
@Composable
|
||||||
private fun CarSafetyWizard(onDone: () -> Unit) {
|
private fun CarSafetyWizard(onDone: () -> Unit) {
|
||||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
// The card and its button share one width, capped so neither sprawls across a wide screen.
|
||||||
Column(
|
Column(
|
||||||
Modifier.fillMaxWidth().padding(24.dp),
|
Modifier.widthIn(max = 640.dp).fillMaxWidth().padding(24.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
Surface(
|
Surface(
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||||
shape = MaterialTheme.shapes.extraLarge,
|
shape = MaterialTheme.shapes.extraLarge,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
Column(Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
Column(Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
Text("Drive safely", style = ChromeType.headline, fontWeight = FontWeight.Bold)
|
Text("Drive safely", style = ChromeType.headline, fontWeight = FontWeight.Bold)
|
||||||
|
|||||||
@@ -30,12 +30,14 @@ 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.filled.ArrowBack
|
import androidx.compose.material.icons.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
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
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -69,6 +71,9 @@ import app.seb3thehacker.gearslip.media.MediaEntry
|
|||||||
import app.seb3thehacker.gearslip.media.NowPlaying
|
import app.seb3thehacker.gearslip.media.NowPlaying
|
||||||
import app.seb3thehacker.gearslip.media.QueueTrack
|
import app.seb3thehacker.gearslip.media.QueueTrack
|
||||||
import android.media.session.PlaybackState
|
import android.media.session.PlaybackState
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A media app on the car screen: what it is playing and its controls on the left, the tree it
|
* A media app on the car screen: what it is playing and its controls on the left, the tree it
|
||||||
@@ -84,7 +89,10 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) {
|
|||||||
val browse by media.browse.collectAsStateWithLifecycle()
|
val browse by media.browse.collectAsStateWithLifecycle()
|
||||||
val rejection by media.rejection.collectAsStateWithLifecycle()
|
val rejection by media.rejection.collectAsStateWithLifecycle()
|
||||||
val capture by CarAudio.capture.collectAsStateWithLifecycle()
|
val capture by CarAudio.capture.collectAsStateWithLifecycle()
|
||||||
|
val search by media.search.collectAsStateWithLifecycle()
|
||||||
var tab by remember { mutableStateOf(Tab.NOW_PLAYING) }
|
var tab by remember { mutableStateOf(Tab.NOW_PLAYING) }
|
||||||
|
var searching by remember { mutableStateOf(false) }
|
||||||
|
val searchAllowed = CarSettings.experimentalFeaturesEnabled.collectAsState().value && CarSettings.mediaSearchEnabled.collectAsState().value
|
||||||
|
|
||||||
// The connection outlives this screen: the home screen's player is the same one.
|
// The connection outlives this screen: the home screen's player is the same one.
|
||||||
LaunchedEffect(app) { CarServices.openMedia(app) }
|
LaunchedEffect(app) { CarServices.openMedia(app) }
|
||||||
@@ -111,6 +119,12 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
CarMedia.Rejection.NEEDS_APP_RUNNING -> Notice(
|
||||||
|
"${app.label} isn't running.",
|
||||||
|
"${app.label} plays through Gearslip's own player, but only while it's " +
|
||||||
|
"open on the phone. Open it once and it keeps playing here in the car.",
|
||||||
|
action = "Open ${app.label}" to { media.launchAndConnect(app) },
|
||||||
|
)
|
||||||
CarMedia.Rejection.REFUSED -> Notice(
|
CarMedia.Rejection.REFUSED -> Notice(
|
||||||
"${app.label} would not let Gearslip browse it.",
|
"${app.label} would not let Gearslip browse it.",
|
||||||
"Some media apps only accept Google's own host.",
|
"Some media apps only accept Google's own host.",
|
||||||
@@ -122,6 +136,29 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) {
|
|||||||
return@Surface
|
return@Surface
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (searching) {
|
||||||
|
SearchPanel(
|
||||||
|
appLabel = app.label,
|
||||||
|
media = media,
|
||||||
|
canPickForYou = now.canSearch,
|
||||||
|
onPlayed = {
|
||||||
|
searching = false
|
||||||
|
tab = Tab.NOW_PLAYING
|
||||||
|
},
|
||||||
|
onOpened = {
|
||||||
|
searching = false
|
||||||
|
tab = Tab.BROWSE
|
||||||
|
},
|
||||||
|
onPickForYou = { query ->
|
||||||
|
media.search(query)
|
||||||
|
searching = false
|
||||||
|
tab = Tab.NOW_PLAYING
|
||||||
|
},
|
||||||
|
onClose = { searching = false },
|
||||||
|
)
|
||||||
|
return@Surface
|
||||||
|
}
|
||||||
|
|
||||||
// Two columns, split the same way on every tab so nothing jumps when switching: the app
|
// Two columns, split the same way on every tab so nothing jumps when switching: the app
|
||||||
// and the art (or, beside a list, the compact player) on the left; the tabs across the
|
// and the art (or, beside a list, the compact player) on the left; the tabs across the
|
||||||
// top of the right column, over whatever the tab shows.
|
// top of the right column, over whatever the tab shows.
|
||||||
@@ -145,7 +182,18 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) {
|
|||||||
MediaTab("Browse", tab == Tab.BROWSE, Modifier.weight(1f)) { tab = Tab.BROWSE }
|
MediaTab("Browse", tab == Tab.BROWSE, Modifier.weight(1f)) { tab = Tab.BROWSE }
|
||||||
MediaTab("Up next", tab == Tab.QUEUE, Modifier.weight(1f)) { tab = Tab.QUEUE }
|
MediaTab("Up next", tab == Tab.QUEUE, Modifier.weight(1f)) { tab = Tab.QUEUE }
|
||||||
MediaTab("Lyrics", tab == Tab.LYRICS, Modifier.weight(1f)) { tab = Tab.LYRICS }
|
MediaTab("Lyrics", tab == Tab.LYRICS, Modifier.weight(1f)) { tab = Tab.LYRICS }
|
||||||
|
if (searchAllowed && (now.canSearch || media.canSearchLibrary)) {
|
||||||
|
GsIconButton(
|
||||||
|
Icons.Filled.Search,
|
||||||
|
"Search ${app.label}",
|
||||||
|
{ searching = true },
|
||||||
|
colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHigh, MaterialTheme.colorScheme.onSurface),
|
||||||
|
size = 44.dp,
|
||||||
|
shape = MaterialTheme.shapes.large,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
search?.let { SearchStatus(it, app.label, media::dismissSearch) }
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
Box(Modifier.weight(1f).fillMaxWidth()) {
|
Box(Modifier.weight(1f).fillMaxWidth()) {
|
||||||
when (tab) {
|
when (tab) {
|
||||||
@@ -186,6 +234,135 @@ private fun AppIdentity(app: MediaApp, onExit: () -> Unit, modifier: Modifier =
|
|||||||
|
|
||||||
private enum class Tab { NOW_PLAYING, BROWSE, QUEUE, LYRICS }
|
private enum class Tab { NOW_PLAYING, BROWSE, QUEUE, LYRICS }
|
||||||
|
|
||||||
|
// --- search ----------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Types a search for the app to play. The app picks the result and plays it, the way it would
|
||||||
|
* for "play X" from a voice assistant, so there's no list to choose from.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Search inside the media app. It asks the app's library for matches half a second after the
|
||||||
|
* driver stops typing and lists them, so the driver picks exactly what plays: a song plays, an
|
||||||
|
* album or artist opens in Browse. The search key hides the keyboard to show more results; the
|
||||||
|
* field brings it back. An app with no library search can still be told to play its own best
|
||||||
|
* match, which is all the panel could do before.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun SearchPanel(
|
||||||
|
appLabel: String,
|
||||||
|
media: CarMedia,
|
||||||
|
canPickForYou: Boolean,
|
||||||
|
onPlayed: () -> Unit,
|
||||||
|
onOpened: () -> Unit,
|
||||||
|
onPickForYou: (String) -> Unit,
|
||||||
|
onClose: () -> Unit,
|
||||||
|
) {
|
||||||
|
var query by remember { mutableStateOf("") }
|
||||||
|
var typing by remember { mutableStateOf(true) }
|
||||||
|
val results by media.results.collectAsStateWithLifecycle()
|
||||||
|
LaunchedEffect(query) {
|
||||||
|
delay(SEARCH_PAUSE_MS)
|
||||||
|
media.searchLibrary(query)
|
||||||
|
}
|
||||||
|
DisposableEffect(Unit) { onDispose { media.clearResults() } }
|
||||||
|
|
||||||
|
val trimmed = query.trim()
|
||||||
|
val current = results?.takeIf { it.query == trimmed }
|
||||||
|
val unsupported = current?.supported == false
|
||||||
|
|
||||||
|
Column(Modifier.fillMaxSize()) {
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
GsIconButton(Icons.Filled.ArrowBack, "Back", onClose, size = ICON_BUTTON_SIZE)
|
||||||
|
SearchField(
|
||||||
|
query,
|
||||||
|
"Search $appLabel",
|
||||||
|
Modifier.weight(1f).clickable { typing = true },
|
||||||
|
onClear = {
|
||||||
|
query = ""
|
||||||
|
typing = true
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Box(Modifier.weight(1f).fillMaxWidth().padding(horizontal = 16.dp)) {
|
||||||
|
val entries = current?.entries
|
||||||
|
when {
|
||||||
|
trimmed.isEmpty() -> SearchNote("Type a song, artist, album, or playlist.")
|
||||||
|
unsupported && canPickForYou ->
|
||||||
|
SearchNote("$appLabel can't list results. Press search, and $appLabel picks what to play.")
|
||||||
|
unsupported -> SearchNote("$appLabel can't search from the car.")
|
||||||
|
entries == null -> SearchNote("Searching $appLabel…")
|
||||||
|
entries.isEmpty() -> SearchNote("$appLabel found nothing for \"$trimmed\".")
|
||||||
|
else -> LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp), contentPadding = PaddingValues(bottom = 8.dp)) {
|
||||||
|
items(entries) { entry ->
|
||||||
|
EntryRow(entry, media) {
|
||||||
|
if (entry.browsable) {
|
||||||
|
media.select(entry)
|
||||||
|
onOpened()
|
||||||
|
} else {
|
||||||
|
media.playAll(entry)
|
||||||
|
onPlayed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typing) {
|
||||||
|
CarKeyboard(
|
||||||
|
text = query,
|
||||||
|
onTextChange = { query = it },
|
||||||
|
onSubmit = {
|
||||||
|
when {
|
||||||
|
trimmed.isEmpty() -> Unit
|
||||||
|
unsupported && canPickForYou -> onPickForYou(trimmed)
|
||||||
|
else -> typing = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDismiss = { if (trimmed.isEmpty()) onClose() else typing = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SearchNote(text: String) {
|
||||||
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
Text(
|
||||||
|
text,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How long typing has to pause before the library is searched. */
|
||||||
|
private const val SEARCH_PAUSE_MS = 1_000L
|
||||||
|
|
||||||
|
/** A line under the tabs while the app works on a search, and if it plays nothing for it. */
|
||||||
|
@Composable
|
||||||
|
private fun SearchStatus(search: CarMedia.Search, appLabel: String, onDismiss: () -> Unit) {
|
||||||
|
Surface(
|
||||||
|
onClick = onDismiss,
|
||||||
|
shape = MaterialTheme.shapes.large,
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
if (search.ignored) "$appLabel didn't play anything for \"${search.query}\"."
|
||||||
|
else "Searching $appLabel for \"${search.query}\"…",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** A bigger, easier-to-hit tab than a plain [TextButton] - this row is the only navigation on the screen. */
|
/** A bigger, easier-to-hit tab than a plain [TextButton] - this row is the only navigation on the screen. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun MediaTab(label: String, selected: Boolean, modifier: Modifier, onClick: () -> Unit) {
|
private fun MediaTab(label: String, selected: Boolean, modifier: Modifier, onClick: () -> Unit) {
|
||||||
@@ -484,13 +661,13 @@ private fun QueueRow(track: QueueTrack, media: CarMedia) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun EntryRow(entry: MediaEntry, media: CarMedia) {
|
private fun EntryRow(entry: MediaEntry, media: CarMedia, onClick: () -> Unit = { media.select(entry) }) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val icon by produceState(entry.iconBitmap, entry.iconUri) {
|
val icon by produceState(entry.iconBitmap, entry.iconUri) {
|
||||||
value = entry.iconBitmap ?: MediaArt.load(context, entry.iconUri)
|
value = entry.iconBitmap ?: MediaArt.load(context, entry.iconUri)
|
||||||
}
|
}
|
||||||
Surface(
|
Surface(
|
||||||
onClick = { media.select(entry) },
|
onClick = onClick,
|
||||||
shape = MaterialTheme.shapes.large,
|
shape = MaterialTheme.shapes.large,
|
||||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
|||||||
@@ -40,16 +40,34 @@ import app.seb3thehacker.gearslip.car.theme.GsButton
|
|||||||
import app.seb3thehacker.gearslip.car.theme.GsTone
|
import app.seb3thehacker.gearslip.car.theme.GsTone
|
||||||
import app.seb3thehacker.gearslip.host.CarAppConnection
|
import app.seb3thehacker.gearslip.host.CarAppConnection
|
||||||
import app.seb3thehacker.gearslip.media.CarMedia
|
import app.seb3thehacker.gearslip.media.CarMedia
|
||||||
|
import app.seb3thehacker.gearslip.CarFocus
|
||||||
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
|
import androidx.compose.ui.graphics.vector.PathParser
|
||||||
|
|
||||||
/** What a built-in app's id carries in a pin, so it can't collide with an installed app's component. */
|
/** What a built-in app's id carries in a pin, so it can't collide with an installed app's component. */
|
||||||
internal const val BUILT_IN_PREFIX = "gearslip:"
|
internal const val BUILT_IN_PREFIX = "gearslip:"
|
||||||
|
|
||||||
/** One of Gearslip's own apps: a screen, with nothing running behind it once you leave. */
|
/** Material's "directions car", drawn here because only the core icon set is bundled. */
|
||||||
|
private val CarGlyph: ImageVector = ImageVector.Builder("Car", 24.dp, 24.dp, 24f, 24f).addPath(
|
||||||
|
PathParser().parsePathString(
|
||||||
|
"M18.92,6.01C18.72,5.42 18.16,5 17.5,5h-11c-0.66,0 -1.21,0.42 -1.42,1.01L3,12v8c0,0.55 0.45,1 1,1h1" +
|
||||||
|
"c0.55,0 1,-0.45 1,-1v-1h12v1c0,0.55 0.45,1 1,1h1c0.55,0 1,-0.45 1,-1v-8l-2.08,-5.99z" +
|
||||||
|
"M6.5,16c-0.83,0 -1.5,-0.67 -1.5,-1.5S5.67,13 6.5,13s1.5,0.67 1.5,1.5S7.33,16 6.5,16z" +
|
||||||
|
"M17.5,16c-0.83,0 -1.5,-0.67 -1.5,-1.5s0.67,-1.5 1.5,-1.5 1.5,0.67 1.5,1.5 -0.67,1.5 -1.5,1.5z" +
|
||||||
|
"M5,11l1.5,-4.5h11L19,11L5,11z",
|
||||||
|
).toNodes(),
|
||||||
|
fill = SolidColor(Color.Black),
|
||||||
|
).build()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One of Gearslip's own apps: a screen, with nothing running behind it once you leave. [screen] is
|
||||||
|
* null for an action like Exit, which has no screen and so never shows as open.
|
||||||
|
*/
|
||||||
internal class BuiltInApp(
|
internal class BuiltInApp(
|
||||||
val id: String,
|
val id: String,
|
||||||
val label: String,
|
val label: String,
|
||||||
val glyph: ImageVector,
|
val glyph: ImageVector,
|
||||||
val screen: CarScreen,
|
val screen: CarScreen?,
|
||||||
val open: (CarNavigator) -> Unit,
|
val open: (CarNavigator) -> Unit,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -60,7 +78,10 @@ internal object BuiltInApps {
|
|||||||
val VehicleData = BuiltInApp("vehicle", "Vehicle data", Icons.Filled.Info, CarScreen.VehicleData) { it.vehicleData() }
|
val VehicleData = BuiltInApp("vehicle", "Vehicle data", Icons.Filled.Info, CarScreen.VehicleData) { it.vehicleData() }
|
||||||
val Settings = BuiltInApp("settings", "Settings", Icons.Filled.Settings, CarScreen.Settings) { it.settings() }
|
val Settings = BuiltInApp("settings", "Settings", Icons.Filled.Settings, CarScreen.Settings) { it.settings() }
|
||||||
|
|
||||||
val all = listOf(Web, ScreenSharing, Phone, VehicleData, Settings)
|
/** Hands the screen back to the car's own interface, like Android Auto's Exit. */
|
||||||
|
val Exit = BuiltInApp("exit", "Exit", CarGlyph, screen = null) { CarFocus.exitToCar() }
|
||||||
|
|
||||||
|
val all = listOf(Web, ScreenSharing, Phone, VehicleData, Settings, Exit)
|
||||||
val entries: List<Entry> = all.map { Entry(it.label, icon = null, builtIn = it) }
|
val entries: List<Entry> = all.map { Entry(it.label, icon = null, builtIn = it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,7 +131,7 @@ internal fun rememberRunningApps(): Map<String, RunningSlot> {
|
|||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
mediaApp?.component?.flattenToString()?.takeIf { mediaLive }?.let { put(it, RunningSlot.MEDIA) }
|
mediaApp?.component?.flattenToString()?.takeIf { mediaLive }?.let { put(it, RunningSlot.MEDIA) }
|
||||||
BuiltInApps.all.firstOrNull { it.screen == current }?.let { put(BUILT_IN_PREFIX + it.id, RunningSlot.SCREEN) }
|
BuiltInApps.all.firstOrNull { it.screen != null && it.screen == current }?.let { put(BUILT_IN_PREFIX + it.id, RunningSlot.SCREEN) }
|
||||||
(current as? CarScreen.Messages)?.let { put(MESSAGING_PREFIX + it.packageName, RunningSlot.SCREEN) }
|
(current as? CarScreen.Messages)?.let { put(MESSAGING_PREFIX + it.packageName, RunningSlot.SCREEN) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -148,8 +169,8 @@ internal fun closeApp(slot: RunningSlot, navigator: CarNavigator) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What a long press on an app offers - pin it to or unpin it from the nav bar, and close it if
|
* What a long press on an app offers - pin it to or unpin it from the nav bar, move it along the
|
||||||
* it's open - as a card in the middle of the screen with car-sized buttons, over a scrim that
|
* launcher, and close it if it's open - as a card in the middle of the screen with car-sized buttons, over a scrim that
|
||||||
* dismisses it. Drawn in the car UI's own layout rather than a system popup window, which the
|
* dismisses it. Drawn in the car UI's own layout rather than a system popup window, which the
|
||||||
* car's virtual display has no reason to host well.
|
* car's virtual display has no reason to host well.
|
||||||
*/
|
*/
|
||||||
@@ -218,6 +239,7 @@ internal fun AppMenu(navigator: CarNavigator, running: Map<String, RunningSlot>)
|
|||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (navigator.current == CarScreen.Apps) MoveButtons(id)
|
||||||
if (slot != null) {
|
if (slot != null) {
|
||||||
GsButton(
|
GsButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
@@ -237,3 +259,41 @@ internal fun AppMenu(navigator: CarNavigator, running: Map<String, RunningSlot>)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moves the app one place left or right on the launcher. The menu stays open so a tile can be
|
||||||
|
* walked several places, and the grid behind the scrim shows where it went.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun MoveButtons(id: String) {
|
||||||
|
val custom by CarSettings.appOrder.collectAsState()
|
||||||
|
val order = launcherOrder(rememberAllEntries(), rememberVehicleDataShown(), custom).mapNotNull { it.componentId }
|
||||||
|
val index = order.indexOf(id)
|
||||||
|
if (index < 0) return
|
||||||
|
fun move(by: Int) {
|
||||||
|
val next = order.toMutableList()
|
||||||
|
java.util.Collections.swap(next, index, index + by)
|
||||||
|
CarSettings.setAppOrder(next)
|
||||||
|
}
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
GsButton(
|
||||||
|
onClick = { move(-1) },
|
||||||
|
tone = GsTone.Tonal,
|
||||||
|
enabled = index > 0,
|
||||||
|
modifier = Modifier.weight(1f).height(56.dp),
|
||||||
|
) { Text("Move left", style = MaterialTheme.typography.titleMedium) }
|
||||||
|
GsButton(
|
||||||
|
onClick = { move(1) },
|
||||||
|
tone = GsTone.Tonal,
|
||||||
|
enabled = index < order.lastIndex,
|
||||||
|
modifier = Modifier.weight(1f).height(56.dp),
|
||||||
|
) { Text("Move right", style = MaterialTheme.typography.titleMedium) }
|
||||||
|
}
|
||||||
|
if (custom.isNotEmpty()) {
|
||||||
|
GsButton(
|
||||||
|
onClick = { CarSettings.setAppOrder(emptyList()) },
|
||||||
|
tone = GsTone.Neutral,
|
||||||
|
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||||
|
) { Text("Reset app order", style = MaterialTheme.typography.titleMedium) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -53,6 +53,8 @@ fun PhoneAppsScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Column(Modifier.fillMaxSize().padding(horizontal = 20.dp, vertical = 12.dp)) {
|
Column(Modifier.fillMaxSize().padding(horizontal = 20.dp, vertical = 12.dp)) {
|
||||||
|
ScreenHeader("Screen sharing")
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
if (!TouchRelayService.enabled) {
|
if (!TouchRelayService.enabled) {
|
||||||
TouchRelayWarning()
|
TouchRelayWarning()
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
|
|||||||
@@ -101,13 +101,7 @@ fun PhoneDialerScreen() {
|
|||||||
|
|
||||||
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||||
// The tabs share the title's row, so the list and keypad get the height back.
|
// The tabs share the title's row, so the list and keypad get the height back.
|
||||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
ScreenHeader("Phone") {
|
||||||
Text(
|
|
||||||
"Phone",
|
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
if (hasPermissions) {
|
if (hasPermissions) {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
DialerTab("Contacts", tab == TAB_CONTACTS, Modifier.width(140.dp)) { tab = TAB_CONTACTS }
|
DialerTab("Contacts", tab == TAB_CONTACTS, Modifier.width(140.dp)) { tab = TAB_CONTACTS }
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The text field the car keyboard is typing into, shared with the phone so a passenger can type
|
||||||
|
* on the phone's own keyboard instead. Whichever [CarKeyboard] is on screen registers here; the
|
||||||
|
* phone app shows the same text while one is open, and its edits go back through the car field's
|
||||||
|
* own callbacks, so the car screen can't tell which keyboard typed.
|
||||||
|
*/
|
||||||
|
object PhoneTyping {
|
||||||
|
|
||||||
|
class Field internal constructor(
|
||||||
|
val id: Int,
|
||||||
|
val text: String,
|
||||||
|
internal val onTextChange: (String) -> Unit,
|
||||||
|
internal val onSubmit: () -> Unit,
|
||||||
|
internal val onDismiss: (() -> Unit)?,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val fieldFlow = MutableStateFlow<Field?>(null)
|
||||||
|
|
||||||
|
/** The car's open field, or null when no car keyboard is showing. */
|
||||||
|
val field: StateFlow<Field?> = fieldFlow.asStateFlow()
|
||||||
|
|
||||||
|
private var nextId = 0
|
||||||
|
|
||||||
|
internal fun open(text: String, onTextChange: (String) -> Unit, onSubmit: () -> Unit, onDismiss: (() -> Unit)?): Int {
|
||||||
|
val id = ++nextId
|
||||||
|
fieldFlow.value = Field(id, text, onTextChange, onSubmit, onDismiss)
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The car field's text changed, from either keyboard. */
|
||||||
|
internal fun carText(id: Int, text: String) {
|
||||||
|
val f = fieldFlow.value ?: return
|
||||||
|
if (f.id == id && f.text != text) fieldFlow.value = Field(id, text, f.onTextChange, f.onSubmit, f.onDismiss)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun close(id: Int) {
|
||||||
|
if (fieldFlow.value?.id == id) fieldFlow.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** From the phone: typed text goes to the car field as if typed there. */
|
||||||
|
fun type(text: String) {
|
||||||
|
fieldFlow.value?.onTextChange?.invoke(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun submit() {
|
||||||
|
fieldFlow.value?.onSubmit?.invoke()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Closes the car keyboard where it can be closed; elsewhere only the phone's field goes. */
|
||||||
|
fun dismiss() {
|
||||||
|
val f = fieldFlow.value ?: return
|
||||||
|
f.onDismiss?.invoke() ?: close(f.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,15 @@
|
|||||||
package app.seb3thehacker.gearslip.car
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import app.seb3thehacker.gearslip.CertProvider
|
||||||
import app.seb3thehacker.gearslip.GearslipLog
|
import app.seb3thehacker.gearslip.GearslipLog
|
||||||
import app.seb3thehacker.gearslip.mirror.PhoneApps
|
import app.seb3thehacker.gearslip.mirror.PhoneApps
|
||||||
import java.util.concurrent.Executors
|
import java.util.concurrent.Executors
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads what the car screens show before anyone opens them: the car and media apps with their
|
* Loads what the car screens show before anyone opens them: the car and media apps with their
|
||||||
* icons, the phone's launcher apps, the weather and the calendar. Each screen paints from these
|
* icons, the phone's launcher apps, the weather and the calendar. Also unlocks the certificate,
|
||||||
|
* so a head unit's first message is answered at once. Each screen paints from these
|
||||||
* results at once and refreshes behind them, so nothing waits on the package manager, the
|
* results at once and refreshes behind them, so nothing waits on the package manager, the
|
||||||
* network or the calendar provider.
|
* network or the calendar provider.
|
||||||
*
|
*
|
||||||
@@ -28,6 +30,7 @@ object Prefetch {
|
|||||||
val app = context.applicationContext
|
val app = context.applicationContext
|
||||||
worker.execute {
|
worker.execute {
|
||||||
val start = System.currentTimeMillis()
|
val start = System.currentTimeMillis()
|
||||||
|
step("certificate") { CertProvider.warm(app) }
|
||||||
step("launcher apps") { warmLauncherApps(app) }
|
step("launcher apps") { warmLauncherApps(app) }
|
||||||
step("phone apps") { PhoneApps.warm(app) }
|
step("phone apps") { PhoneApps.warm(app) }
|
||||||
step("weather and calendar") { warmDashboard(app) }
|
step("weather and calendar") { warmDashboard(app) }
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.RowScope
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import app.seb3thehacker.gearslip.car.theme.GsIconButton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The top of every screen reached from Home: a back button in the current theme, the title, and
|
||||||
|
* room on the right for the screen's own controls. A car touchscreen has no hardware back button,
|
||||||
|
* so this is the way out of a screen.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ScreenHeader(title: String, modifier: Modifier = Modifier, trailing: @Composable RowScope.() -> Unit = {}) {
|
||||||
|
val navigator = LocalCarNavigator.current
|
||||||
|
Row(
|
||||||
|
modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
GsIconButton(Icons.AutoMirrored.Filled.ArrowBack, "Back", navigator::back)
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
trailing()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ package app.seb3thehacker.gearslip.car
|
|||||||
|
|
||||||
import app.seb3thehacker.gearslip.car.theme.*
|
import app.seb3thehacker.gearslip.car.theme.*
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
@@ -246,7 +248,15 @@ private fun SearchContent(template: SearchTemplate, modifier: Modifier) {
|
|||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
template.headerAction?.let { ActionButton(it) }
|
template.headerAction?.let { ActionButton(it) }
|
||||||
SearchField(query, template.searchHint.orEmpty(), Modifier.weight(1f))
|
SearchField(
|
||||||
|
query,
|
||||||
|
template.searchHint.orEmpty(),
|
||||||
|
Modifier.weight(1f),
|
||||||
|
onClear = {
|
||||||
|
query = ""
|
||||||
|
delegate.textChanged("")
|
||||||
|
},
|
||||||
|
)
|
||||||
ActionRow(template.actionStrip?.actions.orEmpty())
|
ActionRow(template.actionStrip?.actions.orEmpty())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,22 +276,59 @@ private fun SearchContent(template: SearchTemplate, modifier: Modifier) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The field the on-screen keyboard types into. It is the same height and shape as the round
|
||||||
|
* buttons beside it, so the header reads as one row of controls rather than a thin strip squeezed
|
||||||
|
* between two big buttons, and it carries a magnifier so it reads as search at a glance.
|
||||||
|
* [onClear] adds a clear button once something is typed; [searchIcon] is off for sign-in fields.
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun SearchField(query: String, hint: String, modifier: Modifier = Modifier) {
|
internal fun SearchField(
|
||||||
|
query: String,
|
||||||
|
hint: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
searchIcon: Boolean = true,
|
||||||
|
onClear: (() -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
val muted = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
Surface(
|
Surface(
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||||
shape = RoundedCornerShape(10.dp),
|
shape = androidx.compose.foundation.shape.CircleShape,
|
||||||
modifier = modifier.fillMaxWidth(),
|
modifier = modifier.fillMaxWidth().height(ICON_BUTTON_SIZE),
|
||||||
) {
|
) {
|
||||||
|
Row(
|
||||||
|
Modifier.padding(start = if (searchIcon) 16.dp else 22.dp, end = 6.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
if (searchIcon) {
|
||||||
|
androidx.compose.material3.Icon(
|
||||||
|
androidx.compose.material.icons.Icons.Filled.Search,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = muted,
|
||||||
|
modifier = Modifier.size(26.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
}
|
||||||
Text(
|
Text(
|
||||||
query.ifEmpty { hint.ifEmpty { "Search" } },
|
query.ifEmpty { hint.ifEmpty { "Search" } },
|
||||||
style = ChromeType.body,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
color = if (query.isEmpty()) MaterialTheme.colorScheme.onSurfaceVariant
|
color = if (query.isEmpty()) muted else MaterialTheme.colorScheme.onSurface,
|
||||||
else MaterialTheme.colorScheme.onSurface,
|
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
|
if (onClear != null && query.isNotEmpty()) {
|
||||||
|
GsIconButton(
|
||||||
|
androidx.compose.material.icons.Icons.Filled.Close,
|
||||||
|
"Clear",
|
||||||
|
onClear,
|
||||||
|
colors = GsColors(androidx.compose.ui.graphics.Color.Transparent, muted),
|
||||||
|
size = 42.dp,
|
||||||
|
iconSize = 22.dp,
|
||||||
|
shape = androidx.compose.foundation.shape.CircleShape,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -344,7 +391,7 @@ private fun InputSignIn(method: InputSignInMethod, modifier: Modifier) {
|
|||||||
val masked = method.inputType == InputSignInMethod.INPUT_TYPE_PASSWORD
|
val masked = method.inputType == InputSignInMethod.INPUT_TYPE_PASSWORD
|
||||||
|
|
||||||
Column(modifier) {
|
Column(modifier) {
|
||||||
SearchField(if (masked) "•".repeat(value.length) else value, method.hint.text())
|
SearchField(if (masked) "•".repeat(value.length) else value, method.hint.text(), searchIcon = false)
|
||||||
val error = method.errorMessage.text()
|
val error = method.errorMessage.text()
|
||||||
if (error.isNotEmpty()) {
|
if (error.isNotEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
@@ -680,5 +680,5 @@ private fun androidx.compose.ui.graphics.ImageBitmap.isPlainWhite(): Boolean {
|
|||||||
return seen > 0
|
return seen > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
private val ICON_BUTTON_SIZE = 54.dp
|
internal val ICON_BUTTON_SIZE = 54.dp
|
||||||
private val ICON_BUTTON_GLYPH = 30.dp
|
private val ICON_BUTTON_GLYPH = 30.dp
|
||||||
@@ -28,7 +28,7 @@ fun VehicleDataScreen() {
|
|||||||
val readings by CarSensors.readings.collectAsState()
|
val readings by CarSensors.readings.collectAsState()
|
||||||
|
|
||||||
Column(Modifier.fillMaxSize().padding(horizontal = 24.dp, vertical = 16.dp)) {
|
Column(Modifier.fillMaxSize().padding(horizontal = 24.dp, vertical = 16.dp)) {
|
||||||
Text("Vehicle data", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
|
ScreenHeader("Vehicle data")
|
||||||
Text(
|
Text(
|
||||||
"Raw sensor channel from the connected head unit. Field meaning is a best guess - " +
|
"Raw sensor channel from the connected head unit. Field meaning is a best guess - " +
|
||||||
"watch a row change while you do the thing (brake, drive, headlights) to confirm it.",
|
"watch a row change while you do the thing (brake, drive, headlights) to confirm it.",
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ object VoiceReply {
|
|||||||
LISTENING,
|
LISTENING,
|
||||||
/**
|
/**
|
||||||
* Reading the reply back, then counting down to [State.until] while listening for
|
* Reading the reply back, then counting down to [State.until] while listening for
|
||||||
* "cancel" or "change it". [State.until] is 0 while the countdown is held.
|
* "cancel" or "change it". The countdown runs out whatever it hears.
|
||||||
*/
|
*/
|
||||||
CONFIRMING,
|
CONFIRMING,
|
||||||
SENT,
|
SENT,
|
||||||
@@ -92,24 +92,37 @@ object VoiceReply {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** "Do you want to reply?" A yes goes on to the reply; anything else ends quietly. */
|
/**
|
||||||
|
* "Do you want to reply?" A yes goes on to the reply; anything else ends quietly. The bar runs
|
||||||
|
* while it listens, so the card never looks stuck, and holds once the driver starts talking.
|
||||||
|
*/
|
||||||
private fun offer(id: Int) {
|
private fun offer(id: Int) {
|
||||||
_state.value = _state.value.copy(phase = Phase.OFFERING, heard = "")
|
_state.value = _state.value.copy(phase = Phase.OFFERING, heard = "", until = 0L, span = 0L, held = 0f)
|
||||||
CarAssistant.say("Do you want to reply?") {
|
CarAssistant.say("Do you want to reply?") {
|
||||||
if (id != run) return@say
|
if (id != run) return@say
|
||||||
|
val until = SystemClock.uptimeMillis() + OFFER_MS
|
||||||
|
_state.value = _state.value.copy(until = until, span = OFFER_MS)
|
||||||
|
// The recognizer gives up on its own; this only covers one that doesn't.
|
||||||
|
main.postAtTime({ if (id == run && _state.value.until != 0L) { CarAssistant.stopListening(); close() } }, until + OFFER_GRACE_MS)
|
||||||
CarAssistant.dictate(
|
CarAssistant.dictate(
|
||||||
onPartial = { text -> if (id == run) _state.value = _state.value.copy(heard = text) },
|
onPartial = { text -> if (id == run) _state.value = _state.value.copy(heard = text) },
|
||||||
|
onSpeaking = {
|
||||||
|
if (id != run) return@dictate
|
||||||
|
val s = _state.value
|
||||||
|
val left = ((s.until - SystemClock.uptimeMillis()).toFloat() / s.span).coerceIn(0.05f, 1f)
|
||||||
|
_state.value = s.copy(until = 0L, span = 0L, held = left)
|
||||||
|
},
|
||||||
onResult = { text ->
|
onResult = { text ->
|
||||||
if (id != run) return@dictate
|
if (id != run) return@dictate
|
||||||
if (isYes(text)) {
|
if (isYes(text)) {
|
||||||
_state.value = _state.value.copy(phase = Phase.ASKING, heard = "")
|
_state.value = _state.value.copy(phase = Phase.ASKING, heard = "", until = 0L, span = 0L, held = 0f)
|
||||||
CarAssistant.say("Go ahead.") { if (id == run) listen(id) }
|
CarAssistant.say("Go ahead.") { if (id == run) listen(id) }
|
||||||
} else {
|
} else {
|
||||||
finish()
|
close()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// Heard nothing: leave the Reply button up for a moment, the same as after reading.
|
// No answer counts as no: the message has been heard, so the card goes.
|
||||||
onError = { if (id == run) idleAfterRead(id) },
|
onError = { if (id == run) close() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -180,43 +193,27 @@ object VoiceReply {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Sends in [ms]. */
|
||||||
* Sends in [ms]. After a hold the bar carries on from where it stopped instead of jumping back
|
|
||||||
* to full: the span is stretched so the bar still runs out exactly at [ms].
|
|
||||||
*/
|
|
||||||
private fun startCountdown(id: Int, ms: Long) {
|
private fun startCountdown(id: Int, ms: Long) {
|
||||||
val sendAt = SystemClock.uptimeMillis() + ms
|
val sendAt = SystemClock.uptimeMillis() + ms
|
||||||
val held = _state.value.held
|
_state.value = _state.value.copy(until = sendAt, span = ms, held = 0f)
|
||||||
val span = if (held > 0f) (ms / held).toLong() else ms
|
|
||||||
_state.value = _state.value.copy(until = sendAt, span = span, held = 0f)
|
|
||||||
main.removeCallbacksAndMessages(SEND_TOKEN)
|
main.removeCallbacksAndMessages(SEND_TOKEN)
|
||||||
main.postAtTime({ if (id == run) send() }, SEND_TOKEN, sendAt)
|
main.postAtTime({ if (id == run) send() }, SEND_TOKEN, sendAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Listens through the countdown for "cancel", "change it" or "send". The countdown holds from
|
* Listens through the countdown for "cancel", "change it" or "send". The countdown never
|
||||||
* the first word, so a reply can't go out while the driver is still saying "cancel".
|
* pauses: in a noisy car it held for road noise and the radio, and the reply never went.
|
||||||
*/
|
*/
|
||||||
private fun listenForCommand(id: Int) {
|
private fun listenForCommand(id: Int) {
|
||||||
CarAssistant.dictate(
|
CarAssistant.dictate(
|
||||||
onPartial = {},
|
onPartial = {},
|
||||||
cue = false,
|
cue = false,
|
||||||
onSpeaking = {
|
|
||||||
if (id != run) return@dictate
|
|
||||||
main.removeCallbacksAndMessages(SEND_TOKEN)
|
|
||||||
val s = _state.value
|
|
||||||
val left = if (s.until > 0L && s.span > 0L) {
|
|
||||||
((s.until - SystemClock.uptimeMillis()).toFloat() / s.span).coerceIn(0.05f, 1f)
|
|
||||||
} else {
|
|
||||||
s.held
|
|
||||||
}
|
|
||||||
_state.value = s.copy(until = 0L, span = 0L, held = left)
|
|
||||||
},
|
|
||||||
onResult = { text ->
|
onResult = { text ->
|
||||||
if (id != run) return@dictate
|
if (id != run) return@dictate
|
||||||
when (command(text)) {
|
when (command(text)) {
|
||||||
Command.CANCEL -> {
|
Command.CANCEL -> {
|
||||||
finish()
|
close()
|
||||||
CarAssistant.say("Not sent.") {}
|
CarAssistant.say("Not sent.") {}
|
||||||
}
|
}
|
||||||
Command.CHANGE -> {
|
Command.CHANGE -> {
|
||||||
@@ -225,11 +222,17 @@ object VoiceReply {
|
|||||||
ask(id)
|
ask(id)
|
||||||
}
|
}
|
||||||
Command.SEND -> send()
|
Command.SEND -> send()
|
||||||
// Talk that wasn't meant for us, a passenger or the radio: carry on counting.
|
// Talk that wasn't meant for us, a passenger or the radio: keep listening, so a
|
||||||
null -> startCountdown(id, RESUME_MS)
|
// "cancel" after it still lands before the countdown runs out.
|
||||||
|
null -> listenForCommand(id)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError = { if (id == run && _state.value.until == 0L) startCountdown(id, RESUME_MS) },
|
// Silence ends the recognizer before the countdown does: listen again until it sends.
|
||||||
|
onError = {
|
||||||
|
if (id != run) return@dictate
|
||||||
|
// A short gap, so a recognizer that fails at once can't spin.
|
||||||
|
main.postDelayed({ if (id == run) listenForCommand(id) }, RELISTEN_MS)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,11 +298,20 @@ object VoiceReply {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Stops wherever it is. Nothing is sent. */
|
/** Stops wherever it is. Nothing is sent, and the message card closes. */
|
||||||
fun cancel() {
|
fun cancel() {
|
||||||
if (!active) return
|
if (!active) return
|
||||||
CarAssistant.stopListening()
|
CarAssistant.stopListening()
|
||||||
CarAssistant.stopSpeaking()
|
CarAssistant.stopSpeaking()
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ends without sending and takes the message card with it. Without this the card's own popup
|
||||||
|
* came back with a fresh bar, as if the message had just arrived.
|
||||||
|
*/
|
||||||
|
private fun close() {
|
||||||
|
_state.value.target?.let { CarNotifications.dismissPopup(it.id) }
|
||||||
finish()
|
finish()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -362,6 +374,8 @@ object VoiceReply {
|
|||||||
|
|
||||||
private val SEND_TOKEN = Any()
|
private val SEND_TOKEN = Any()
|
||||||
private const val ECHO_MS = 300L
|
private const val ECHO_MS = 300L
|
||||||
/** After talk that wasn't a command, the reply waits this long again before it goes. */
|
/** How long "Do you want to reply?" waits for an answer: the start beep, then the engine's silence limit. */
|
||||||
private const val RESUME_MS = 3_000L
|
private const val OFFER_MS = 6_500L
|
||||||
|
private const val OFFER_GRACE_MS = 1_500L
|
||||||
|
private const val RELISTEN_MS = 400L
|
||||||
}
|
}
|
||||||
@@ -84,11 +84,13 @@ object Weather {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
val data = runCatching { fetch(context, location) }
|
val data = runCatching { fetch(context, location) }
|
||||||
.onFailure { GearslipLog.w("weather fetch failed: ${it.message}") }
|
// Only the kind of error: some messages carry the request URL, and that holds the location.
|
||||||
|
.onFailure { GearslipLog.w("weather fetch failed: ${it.javaClass.simpleName}") }
|
||||||
.getOrNull()
|
.getOrNull()
|
||||||
if (data != null) {
|
if (data != null) {
|
||||||
flow.value = WeatherState.Ready(data)
|
flow.value = WeatherState.Ready(data)
|
||||||
GearslipLog.i("weather: ${data.place} ${data.temp}${data.tempUnit} ${describe(data.code)}")
|
// No place name: logs get shared in bug reports.
|
||||||
|
GearslipLog.i("weather: loaded")
|
||||||
}
|
}
|
||||||
else if (flow.value !is WeatherState.Ready) flow.value = WeatherState.Unavailable("Could not reach the weather service.")
|
else if (flow.value !is WeatherState.Ready) flow.value = WeatherState.Unavailable("Could not reach the weather service.")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ fun WeatherScreen() {
|
|||||||
Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = 24.dp, vertical = 16.dp),
|
Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = 24.dp, vertical = 16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(20.dp),
|
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||||
) {
|
) {
|
||||||
|
ScreenHeader("Weather")
|
||||||
when (val s = state) {
|
when (val s = state) {
|
||||||
WeatherState.Loading -> Text("Getting the weather…", style = MaterialTheme.typography.titleMedium)
|
WeatherState.Loading -> Text("Getting the weather…", style = MaterialTheme.typography.titleMedium)
|
||||||
is WeatherState.Unavailable -> Text(
|
is WeatherState.Unavailable -> Text(
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import android.webkit.WebView
|
|||||||
import android.webkit.WebViewClient
|
import android.webkit.WebViewClient
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -53,6 +55,8 @@ fun WebApp(content: String) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Column(Modifier.fillMaxSize()) {
|
Column(Modifier.fillMaxSize()) {
|
||||||
|
// Back steps through the page's history first (the interceptor above), then leaves.
|
||||||
|
ScreenHeader("Web", Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
|
||||||
AndroidView(
|
AndroidView(
|
||||||
modifier = Modifier.weight(1f).fillMaxSize(),
|
modifier = Modifier.weight(1f).fillMaxSize(),
|
||||||
factory = { context ->
|
factory = { context ->
|
||||||
|
|||||||
@@ -315,6 +315,7 @@ fun GsIconBox(
|
|||||||
shape: Shape = gsShape(),
|
shape: Shape = gsShape(),
|
||||||
/** Held down, as a keycap theme draws it - for a toggle or the current screen's nav button. */
|
/** Held down, as a keycap theme draws it - for a toggle or the current screen's nav button. */
|
||||||
latched: Boolean = false,
|
latched: Boolean = false,
|
||||||
|
/** Also marks the control with the theme's hold marker, so the hold can be found. */
|
||||||
onLongClick: (() -> Unit)? = null,
|
onLongClick: (() -> Unit)? = null,
|
||||||
content: @Composable BoxScope.() -> Unit,
|
content: @Composable BoxScope.() -> Unit,
|
||||||
) {
|
) {
|
||||||
@@ -327,6 +328,7 @@ fun GsIconBox(
|
|||||||
.gsSurface(c.container, shape, c.border, pressed, latched),
|
.gsSurface(c.container, shape, c.border, pressed, latched),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
|
if (onLongClick != null && enabled) GsHoldMarker(gsLook().holdMarker, c.container, shape, source)
|
||||||
CompositionLocalProvider(LocalContentColor provides c.content) { content() }
|
CompositionLocalProvider(LocalContentColor provides c.content) { content() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
package app.seb3thehacker.gearslip.car.theme
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.Animatable
|
||||||
|
import androidx.compose.animation.core.LinearEasing
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.interaction.InteractionSource
|
||||||
|
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||||
|
import androidx.compose.foundation.layout.BoxScope
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.Path
|
||||||
|
import androidx.compose.ui.graphics.Shape
|
||||||
|
import androidx.compose.foundation.shape.CornerBasedShape
|
||||||
|
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||||
|
import androidx.compose.ui.graphics.drawscope.scale
|
||||||
|
import androidx.compose.ui.graphics.drawscope.translate
|
||||||
|
import androidx.compose.ui.graphics.lerp
|
||||||
|
import androidx.compose.ui.platform.LocalViewConfiguration
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlin.math.PI
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.min
|
||||||
|
import kotlin.math.sin
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How a control says it also does something when held. [GsIconBox] draws it on its own for any
|
||||||
|
* control given an onLongClick, so callers never place it by hand.
|
||||||
|
*/
|
||||||
|
enum class HoldMarkerStyle {
|
||||||
|
/** The top layer peels back from the top-right corner, further while held. Needs depth. */
|
||||||
|
PEEL,
|
||||||
|
|
||||||
|
/** A plain dot in the top-right corner. It doesn't react to a hold. */
|
||||||
|
DOT,
|
||||||
|
|
||||||
|
/** Nothing: the hold stays undiscovered. For themes that mark it some other way. */
|
||||||
|
NONE,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The hold marker for one control, drawn under its content. [container] is the control's own
|
||||||
|
* colour, so the peel's flap matches whatever it sits on. Round and pill-shaped controls get the
|
||||||
|
* dot whatever the theme says: a circle has no corner to peel.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun BoxScope.GsHoldMarker(style: HoldMarkerStyle, container: Color, shape: Shape, press: InteractionSource) {
|
||||||
|
if (style == HoldMarkerStyle.NONE) return
|
||||||
|
val pressed by press.collectIsPressedAsState()
|
||||||
|
val timeout = LocalViewConfiguration.current.longPressTimeoutMillis.toInt()
|
||||||
|
val lift = remember { Animatable(0f) }
|
||||||
|
if (style == HoldMarkerStyle.PEEL) {
|
||||||
|
LaunchedEffect(pressed) {
|
||||||
|
if (pressed) lift.animateTo(1f, tween(timeout, easing = LinearEasing)) else lift.animateTo(0f, tween(220))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val ink = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
val accent = MaterialTheme.colorScheme.primaryContainer
|
||||||
|
val onAccent = MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
Canvas(Modifier.matchParentSize()) {
|
||||||
|
val side = min(size.width, size.height)
|
||||||
|
val r = (shape as? CornerBasedShape)?.topEnd?.toPx(size, this) ?: 0f
|
||||||
|
val round = r >= side * 0.4f
|
||||||
|
if (style == HoldMarkerStyle.DOT || round) {
|
||||||
|
drawHoldDot(r, side, ink)
|
||||||
|
} else {
|
||||||
|
// Drawn for the bottom-right corner, then flipped up to the top-right.
|
||||||
|
scale(1f, -1f, pivot = center) {
|
||||||
|
drawPeel(r, side, container, accent, onAccent, lift.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Depth of a corner's curve along its diagonal: a fold shallower than this only shaves the curve. */
|
||||||
|
private fun curveDepth(r: Float) = r * (2f - 1.41421f)
|
||||||
|
|
||||||
|
private fun DrawScope.drawHoldDot(r: Float, side: Float, ink: Color) {
|
||||||
|
val dot = (side * 0.06f).coerceIn(5.dp.toPx(), 7.dp.toPx())
|
||||||
|
// Tucked into the corner, clear of its curve.
|
||||||
|
val inset = r * (1f - 0.7071f) + (side * 0.05f).coerceIn(4.dp.toPx(), 6.dp.toPx()) + dot / 2
|
||||||
|
drawCircle(ink.copy(alpha = 0.6f), radius = dot / 2, center = Offset(size.width - inset, inset))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The part of a rounded rectangle beyond the fold line x + y = c, as a closed outline. */
|
||||||
|
private fun cornerRegion(w: Float, h: Float, r: Float, c: Float): List<Offset> {
|
||||||
|
// Walk the bottom-right boundary: down the right edge, round the corner, along the bottom.
|
||||||
|
val reach = (h + w - c) + 1f
|
||||||
|
val edge = ArrayList<Offset>()
|
||||||
|
edge += Offset(w, h - maxOf(r, reach))
|
||||||
|
if (r > 0f) {
|
||||||
|
for (i in 0..24) {
|
||||||
|
val a = (i / 24f) * (PI / 2).toFloat()
|
||||||
|
edge += Offset(w - r + r * cos(a), h - r + r * sin(a))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
edge += Offset(w, h)
|
||||||
|
}
|
||||||
|
edge += Offset(w - maxOf(r, reach), h)
|
||||||
|
// Keep what lies beyond the fold, adding the exact crossing points.
|
||||||
|
val out = ArrayList<Offset>()
|
||||||
|
for (i in 0 until edge.size - 1) {
|
||||||
|
val p = edge[i]
|
||||||
|
val q = edge[i + 1]
|
||||||
|
val ps = p.x + p.y - c
|
||||||
|
val qs = q.x + q.y - c
|
||||||
|
if (ps >= 0) out += p
|
||||||
|
if ((ps >= 0) != (qs >= 0)) {
|
||||||
|
val t = ps / (ps - qs)
|
||||||
|
out += Offset(p.x + (q.x - p.x) * t, p.y + (q.y - p.y) * t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val last = edge.last()
|
||||||
|
if (last.x + last.y - c >= 0) out += last
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun pathOf(points: List<Offset>): Path = Path().apply {
|
||||||
|
if (points.isEmpty()) return@apply
|
||||||
|
moveTo(points[0].x, points[0].y)
|
||||||
|
for (p in points.drop(1)) lineTo(p.x, p.y)
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The top layer peeling back from the corner, showing a second layer underneath with the same
|
||||||
|
* rounded corner, so the control's outline never changes. Sized from the control: a launcher
|
||||||
|
* tile folds about 27dp at rest and 64dp held, a nav bar button far less.
|
||||||
|
*/
|
||||||
|
private fun DrawScope.drawPeel(r: Float, side: Float, face: Color, accent: Color, onAccent: Color, lift: Float) {
|
||||||
|
val w = size.width
|
||||||
|
val h = size.height
|
||||||
|
val rest = curveDepth(r) + (side * 0.1f).coerceIn(4.dp.toPx(), 11.dp.toPx())
|
||||||
|
val held = rest + min(37.dp.toPx(), side * 0.35f)
|
||||||
|
val e = rest + (held - rest) * lift
|
||||||
|
val c = w + h - e
|
||||||
|
val region = cornerRegion(w, h, r, c)
|
||||||
|
if (region.size < 3) return
|
||||||
|
val mid = Offset(w - e / 2, h - e / 2)
|
||||||
|
// 1. The second layer, turning the accent colour as the hold completes.
|
||||||
|
drawPath(pathOf(region), lerp(lerp(face, Color.Black, 0.26f), accent, lift))
|
||||||
|
// The lifted edge of the top layer shades the second layer just beside it.
|
||||||
|
drawPath(
|
||||||
|
pathOf(region),
|
||||||
|
Brush.linearGradient(
|
||||||
|
listOf(Color.Black.copy(alpha = 0.35f), Color.Transparent),
|
||||||
|
start = mid,
|
||||||
|
end = mid + Offset(e * 0.25f, e * 0.25f),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
// 2. "More" waits on the second layer, readable once the peel is well back.
|
||||||
|
if (lift > 0.35f && side > 80.dp.toPx()) {
|
||||||
|
val a = ((lift - 0.35f) / 0.65f).coerceIn(0f, 1f)
|
||||||
|
val centre = Offset(w - e * 0.3f, h - e * 0.3f)
|
||||||
|
val gap = 5.5.dp.toPx()
|
||||||
|
for (k in -1..1) {
|
||||||
|
drawCircle(onAccent.copy(alpha = a), radius = 2.2.dp.toPx(), center = centre + Offset(k * gap * 0.71f, -k * gap * 0.71f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 3. The flap: the same corner folded back over the face, its rounded tip and all.
|
||||||
|
val flap = region.map { Offset(c - it.y, c - it.x) }
|
||||||
|
translate(-1.5.dp.toPx(), -1.5.dp.toPx()) {
|
||||||
|
drawPath(pathOf(flap), Color.Black.copy(alpha = 0.30f))
|
||||||
|
}
|
||||||
|
drawPath(
|
||||||
|
pathOf(flap),
|
||||||
|
Brush.linearGradient(
|
||||||
|
listOf(lerp(face, Color.White, 0.30f), lerp(face, Color.White, 0.08f)),
|
||||||
|
start = Offset(c - h, c - w),
|
||||||
|
end = mid,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -59,6 +59,11 @@ data class ControlLook(
|
|||||||
val pressedDim: Float = 0.22f,
|
val pressedDim: Float = 0.22f,
|
||||||
/** Scale while pressed, 1 for none. */
|
/** Scale while pressed, 1 for none. */
|
||||||
val pressedScale: Float = 0.95f,
|
val pressedScale: Float = 0.95f,
|
||||||
|
/**
|
||||||
|
* How a control with a press-and-hold action says so. The peel needs a raised face to lift
|
||||||
|
* from; a flat look takes the dot.
|
||||||
|
*/
|
||||||
|
val holdMarker: HoldMarkerStyle = HoldMarkerStyle.PEEL,
|
||||||
/** Replaces this look when the UI is light, for effects that only work on one background. */
|
/** Replaces this look when the UI is light, for effects that only work on one background. */
|
||||||
val light: ControlLook? = null,
|
val light: ControlLook? = null,
|
||||||
)
|
)
|
||||||
@@ -67,7 +72,7 @@ object GsThemes {
|
|||||||
val Keycap = GsTheme(
|
val Keycap = GsTheme(
|
||||||
id = "keycap",
|
id = "keycap",
|
||||||
name = "Keycap",
|
name = "Keycap",
|
||||||
control = ControlLook(lip = 4.dp, pressedScale = 1f),
|
control = ControlLook(lip = 4.dp, pressedScale = 1f, holdMarker = HoldMarkerStyle.PEEL),
|
||||||
)
|
)
|
||||||
|
|
||||||
val Tactile = GsTheme(
|
val Tactile = GsTheme(
|
||||||
@@ -83,6 +88,7 @@ object GsThemes {
|
|||||||
insetAlpha = 0.30f,
|
insetAlpha = 0.30f,
|
||||||
pressedDim = 0.12f,
|
pressedDim = 0.12f,
|
||||||
pressedScale = 1f,
|
pressedScale = 1f,
|
||||||
|
holdMarker = HoldMarkerStyle.PEEL,
|
||||||
// A wide soft shadow bands once the car's video stream compresses it on a light
|
// A wide soft shadow bands once the car's video stream compresses it on a light
|
||||||
// background, so light mode gets a tight one and leans on the bevel instead.
|
// background, so light mode gets a tight one and leans on the bevel instead.
|
||||||
light = ControlLook(
|
light = ControlLook(
|
||||||
@@ -95,6 +101,7 @@ object GsThemes {
|
|||||||
insetAlpha = 0.30f,
|
insetAlpha = 0.30f,
|
||||||
pressedDim = 0.12f,
|
pressedDim = 0.12f,
|
||||||
pressedScale = 1f,
|
pressedScale = 1f,
|
||||||
|
holdMarker = HoldMarkerStyle.PEEL,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -102,7 +109,7 @@ object GsThemes {
|
|||||||
val Flat = GsTheme(
|
val Flat = GsTheme(
|
||||||
id = "flat",
|
id = "flat",
|
||||||
name = "Flat",
|
name = "Flat",
|
||||||
control = ControlLook(),
|
control = ControlLook(holdMarker = HoldMarkerStyle.DOT),
|
||||||
)
|
)
|
||||||
|
|
||||||
val default = Tactile
|
val default = Tactile
|
||||||
|
|||||||
@@ -455,28 +455,28 @@ class CarAppConnection(private val context: Context) {
|
|||||||
|
|
||||||
private val _surfaceEpoch = MutableStateFlow(0)
|
private val _surfaceEpoch = MutableStateFlow(0)
|
||||||
|
|
||||||
/** Bumped when the lent surface turns out to be dead, so the stage builds a fresh one. */
|
/** Bumped to throw the lent surface away, so the stage builds a fresh one. */
|
||||||
val surfaceEpoch: StateFlow<Int> = _surfaceEpoch.asStateFlow()
|
val surfaceEpoch: StateFlow<Int> = _surfaceEpoch.asStateFlow()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lends the app its surface again: called when the map comes back into view. An app that
|
* Called as the map comes back into view: replaces the surface only if it died while the map
|
||||||
* draws through its own VirtualDisplay (Organic Maps does) can be left pointing at a surface
|
* was covered. A live one is left alone, because every hand-over costs the app its map - some
|
||||||
* that has since died, and draws black until it's told about a live one. Taking the surface
|
* (MapQuest) rebuild theirs from a world view and fly back to the car's position.
|
||||||
* back and lending it again repoints it; a surface that has itself died is replaced.
|
*
|
||||||
|
* Always a new surface, never the dead one lent again: an app that draws through its own
|
||||||
|
* VirtualDisplay (Flitsmeister does) builds its new display on a re-lent surface before
|
||||||
|
* releasing the old, and the old one's disconnect then takes the shared BufferQueue's
|
||||||
|
* connection with it - the map freezes on its last frame for good. The stage's key on
|
||||||
|
* [surfaceEpoch] swaps in a new SurfaceView, which lends itself as it comes up.
|
||||||
*/
|
*/
|
||||||
fun refreshSurface() {
|
fun replaceDeadSurface() {
|
||||||
main.post {
|
main.post {
|
||||||
val current = lent ?: return@post
|
val current = lent ?: return@post
|
||||||
if (!current.surface.isValid) {
|
if (current.surface.isValid) return@post
|
||||||
GearslipLog.w("host: the lent surface died; building a new one")
|
GearslipLog.w("host: the lent surface died; building a new one")
|
||||||
lent = null
|
lent = null
|
||||||
sendSurfaceDestroyed(current)
|
sendSurfaceDestroyed(current)
|
||||||
_surfaceEpoch.value = _surfaceEpoch.value + 1
|
_surfaceEpoch.value = _surfaceEpoch.value + 1
|
||||||
return@post
|
|
||||||
}
|
|
||||||
GearslipLog.i("host: lending the surface again as the map comes back")
|
|
||||||
sendSurfaceDestroyed(current)
|
|
||||||
sendSurfaceAvailable()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -504,9 +504,9 @@ 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. We keep our chrome
|
// chrome, and the stable area is the part that never gets covered. Both depend on the
|
||||||
// to a top bar, so both are the surface minus that bar - reported by the UI via
|
// template (see visibleAreaFor) and are reported by the UI via [reportAreas] once it
|
||||||
// [reportAreas] once it has measured. Until then the whole surface is fair game.
|
// has measured. 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)
|
reportAreas(whole, whole)
|
||||||
|
|
||||||
|
|||||||
@@ -52,7 +52,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.describe()
|
val key = icon.cacheKey(context)
|
||||||
iconCache.get(key)?.let { return it }
|
iconCache.get(key)?.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}") }
|
||||||
@@ -77,6 +77,18 @@ fun CarIcon?.image(context: Context): ImageBitmap? {
|
|||||||
return bitmap
|
return bitmap
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [describe], plus when the app was last installed for a resource icon. Resource ids are only
|
||||||
|
* numbers inside one build of the app: an update that adds a drawable renumbers the ones after it,
|
||||||
|
* so a cache keyed on the bare id kept drawing the old build's picture on the wrong button (Vela's
|
||||||
|
* new Saved button came up as its search glyph).
|
||||||
|
*/
|
||||||
|
private fun androidx.core.graphics.drawable.IconCompat.cacheKey(context: Context): String {
|
||||||
|
if (type != androidx.core.graphics.drawable.IconCompat.TYPE_RESOURCE) return describe()
|
||||||
|
val installed = runCatching { context.packageManager.getPackageInfo(resPackage, 0).lastUpdateTime }.getOrDefault(0L)
|
||||||
|
return "${describe()}@$installed"
|
||||||
|
}
|
||||||
|
|
||||||
/** Enough of an [androidx.core.graphics.drawable.IconCompat] to tell log lines apart without dumping the whole object. */
|
/** Enough of an [androidx.core.graphics.drawable.IconCompat] to tell log lines apart without dumping the whole object. */
|
||||||
private fun androidx.core.graphics.drawable.IconCompat.describe(): String = when (type) {
|
private fun androidx.core.graphics.drawable.IconCompat.describe(): String = when (type) {
|
||||||
androidx.core.graphics.drawable.IconCompat.TYPE_URI,
|
androidx.core.graphics.drawable.IconCompat.TYPE_URI,
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ object KnownApps {
|
|||||||
"in.krosbits.musicolet", // Musicolet: browse, play, seek, lyrics
|
"in.krosbits.musicolet", // Musicolet: browse, play, seek, lyrics
|
||||||
"com.kododake.aabrowser", // Aardvark: browser template, starts, draws, takes touches
|
"com.kododake.aabrowser", // Aardvark: browser template, starts, draws, takes touches
|
||||||
"com.vivi.vivimusic", // Vivi Music: browse, play, seek, works well
|
"com.vivi.vivimusic", // Vivi Music: browse, play, seek, works well
|
||||||
|
"com.metrolist.music", // Metrolist: browse, play, works
|
||||||
|
"nl.flitsmeister", // Flitsmeister: map, routing, alerts - works well
|
||||||
)
|
)
|
||||||
|
|
||||||
fun works(packageName: String): Boolean = packageName in working
|
fun works(packageName: String): Boolean = packageName in working
|
||||||
@@ -31,8 +33,10 @@ object KnownApps {
|
|||||||
fun playerWorks(packageName: String): Boolean = packageName in workingPlayer
|
fun playerWorks(packageName: String): Boolean = packageName in workingPlayer
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apps whose car screen refuses Gearslip but whose player works. Only the "· Browse" tile
|
* Apps whose car screen refuses Gearslip but whose player works. When the player covers the
|
||||||
* gets the red X. Car library 1.9 and later accepts only Google's host on a phone.
|
* app (see [playerWorks]) the launcher drops the dead "· Browse" tile entirely rather than
|
||||||
|
* show it; otherwise that tile gets a red X. Car library 1.9 and later accepts only Google's
|
||||||
|
* host on a phone.
|
||||||
*/
|
*/
|
||||||
private val brokenScreen = setOf(
|
private val brokenScreen = setOf(
|
||||||
"com.spotify.music", // Spotify 9.1.86 and later: car library 1.9 rejects Gearslip
|
"com.spotify.music", // Spotify 9.1.86 and later: car library 1.9 rejects Gearslip
|
||||||
@@ -47,6 +51,7 @@ object KnownApps {
|
|||||||
private val hidden = setOf(
|
private val hidden = setOf(
|
||||||
"com.google.android.dialer", // Google Phone
|
"com.google.android.dialer", // Google Phone
|
||||||
"com.google.android.googlequicksearchbox", // Google Assistant driving surfaces
|
"com.google.android.googlequicksearchbox", // Google Assistant driving surfaces
|
||||||
|
"com.google.android.apps.maps", // Google Maps: shows as a messenger, and its car UI is Android Auto's own
|
||||||
)
|
)
|
||||||
|
|
||||||
fun isHidden(packageName: String): Boolean = packageName in hidden
|
fun isHidden(packageName: String): Boolean = packageName in hidden
|
||||||
@@ -60,9 +65,15 @@ object KnownApps {
|
|||||||
|
|
||||||
fun isBroken(packageName: String): Boolean = packageName in broken
|
fun isBroken(packageName: String): Boolean = packageName in broken
|
||||||
|
|
||||||
/** Shown on the launcher with a yellow mark: starts and can be used, but not cleanly enough to call working. */
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
private val partial = setOf(
|
private val partial = setOf(
|
||||||
"app.vela", // Vela Maps: connects and draws, but rough enough not to call it working yet
|
"app.vela", // Vela Maps: connects and draws, but rough enough not to call it working yet
|
||||||
|
"com.mapquest.android.ace", // MapQuest: map, search and routing run, but not cleanly
|
||||||
|
"com.spotify.music", // Spotify: plays through Gearslip's player, but the app has to be opened on the phone first
|
||||||
|
"deezer.android.app", // Deezer: the same - it has to be opened on the phone first
|
||||||
)
|
)
|
||||||
|
|
||||||
fun isPartial(packageName: String): Boolean = packageName in partial
|
fun isPartial(packageName: String): Boolean = packageName in partial
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ package app.seb3thehacker.gearslip.media
|
|||||||
|
|
||||||
import android.content.ComponentName
|
import android.content.ComponentName
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.media.MediaMetadata
|
import android.media.MediaMetadata
|
||||||
import android.media.Rating
|
import android.media.Rating
|
||||||
|
import android.support.v4.media.MediaBrowserCompat
|
||||||
import android.support.v4.media.session.MediaControllerCompat
|
import android.support.v4.media.session.MediaControllerCompat
|
||||||
import android.support.v4.media.session.MediaSessionCompat
|
import android.support.v4.media.session.MediaSessionCompat
|
||||||
import android.support.v4.media.session.PlaybackStateCompat
|
import android.support.v4.media.session.PlaybackStateCompat
|
||||||
@@ -19,6 +21,7 @@ import android.os.Handler
|
|||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.os.SystemClock
|
import android.os.SystemClock
|
||||||
import app.seb3thehacker.gearslip.GearslipLog
|
import app.seb3thehacker.gearslip.GearslipLog
|
||||||
|
import app.seb3thehacker.gearslip.host.KnownApps
|
||||||
import app.seb3thehacker.gearslip.notify.GearslipNotificationListener
|
import app.seb3thehacker.gearslip.notify.GearslipNotificationListener
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -73,6 +76,9 @@ class NowPlaying(
|
|||||||
words.any { a.id.contains(it, ignoreCase = true) || a.name.contains(it, ignoreCase = true) }
|
words.any { a.id.contains(it, ignoreCase = true) || a.name.contains(it, ignoreCase = true) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The app says it takes searches. Spotify says so and then ignores Gearslip's, so [CarMedia.search] checks. */
|
||||||
|
val canSearch get() = (actions and PlaybackState.ACTION_PLAY_FROM_SEARCH) != 0L
|
||||||
|
|
||||||
/** Thumbs-down buttons say "like" too ("Dislike"), so those are skipped. */
|
/** Thumbs-down buttons say "like" too ("Dislike"), so those are skipped. */
|
||||||
val likeAction
|
val likeAction
|
||||||
get() = custom.firstOrNull { a ->
|
get() = custom.firstOrNull { a ->
|
||||||
@@ -142,7 +148,7 @@ class CarMedia(private val context: Context) {
|
|||||||
enum class Phase { IDLE, CONNECTING, READY, REJECTED }
|
enum class Phase { IDLE, CONNECTING, READY, REJECTED }
|
||||||
|
|
||||||
/** Why a connection was [Phase.REJECTED], so the screen can say something useful. */
|
/** Why a connection was [Phase.REJECTED], so the screen can say something useful. */
|
||||||
enum class Rejection { REFUSED, NEEDS_NOTIFICATION_ACCESS }
|
enum class Rejection { REFUSED, NEEDS_NOTIFICATION_ACCESS, NEEDS_APP_RUNNING }
|
||||||
|
|
||||||
private val _rejection = MutableStateFlow(Rejection.REFUSED)
|
private val _rejection = MutableStateFlow(Rejection.REFUSED)
|
||||||
val rejection: StateFlow<Rejection> = _rejection.asStateFlow()
|
val rejection: StateFlow<Rejection> = _rejection.asStateFlow()
|
||||||
@@ -162,6 +168,24 @@ class CarMedia(private val context: Context) {
|
|||||||
private val _queue = MutableStateFlow<List<QueueTrack>>(emptyList())
|
private val _queue = MutableStateFlow<List<QueueTrack>>(emptyList())
|
||||||
val queue: StateFlow<List<QueueTrack>> = _queue.asStateFlow()
|
val queue: StateFlow<List<QueueTrack>> = _queue.asStateFlow()
|
||||||
|
|
||||||
|
/** A typed search on its way to the app; [Search.ignored] once it has played nothing for it. */
|
||||||
|
class Search(val query: String, val ignored: Boolean = false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the app's own library search found for [query]. [entries] is null while it looks.
|
||||||
|
* [supported] is false when the app has no library search, so the panel can fall back to
|
||||||
|
* "play the best match" (see [search]).
|
||||||
|
*/
|
||||||
|
class Results(val query: String, val entries: List<MediaEntry>? = null, val supported: Boolean = true)
|
||||||
|
|
||||||
|
private val _results = MutableStateFlow<Results?>(null)
|
||||||
|
val results: StateFlow<Results?> = _results.asStateFlow()
|
||||||
|
private var resultsQuery: String? = null
|
||||||
|
|
||||||
|
private val _search = MutableStateFlow<Search?>(null)
|
||||||
|
val search: StateFlow<Search?> = _search.asStateFlow()
|
||||||
|
private var searchBefore: NowPlaying? = null
|
||||||
|
|
||||||
/** Shuffle and repeat, as [PlaybackStateCompat]'s modes; [PlaybackStateCompat.SHUFFLE_MODE_INVALID] when unknown. */
|
/** Shuffle and repeat, as [PlaybackStateCompat]'s modes; [PlaybackStateCompat.SHUFFLE_MODE_INVALID] when unknown. */
|
||||||
class Modes(
|
class Modes(
|
||||||
val shuffle: Int = PlaybackStateCompat.SHUFFLE_MODE_INVALID,
|
val shuffle: Int = PlaybackStateCompat.SHUFFLE_MODE_INVALID,
|
||||||
@@ -236,7 +260,7 @@ class CarMedia(private val context: Context) {
|
|||||||
* is out of reach. Reading another app's session needs Notification access, which the driver
|
* is out of reach. Reading another app's session needs Notification access, which the driver
|
||||||
* grants once on the phone.
|
* grants once on the phone.
|
||||||
*/
|
*/
|
||||||
private fun attachToSession(app: MediaApp, attempt: Int = 0) {
|
private fun attachToSession(app: MediaApp, attempt: Int = 0, launched: Boolean = false) {
|
||||||
if (connecting != app) return
|
if (connecting != app) return
|
||||||
val manager = context.getSystemService(MediaSessionManager::class.java)
|
val manager = context.getSystemService(MediaSessionManager::class.java)
|
||||||
val listener = ComponentName(context, GearslipNotificationListener::class.java)
|
val listener = ComponentName(context, GearslipNotificationListener::class.java)
|
||||||
@@ -249,12 +273,19 @@ class CarMedia(private val context: Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (session == null) {
|
if (session == null) {
|
||||||
|
val limit = if (launched) SESSION_RETRIES_LAUNCHED else SESSION_RETRIES
|
||||||
// The app only publishes its session once its service has started; give it a moment.
|
// The app only publishes its session once its service has started; give it a moment.
|
||||||
if (attempt < SESSION_RETRIES) {
|
if (attempt < limit) {
|
||||||
main.postDelayed({ attachToSession(app, attempt + 1) }, SESSION_RETRY_MS)
|
main.postDelayed({ attachToSession(app, attempt + 1, launched) }, SESSION_RETRY_MS)
|
||||||
} else {
|
} else {
|
||||||
GearslipLog.w("media: ${app.label} has no active session to control")
|
GearslipLog.w("media: ${app.label} has no active session to control")
|
||||||
_rejection.value = Rejection.REFUSED
|
// A session-only app (Spotify) just isn't running - say so and offer to open it,
|
||||||
|
// rather than calling it unsupported.
|
||||||
|
_rejection.value = if (KnownApps.playerWorks(app.component.packageName)) {
|
||||||
|
Rejection.NEEDS_APP_RUNNING
|
||||||
|
} else {
|
||||||
|
Rejection.REFUSED
|
||||||
|
}
|
||||||
_phase.value = Phase.REJECTED
|
_phase.value = Phase.REJECTED
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
@@ -267,6 +298,7 @@ class CarMedia(private val context: Context) {
|
|||||||
|
|
||||||
private fun onConnected(app: MediaApp) {
|
private fun onConnected(app: MediaApp) {
|
||||||
val mb = browser ?: return
|
val mb = browser ?: return
|
||||||
|
searchComponent = app.component
|
||||||
GearslipLog.i("media: connected to ${app.label}, root=${mb.root}")
|
GearslipLog.i("media: connected to ${app.label}, root=${mb.root}")
|
||||||
attach(MediaController(context, mb.sessionToken))
|
attach(MediaController(context, mb.sessionToken))
|
||||||
rootId = mb.root
|
rootId = mb.root
|
||||||
@@ -317,6 +349,7 @@ class CarMedia(private val context: Context) {
|
|||||||
metadata?.getRating(MediaMetadata.METADATA_KEY_USER_RATING)?.takeIf { it.isRated }?.hasHeart() ?: false
|
metadata?.getRating(MediaMetadata.METADATA_KEY_USER_RATING)?.takeIf { it.isRated }?.hasHeart() ?: false
|
||||||
} else null,
|
} else null,
|
||||||
)
|
)
|
||||||
|
checkSearch()
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- browsing ------------------------------------------------------------------------
|
// --- browsing ------------------------------------------------------------------------
|
||||||
@@ -392,6 +425,141 @@ class CarMedia(private val context: Context) {
|
|||||||
fun custom(action: CustomAction) = controller?.transportControls?.sendCustomAction(action.id, action.extras)
|
fun custom(action: CustomAction) = controller?.transportControls?.sendCustomAction(action.id, action.extras)
|
||||||
fun playQueueItem(track: QueueTrack) = controller?.transportControls?.skipToQueueItem(track.queueId)
|
fun playQueueItem(track: QueueTrack) = controller?.transportControls?.skipToQueueItem(track.queueId)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asks the app to find and play [query]: a song, artist, album or playlist. This goes through
|
||||||
|
* the app's playback session, so it works for apps that keep their library from Gearslip, and
|
||||||
|
* the app stays in the background. Apps may still ignore it (Spotify only takes Google's), so
|
||||||
|
* if nothing new plays within [SEARCH_WAIT_MS] the search is marked ignored.
|
||||||
|
*/
|
||||||
|
fun search(query: String) {
|
||||||
|
val c = controller ?: return
|
||||||
|
main.removeCallbacks(searchTimeout)
|
||||||
|
searchBefore = _now.value
|
||||||
|
_search.value = Search(query)
|
||||||
|
GearslipLog.i("media: -> playFromSearch")
|
||||||
|
c.transportControls.playFromSearch(query, Bundle())
|
||||||
|
main.postDelayed(searchTimeout, SEARCH_WAIT_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asks the app's library for matches to [query], to list for the driver to pick from. Unlike
|
||||||
|
* [search], nothing plays until one is chosen, so a wrong guess costs nothing. Metrolist and
|
||||||
|
* ViVi answer with songs from the library first, then YouTube Music.
|
||||||
|
*/
|
||||||
|
fun searchLibrary(query: String) {
|
||||||
|
val q = query.trim()
|
||||||
|
if (q.isEmpty()) {
|
||||||
|
clearResults()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val component = searchComponent
|
||||||
|
if (component == null) {
|
||||||
|
_results.value = Results(q, emptyList(), supported = false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resultsQuery = q
|
||||||
|
_results.value = Results(q)
|
||||||
|
val sb = searchBrowser
|
||||||
|
if (sb != null && sb.isConnected) {
|
||||||
|
runSearch(sb, q)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (sb != null) return // still connecting; it searches for [resultsQuery] once connected
|
||||||
|
// The framework MediaBrowser has no search; the support library's does, over the same
|
||||||
|
// service. Opened on first use only, so apps nobody searches never see a second client.
|
||||||
|
lateinit var created: MediaBrowserCompat
|
||||||
|
created = MediaBrowserCompat(context, component, object : MediaBrowserCompat.ConnectionCallback() {
|
||||||
|
override fun onConnected() {
|
||||||
|
resultsQuery?.let { runSearch(created, it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onConnectionFailed() {
|
||||||
|
GearslipLog.w("media: no library connection for search")
|
||||||
|
searchBrowser = null
|
||||||
|
resultsQuery?.let { _results.value = Results(it, emptyList(), supported = false) }
|
||||||
|
}
|
||||||
|
}, null)
|
||||||
|
searchBrowser = created
|
||||||
|
runCatching { created.connect() }.onFailure {
|
||||||
|
searchBrowser = null
|
||||||
|
_results.value = Results(q, emptyList(), supported = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun runSearch(sb: MediaBrowserCompat, q: String) {
|
||||||
|
runCatching {
|
||||||
|
sb.search(q, Bundle(), object : MediaBrowserCompat.SearchCallback() {
|
||||||
|
override fun onSearchResult(query: String, extras: Bundle?, items: List<MediaBrowserCompat.MediaItem>) {
|
||||||
|
if (query != resultsQuery) return
|
||||||
|
GearslipLog.i("media: library search found ${items.size}")
|
||||||
|
_results.value = Results(query, items.map(::entryOfCompat))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onError(query: String, extras: Bundle?) {
|
||||||
|
if (query != resultsQuery) return
|
||||||
|
GearslipLog.i("media: the app has no library search")
|
||||||
|
_results.value = Results(query, emptyList(), supported = false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}.onFailure {
|
||||||
|
GearslipLog.w("media: library search failed: ${it.message}")
|
||||||
|
_results.value = Results(q, emptyList(), supported = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun entryOfCompat(item: MediaBrowserCompat.MediaItem): MediaEntry {
|
||||||
|
val d = item.description
|
||||||
|
return MediaEntry(
|
||||||
|
id = item.mediaId.orEmpty(),
|
||||||
|
title = d.title?.toString().orEmpty(),
|
||||||
|
subtitle = d.subtitle?.toString().orEmpty(),
|
||||||
|
iconUri = d.iconUri,
|
||||||
|
iconBitmap = d.iconBitmap,
|
||||||
|
browsable = item.isBrowsable,
|
||||||
|
playable = item.isPlayable,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The connected app's service, for the search connection; null when nothing is connected. */
|
||||||
|
private var searchComponent: android.content.ComponentName? = null
|
||||||
|
private var searchBrowser: MediaBrowserCompat? = null
|
||||||
|
|
||||||
|
fun clearResults() {
|
||||||
|
resultsQuery = null
|
||||||
|
_results.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether there's a library to search through, the better of the two searches. */
|
||||||
|
val canSearchLibrary: Boolean get() = browser?.isConnected == true && searchComponent != null
|
||||||
|
|
||||||
|
fun dismissSearch() {
|
||||||
|
main.removeCallbacks(searchTimeout)
|
||||||
|
main.removeCallbacks(clearSearch)
|
||||||
|
_search.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private val searchTimeout = Runnable {
|
||||||
|
val pending = _search.value ?: return@Runnable
|
||||||
|
if (pending.ignored) return@Runnable
|
||||||
|
GearslipLog.w("media: nothing new played after a search")
|
||||||
|
_search.value = Search(pending.query, ignored = true)
|
||||||
|
main.postDelayed(clearSearch, SEARCH_NOTICE_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val clearSearch = Runnable { _search.value = null }
|
||||||
|
|
||||||
|
/** Something new is playing: another track, or the same one started from a pause. */
|
||||||
|
private fun checkSearch() {
|
||||||
|
val pending = _search.value ?: return
|
||||||
|
val before = searchBefore ?: return
|
||||||
|
val now = _now.value
|
||||||
|
if (pending.ignored || !now.playing) return
|
||||||
|
if (now.title != before.title || !before.playing) {
|
||||||
|
GearslipLog.i("media: search started playback")
|
||||||
|
dismissSearch()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** The app's own like button if it has one, since that's the one its library listens to; else a heart rating. */
|
/** The app's own like button if it has one, since that's the one its library listens to; else a heart rating. */
|
||||||
fun toggleLike() {
|
fun toggleLike() {
|
||||||
val now = _now.value
|
val now = _now.value
|
||||||
@@ -422,6 +590,10 @@ class CarMedia(private val context: Context) {
|
|||||||
|
|
||||||
fun disconnect() {
|
fun disconnect() {
|
||||||
connecting = null
|
connecting = null
|
||||||
|
runCatching { searchBrowser?.disconnect() }
|
||||||
|
searchBrowser = null
|
||||||
|
searchComponent = null
|
||||||
|
clearResults()
|
||||||
main.removeCallbacksAndMessages(null)
|
main.removeCallbacksAndMessages(null)
|
||||||
runCatching { compat?.unregisterCallback(compatCallback) }
|
runCatching { compat?.unregisterCallback(compatCallback) }
|
||||||
compat = null
|
compat = null
|
||||||
@@ -435,10 +607,44 @@ class CarMedia(private val context: Context) {
|
|||||||
_phase.value = Phase.IDLE
|
_phase.value = Phase.IDLE
|
||||||
_now.value = NowPlaying()
|
_now.value = NowPlaying()
|
||||||
_queue.value = emptyList()
|
_queue.value = emptyList()
|
||||||
|
dismissSearch()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens [app] on the phone, then connects once it publishes its session. The driver asks for
|
||||||
|
* this from the "Open" button a session-only app (Spotify) shows when it isn't running yet.
|
||||||
|
*/
|
||||||
|
fun launchAndConnect(app: MediaApp) {
|
||||||
|
if (!openApp(app)) return
|
||||||
|
disconnect()
|
||||||
|
connecting = app
|
||||||
|
_phase.value = Phase.CONNECTING
|
||||||
|
// A cold start takes a few seconds to publish a session, so wait, then watch for longer.
|
||||||
|
main.postDelayed({ attachToSession(app, launched = true) }, LAUNCH_WAIT_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Brings [app] to the foreground on the phone so it publishes its session. */
|
||||||
|
private fun openApp(app: MediaApp): Boolean {
|
||||||
|
val intent = context.packageManager.getLaunchIntentForPackage(app.component.packageName) ?: return false
|
||||||
|
return runCatching {
|
||||||
|
context.startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||||
|
GearslipLog.i("media: opened ${app.label} to bring up its session")
|
||||||
|
true
|
||||||
|
}.getOrElse {
|
||||||
|
GearslipLog.w("media: could not open ${app.label}: ${it.message}")
|
||||||
|
false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val SESSION_RETRIES = 6
|
const val SESSION_RETRIES = 6
|
||||||
const val SESSION_RETRY_MS = 500L
|
const val SESSION_RETRY_MS = 500L
|
||||||
|
/** After opening a cold app, how long to wait before the first look for its session. */
|
||||||
|
const val LAUNCH_WAIT_MS = 1_500L
|
||||||
|
/** Retries once the app has been opened: a cold start can take a few seconds to publish. */
|
||||||
|
const val SESSION_RETRIES_LAUNCHED = 16
|
||||||
|
/** How long an app gets to start playing a search before Gearslip says it didn't. */
|
||||||
|
const val SEARCH_WAIT_MS = 6_000L
|
||||||
|
const val SEARCH_NOTICE_MS = 6_000L
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -14,6 +14,7 @@ import android.os.Handler
|
|||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.os.SystemClock
|
import android.os.SystemClock
|
||||||
import app.seb3thehacker.gearslip.GearslipLog
|
import app.seb3thehacker.gearslip.GearslipLog
|
||||||
|
import app.seb3thehacker.gearslip.audio.CarMic
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import java.util.concurrent.Executors
|
import java.util.concurrent.Executors
|
||||||
import kotlin.math.log10
|
import kotlin.math.log10
|
||||||
@@ -25,7 +26,8 @@ import kotlin.math.sqrt
|
|||||||
* none). whisper.cpp runs FUTO's ACFT Whisper model, which are tuned for short phrases: a reply
|
* none). whisper.cpp runs FUTO's ACFT Whisper model, which are tuned for short phrases: a reply
|
||||||
* of a few seconds comes back in well under a second on a recent phone.
|
* of a few seconds comes back in well under a second on a recent phone.
|
||||||
*
|
*
|
||||||
* Whisper needs the whole phrase before it starts, so this records from the phone's microphone,
|
* Whisper needs the whole phrase before it starts, so this records from the car's microphone (the
|
||||||
|
* phone's when the car has none or sends nothing),
|
||||||
* decides when the driver has stopped talking (a stretch of quiet after speech), then transcribes
|
* decides when the driver has stopped talking (a stretch of quiet after speech), then transcribes
|
||||||
* once. Everything runs on one worker thread; callbacks arrive on the main thread.
|
* once. Everything runs on one worker thread; callbacks arrive on the main thread.
|
||||||
*/
|
*/
|
||||||
@@ -45,6 +47,9 @@ object SpeechEngine {
|
|||||||
private const val CALIBRATE_MS = 300L
|
private const val CALIBRATE_MS = 300L
|
||||||
private const val PRE_ROLL = RATE * 3 / 10 // keep 300 ms before the first word
|
private const val PRE_ROLL = RATE * 3 / 10 // keep 300 ms before the first word
|
||||||
private const val CUE_VOLUME = 80
|
private const val CUE_VOLUME = 80
|
||||||
|
/** How long the car gets to start sending once asked, before the phone's microphone takes over. */
|
||||||
|
private const val CAR_FIRST_AUDIO_MS = 1_500L
|
||||||
|
private const val CAR_GAP_MS = 1_000L
|
||||||
private const val CUE_MS = 150
|
private const val CUE_MS = 150
|
||||||
/** The car plays what the phone sends a moment late; waiting this out keeps the beep off the recording. */
|
/** The car plays what the phone sends a moment late; waiting this out keeps the beep off the recording. */
|
||||||
private const val CUE_TAIL_MS = 250L
|
private const val CUE_TAIL_MS = 250L
|
||||||
@@ -85,13 +90,16 @@ object SpeechEngine {
|
|||||||
worker.execute {
|
worker.execute {
|
||||||
if (id != run) return@execute
|
if (id != run) return@execute
|
||||||
if (!ensureLoaded(app)) return@execute post { onError(NOT_LOADED) }
|
if (!ensureLoaded(app)) return@execute post { onError(NOT_LOADED) }
|
||||||
|
VoiceFocus.acquire(app)
|
||||||
|
val audio = try {
|
||||||
if (cue) cue()
|
if (cue) cue()
|
||||||
if (id != run) return@execute
|
if (id != run) return@execute
|
||||||
val audio = try {
|
|
||||||
record(id) { post(onSpeaking) }
|
record(id) { post(onSpeaking) }
|
||||||
} catch (t: Throwable) {
|
} catch (t: Throwable) {
|
||||||
GearslipLog.e("speech: recording failed", t)
|
GearslipLog.e("speech: recording failed", t)
|
||||||
return@execute post { onError("The microphone isn't available right now.") }
|
return@execute post { onError("The microphone isn't available right now.") }
|
||||||
|
} finally {
|
||||||
|
VoiceFocus.release()
|
||||||
}
|
}
|
||||||
if (id != run) return@execute
|
if (id != run) return@execute
|
||||||
if (audio == null) return@execute post { onError("Didn't catch that.") }
|
if (audio == null) return@execute post { onError("Didn't catch that.") }
|
||||||
@@ -129,14 +137,73 @@ object SpeechEngine {
|
|||||||
* or null when nobody spoke. The speech threshold follows the noise in the first 300 ms, so a
|
* or null when nobody spoke. The speech threshold follows the noise in the first 300 ms, so a
|
||||||
* loud cabin needs a louder voice than a quiet one to count as talking.
|
* loud cabin needs a louder voice than a quiet one to count as talking.
|
||||||
*/
|
*/
|
||||||
|
/** Where a phrase is recorded from: [read] fills [frame] and returns how many samples it got. */
|
||||||
|
private interface Mic {
|
||||||
|
fun read(frame: ShortArray): Int
|
||||||
|
fun close()
|
||||||
|
}
|
||||||
|
|
||||||
@SuppressLint("MissingPermission") // checked in listen()
|
@SuppressLint("MissingPermission") // checked in listen()
|
||||||
private fun record(id: Int, onSpeaking: () -> Unit): FloatArray? {
|
private fun phoneMic(): Mic {
|
||||||
val min = AudioRecord.getMinBufferSize(RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT)
|
val min = AudioRecord.getMinBufferSize(RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT)
|
||||||
val recorder = AudioRecord(
|
val recorder = AudioRecord(
|
||||||
MediaRecorder.AudioSource.VOICE_RECOGNITION, RATE,
|
MediaRecorder.AudioSource.VOICE_RECOGNITION, RATE,
|
||||||
AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT, max(min, FRAME * 8),
|
AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT, max(min, FRAME * 8),
|
||||||
)
|
)
|
||||||
check(recorder.state == AudioRecord.STATE_INITIALIZED) { "AudioRecord didn't initialize" }
|
check(recorder.state == AudioRecord.STATE_INITIALIZED) { "AudioRecord didn't initialize" }
|
||||||
|
recorder.startRecording()
|
||||||
|
return object : Mic {
|
||||||
|
override fun read(frame: ShortArray) = recorder.read(frame, 0, frame.size)
|
||||||
|
override fun close() {
|
||||||
|
runCatching { recorder.stop() }
|
||||||
|
recorder.release()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The car's microphone, which hears the driver better than a phone in a cup holder. The car
|
||||||
|
* sends audio in its own sized pieces; they're cut to [FRAME]s here. Null when there's no car
|
||||||
|
* microphone, or it sends nothing at first, so the phone's is used instead.
|
||||||
|
*/
|
||||||
|
private fun carMic(): Mic? {
|
||||||
|
val car = CarMic.current ?: return null
|
||||||
|
if (!car.start()) return null
|
||||||
|
val first = car.read(CAR_FIRST_AUDIO_MS)
|
||||||
|
if (first == null) {
|
||||||
|
car.stop()
|
||||||
|
GearslipLog.w("speech: the car's microphone sent nothing - using the phone's")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
GearslipLog.i("speech: listening through the car's microphone")
|
||||||
|
return object : Mic {
|
||||||
|
var pending = first
|
||||||
|
var at = 0
|
||||||
|
override fun read(frame: ShortArray): Int {
|
||||||
|
var filled = 0
|
||||||
|
while (filled < frame.size) {
|
||||||
|
val chunk = pending ?: car.read(CAR_GAP_MS) ?: break
|
||||||
|
pending = chunk
|
||||||
|
val n = minOf(frame.size - filled, chunk.size - at)
|
||||||
|
System.arraycopy(chunk, at, frame, filled, n)
|
||||||
|
filled += n
|
||||||
|
at += n
|
||||||
|
if (at == chunk.size) { pending = null; at = 0 }
|
||||||
|
}
|
||||||
|
// The car stopped sending mid-phrase: end the recording rather than wait forever.
|
||||||
|
return if (filled == 0) -1 else filled
|
||||||
|
}
|
||||||
|
override fun close() = car.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records until the driver stops talking. Returns the phrase from just before the first word,
|
||||||
|
* or null when nobody spoke. The speech threshold follows the noise in the first 300 ms, so a
|
||||||
|
* loud cabin needs a louder voice than a quiet one to count as talking.
|
||||||
|
*/
|
||||||
|
private fun record(id: Int, onSpeaking: () -> Unit): FloatArray? {
|
||||||
|
val mic = carMic() ?: phoneMic()
|
||||||
val samples = FloatArray((RATE * MAX_MS / 1000).toInt())
|
val samples = FloatArray((RATE * MAX_MS / 1000).toInt())
|
||||||
var count = 0
|
var count = 0
|
||||||
val frame = ShortArray(FRAME)
|
val frame = ShortArray(FRAME)
|
||||||
@@ -144,11 +211,11 @@ object SpeechEngine {
|
|||||||
var noiseFrames = 0
|
var noiseFrames = 0
|
||||||
var speechStart = -1
|
var speechStart = -1
|
||||||
var lastSpeech = 0
|
var lastSpeech = 0
|
||||||
recorder.startRecording()
|
|
||||||
try {
|
try {
|
||||||
while (id == run && count + FRAME <= samples.size) {
|
while (id == run && count + FRAME <= samples.size) {
|
||||||
val n = recorder.read(frame, 0, FRAME)
|
val n = mic.read(frame)
|
||||||
if (n <= 0) continue
|
if (n < 0) break
|
||||||
|
if (n == 0) continue
|
||||||
var sum = 0.0
|
var sum = 0.0
|
||||||
for (i in 0 until n) {
|
for (i in 0 until n) {
|
||||||
val v = frame[i] / 32768f
|
val v = frame[i] / 32768f
|
||||||
@@ -175,8 +242,7 @@ object SpeechEngine {
|
|||||||
if (speechStart >= 0 && (count - lastSpeech) * 1000L / RATE > END_SILENCE_MS) break
|
if (speechStart >= 0 && (count - lastSpeech) * 1000L / RATE > END_SILENCE_MS) break
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
runCatching { recorder.stop() }
|
mic.close()
|
||||||
recorder.release()
|
|
||||||
}
|
}
|
||||||
if (speechStart < 0) return null
|
if (speechStart < 0) return null
|
||||||
return samples.copyOfRange(speechStart, count)
|
return samples.copyOfRange(speechStart, count)
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package app.seb3thehacker.gearslip.speech
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.media.AudioAttributes
|
||||||
|
import android.media.AudioFocusRequest
|
||||||
|
import android.media.AudioManager
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import app.seb3thehacker.gearslip.GearslipLog
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pauses the music while Gearslip talks or listens. Without it the microphone heard the song and
|
||||||
|
* Whisper wrote down lyrics, and a message read aloud had to compete with the music.
|
||||||
|
*
|
||||||
|
* Transient exclusive focus makes music apps pause rather than duck, and they resume on their
|
||||||
|
* own once it's given back. The release waits a moment, so a read-aloud message followed by
|
||||||
|
* "Do you want to reply?" and the microphone is one pause, not three.
|
||||||
|
*/
|
||||||
|
object VoiceFocus {
|
||||||
|
|
||||||
|
private const val RELEASE_DELAY_MS = 1_500L
|
||||||
|
|
||||||
|
private val main = Handler(Looper.getMainLooper())
|
||||||
|
private var request: AudioFocusRequest? = null
|
||||||
|
private var audio: AudioManager? = null
|
||||||
|
private var holders = 0
|
||||||
|
|
||||||
|
private val releaseNow = Runnable {
|
||||||
|
val r = request ?: return@Runnable
|
||||||
|
audio?.abandonAudioFocusRequest(r)
|
||||||
|
request = null
|
||||||
|
GearslipLog.i("voice: gave audio focus back")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pauses the music until a matching [release]. Safe from any thread. */
|
||||||
|
fun acquire(context: Context) {
|
||||||
|
val app = context.applicationContext
|
||||||
|
main.post {
|
||||||
|
holders++
|
||||||
|
main.removeCallbacks(releaseNow)
|
||||||
|
if (request != null) return@post
|
||||||
|
val manager = app.getSystemService(AudioManager::class.java) ?: return@post
|
||||||
|
val r = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE)
|
||||||
|
.setAudioAttributes(
|
||||||
|
AudioAttributes.Builder()
|
||||||
|
.setUsage(AudioAttributes.USAGE_ASSISTANT)
|
||||||
|
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.setOnAudioFocusChangeListener { }
|
||||||
|
.build()
|
||||||
|
val granted = manager.requestAudioFocus(r) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
|
||||||
|
GearslipLog.i("voice: audio focus ${if (granted) "granted, music paused" else "refused"}")
|
||||||
|
if (granted) {
|
||||||
|
audio = manager
|
||||||
|
request = r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lets the music come back shortly, unless something else takes focus again first. */
|
||||||
|
fun release() {
|
||||||
|
main.post {
|
||||||
|
if (holders == 0) return@post
|
||||||
|
holders--
|
||||||
|
if (holders == 0) main.postDelayed(releaseNow, RELEASE_DELAY_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
package app.seb3thehacker.gearslip.stats
|
||||||
|
|
||||||
|
import android.app.job.JobScheduler
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Build
|
||||||
|
import app.seb3thehacker.gearslip.BuildConfig
|
||||||
|
import app.seb3thehacker.gearslip.GearslipLog
|
||||||
|
import app.seb3thehacker.gearslip.ServiceDiscovery
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URL
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.util.UUID
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opt-in usage counts: a short note to the stats Worker (stats-worker/ in the repo). Off until the
|
||||||
|
* driver turns it on in setup or Settings. Only the app version is always sent; everything else
|
||||||
|
* is one of the driver's [Choices]. A random install ID counts a phone once; nothing ties it to a
|
||||||
|
* person, and the Worker keeps no IP addresses.
|
||||||
|
*
|
||||||
|
* Notes go out only while Gearslip is in use: the first time the app is opened each day, and
|
||||||
|
* after every car session. Nothing runs in the background, so a phone that stops using Gearslip
|
||||||
|
* goes quiet. A note that fails waits for the next one. With no [BuildConfig.STATS_URL] set at
|
||||||
|
* build time, nothing is sent.
|
||||||
|
*/
|
||||||
|
object UsageStats {
|
||||||
|
|
||||||
|
/** What the driver agreed to share beyond the app version. */
|
||||||
|
data class Choices(
|
||||||
|
val android: Boolean = true,
|
||||||
|
val phone: Boolean = true,
|
||||||
|
val cars: Boolean = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
private const val PREFS = "usage_stats"
|
||||||
|
private const val KEY_ENABLED = "enabled"
|
||||||
|
private const val KEY_ASKED = "asked"
|
||||||
|
private const val KEY_ID = "install_id"
|
||||||
|
/** The phone details the server last got, and when, so they go again only on a change. */
|
||||||
|
private const val KEY_SENT_DETAILS = "sent_details"
|
||||||
|
private const val KEY_DETAILS_DAY = "details_day"
|
||||||
|
private const val DETAILS_REFRESH_DAYS = 30L
|
||||||
|
private const val KEY_CARS = "cars"
|
||||||
|
/** The cars the last note carried, so Settings can show them after the queue empties. */
|
||||||
|
private const val KEY_LAST_CARS = "last_cars"
|
||||||
|
private const val KEY_SENT_DAY = "sent_day"
|
||||||
|
/** The daily background job of earlier builds, cancelled wherever it's still scheduled. */
|
||||||
|
private const val OLD_JOB_ID = 4_207
|
||||||
|
private const val MAX_CARS = 10
|
||||||
|
|
||||||
|
private fun prefs(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dev builds show every switch and screen, so they can be tested, but never send anything:
|
||||||
|
* testing mustn't muddy the numbers.
|
||||||
|
*/
|
||||||
|
val sends: Boolean get() = !BuildConfig.DEBUG && BuildConfig.STATS_URL.isNotBlank()
|
||||||
|
|
||||||
|
fun enabled(context: Context): Boolean = prefs(context).getBoolean(KEY_ENABLED, false)
|
||||||
|
|
||||||
|
/** Whether the driver has answered the question in setup, either way. */
|
||||||
|
fun asked(context: Context): Boolean = prefs(context).getBoolean(KEY_ASKED, false)
|
||||||
|
|
||||||
|
fun choices(context: Context): Choices {
|
||||||
|
val p = prefs(context)
|
||||||
|
return Choices(
|
||||||
|
android = p.getBoolean("share_android", true),
|
||||||
|
phone = p.getBoolean("share_phone", true),
|
||||||
|
cars = p.getBoolean("share_cars", true),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun set(context: Context, enabled: Boolean, choices: Choices = choices(context)) {
|
||||||
|
prefs(context).edit()
|
||||||
|
.putBoolean(KEY_ENABLED, enabled)
|
||||||
|
.putBoolean(KEY_ASKED, true)
|
||||||
|
.putBoolean("share_android", choices.android)
|
||||||
|
.putBoolean("share_phone", choices.phone)
|
||||||
|
.putBoolean("share_cars", choices.cars)
|
||||||
|
.apply()
|
||||||
|
// Turning it on sends nothing yet: the driver may still be ticking boxes. The first note
|
||||||
|
// waits for [choicesClosed].
|
||||||
|
if (!enabled) prefs(context).edit().remove(KEY_CARS).remove(KEY_LAST_CARS).remove(KEY_SENT_DAY).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The driver left the screen with the usage note choices: send today's note if it's due. */
|
||||||
|
fun choicesClosed(context: Context) {
|
||||||
|
if (prefs(context).getString(KEY_SENT_DAY, null) != LocalDate.now().toString()) sendSoon(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gearslip was opened: send today's note unless one already went out. */
|
||||||
|
fun onAppOpened(context: Context) {
|
||||||
|
context.getSystemService(JobScheduler::class.java)?.cancel(OLD_JOB_ID)
|
||||||
|
if (prefs(context).getString(KEY_SENT_DAY, null) != LocalDate.now().toString()) sendSoon(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val sending = AtomicBoolean(false)
|
||||||
|
@Volatile private var sendAgain = false
|
||||||
|
/** Guards the saved car list, which a session's end and a finished send both rewrite. */
|
||||||
|
private val carsLock = Any()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends off the calling thread. A call while a note is in flight sends once more after it, so
|
||||||
|
* a car recorded mid-send still goes out.
|
||||||
|
*/
|
||||||
|
private fun sendSoon(context: Context) {
|
||||||
|
if (!sends || !enabled(context)) return
|
||||||
|
if (!sending.compareAndSet(false, true)) { sendAgain = true; return }
|
||||||
|
val app = context.applicationContext
|
||||||
|
Thread({
|
||||||
|
try {
|
||||||
|
do { sendAgain = false } while (send(app) && sendAgain)
|
||||||
|
} finally {
|
||||||
|
sending.set(false)
|
||||||
|
}
|
||||||
|
}, "gearslip-stats").start()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun installId(context: Context): String {
|
||||||
|
val p = prefs(context)
|
||||||
|
return p.getString(KEY_ID, null) ?: UUID.randomUUID().toString().also { p.edit().putString(KEY_ID, it).apply() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A car session ended. [info] is null when the car hung up before it said who it is, for
|
||||||
|
* instance after rejecting the certificate; the protocol version and [outcome] still say a lot.
|
||||||
|
*/
|
||||||
|
fun recordSession(
|
||||||
|
context: Context,
|
||||||
|
info: ServiceDiscovery.HeadUnitInfo?,
|
||||||
|
protocol: String,
|
||||||
|
outcome: String,
|
||||||
|
screen: String = "",
|
||||||
|
dpi: Int = 0,
|
||||||
|
) {
|
||||||
|
if (!enabled(context)) return
|
||||||
|
val p = prefs(context)
|
||||||
|
synchronized(carsLock) { addCar(p, info, protocol, outcome, screen, dpi) }
|
||||||
|
sendSoon(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addCar(
|
||||||
|
p: android.content.SharedPreferences,
|
||||||
|
info: ServiceDiscovery.HeadUnitInfo?,
|
||||||
|
protocol: String,
|
||||||
|
outcome: String,
|
||||||
|
screen: String,
|
||||||
|
dpi: Int,
|
||||||
|
) {
|
||||||
|
val cars = runCatching { JSONArray(p.getString(KEY_CARS, "[]")) }.getOrDefault(JSONArray())
|
||||||
|
val car = JSONObject()
|
||||||
|
.put("name", info?.headUnitName.orEmpty())
|
||||||
|
.put("car", info?.carModel.orEmpty())
|
||||||
|
.put("year", info?.carYear.orEmpty())
|
||||||
|
.put("make", info?.headUnitMake.orEmpty())
|
||||||
|
.put("model", info?.headUnitModel.orEmpty())
|
||||||
|
.put("protocol", protocol)
|
||||||
|
.put("result", outcome)
|
||||||
|
if (screen.isNotEmpty()) car.put("screen", screen).put("dpi", dpi)
|
||||||
|
// The same car and outcome twice in a day is one fact, not two.
|
||||||
|
val seen = (0 until cars.length()).any { cars.optJSONObject(it)?.toString() == car.toString() }
|
||||||
|
if (!seen && cars.length() < MAX_CARS) cars.put(car)
|
||||||
|
p.edit().putString(KEY_CARS, cars.toString()).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GrapheneOS reports the same build fields as stock Pixel firmware; its own apps give it away.
|
||||||
|
private fun isGrapheneOs(context: Context) =
|
||||||
|
runCatching { context.packageManager.getPackageInfo("app.grapheneos.setupwizard", 0) }.isSuccess
|
||||||
|
|
||||||
|
private fun details(context: Context, c: Choices) = JSONObject().apply {
|
||||||
|
if (c.android) put("android", Build.VERSION.RELEASE).put("os", if (isGrapheneOs(context)) "GrapheneOS" else "other")
|
||||||
|
if (c.phone) put("phone", "${Build.MANUFACTURER} ${Build.MODEL}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
fun preview(context: Context, everything: Boolean = false): JSONObject {
|
||||||
|
val c = choices(context)
|
||||||
|
val p = prefs(context)
|
||||||
|
val note = JSONObject()
|
||||||
|
.put("id", installId(context))
|
||||||
|
.put("version", BuildConfig.VERSION_NAME)
|
||||||
|
.put("code", BuildConfig.VERSION_CODE)
|
||||||
|
.put("build", if (BuildConfig.DEBUG) "dev" else "release")
|
||||||
|
// Phone details change rarely, so they ride along only when they differ from what the
|
||||||
|
// server last got, or once a month in case it lost them. Unticked ones are left out, and
|
||||||
|
// the server keeps the last value it had.
|
||||||
|
val details = details(context, c)
|
||||||
|
val stale = p.getString(KEY_DETAILS_DAY, null)?.let { LocalDate.parse(it).plusDays(DETAILS_REFRESH_DAYS) <= LocalDate.now() } ?: true
|
||||||
|
if (details.length() > 0 && (everything || stale || details.toString() != p.getString(KEY_SENT_DETAILS, null))) {
|
||||||
|
details.keys().forEach { note.put(it, details.get(it)) }
|
||||||
|
}
|
||||||
|
if (c.cars) {
|
||||||
|
var cars = runCatching { JSONArray(p.getString(KEY_CARS, "[]")) }.getOrDefault(JSONArray())
|
||||||
|
// A car joins the queue when a drive ends and goes out straight away, so the queue
|
||||||
|
// is nearly always empty. Settings shows the last cars sent instead of a bare [].
|
||||||
|
if (everything && cars.length() == 0) {
|
||||||
|
cars = runCatching { JSONArray(p.getString(KEY_LAST_CARS, "[]")) }.getOrDefault(JSONArray())
|
||||||
|
}
|
||||||
|
note.put("cars", cars)
|
||||||
|
}
|
||||||
|
return note
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sends the note now; false when it didn't get through. */
|
||||||
|
private fun send(context: Context): Boolean {
|
||||||
|
if (!sends || !enabled(context)) return true
|
||||||
|
val endpoint = BuildConfig.STATS_URL
|
||||||
|
val p = prefs(context)
|
||||||
|
val note = synchronized(carsLock) { preview(context) }
|
||||||
|
val sentCars = note.optJSONArray("cars")?.toString()
|
||||||
|
val sentDetails = if (note.has("android") || note.has("phone")) details(context, choices(context)).toString() else null
|
||||||
|
val body = note.toString().toByteArray()
|
||||||
|
return runCatching {
|
||||||
|
val conn = (URL("$endpoint/ping").openConnection() as HttpURLConnection).apply {
|
||||||
|
requestMethod = "POST"
|
||||||
|
doOutput = true
|
||||||
|
connectTimeout = 15_000
|
||||||
|
readTimeout = 15_000
|
||||||
|
setRequestProperty("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
conn.outputStream.use { it.write(body) }
|
||||||
|
val code = conn.responseCode
|
||||||
|
conn.disconnect()
|
||||||
|
if (code in 200..299) {
|
||||||
|
// Sent: the cars start over, unless a session ended meanwhile and added one, which
|
||||||
|
// stays for the next note.
|
||||||
|
synchronized(carsLock) {
|
||||||
|
val edit = p.edit().putString(KEY_SENT_DAY, LocalDate.now().toString())
|
||||||
|
if (sentDetails != null) edit.putString(KEY_SENT_DETAILS, sentDetails).putString(KEY_DETAILS_DAY, LocalDate.now().toString())
|
||||||
|
if (sentCars != null && sentCars != "[]") edit.putString(KEY_LAST_CARS, sentCars)
|
||||||
|
if (sentCars == null || p.getString(KEY_CARS, "[]") == sentCars) edit.remove(KEY_CARS)
|
||||||
|
edit.apply()
|
||||||
|
}
|
||||||
|
GearslipLog.i("stats: note sent")
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
GearslipLog.w("stats: the server answered $code")
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}.getOrElse {
|
||||||
|
GearslipLog.w("stats: could not send: ${it.message}")
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -105,7 +105,7 @@ fun CarPreviewScreen(onBack: () -> Unit) {
|
|||||||
}
|
}
|
||||||
}, handler)
|
}, handler)
|
||||||
|
|
||||||
projector.start(reader.surface, width, height, density) { CarUi() }
|
projector.start(reader.surface, width, height, density, name = "gearslip-preview") { CarUi() }
|
||||||
|
|
||||||
onDispose {
|
onDispose {
|
||||||
projector.stop()
|
projector.stop()
|
||||||
|
|||||||
@@ -37,8 +37,9 @@ import androidx.compose.ui.unit.dp
|
|||||||
import app.seb3thehacker.gearslip.AppSettings
|
import app.seb3thehacker.gearslip.AppSettings
|
||||||
import app.seb3thehacker.gearslip.BuildConfig
|
import app.seb3thehacker.gearslip.BuildConfig
|
||||||
import app.seb3thehacker.gearslip.CertProvider
|
import app.seb3thehacker.gearslip.CertProvider
|
||||||
|
import app.seb3thehacker.gearslip.stats.UsageStats
|
||||||
|
|
||||||
private enum class Screen { HOME, LOGS, SETTINGS, CAR_PREVIEW, CERT_SETUP, SETUP_GUIDE, HELP, WHATS_NEW }
|
private enum class Screen { HOME, LOGS, SETTINGS, CAR_PREVIEW, CERT_SETUP, SETUP_GUIDE, USAGE_NOTES, HELP, WHATS_NEW, RECOMMENDED_APPS }
|
||||||
|
|
||||||
/** 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
|
||||||
@@ -53,11 +54,15 @@ fun GearslipApp(onDisconnect: () -> Unit, onRequestCallScreening: () -> Unit) {
|
|||||||
when {
|
when {
|
||||||
needsCertSetup -> Screen.CERT_SETUP
|
needsCertSetup -> Screen.CERT_SETUP
|
||||||
needsSetupGuide -> Screen.SETUP_GUIDE
|
needsSetupGuide -> Screen.SETUP_GUIDE
|
||||||
|
// Set up before usage notes existed: ask once, on the setup guide's own page.
|
||||||
|
!UsageStats.asked(context) -> Screen.USAGE_NOTES
|
||||||
else -> Screen.HOME
|
else -> Screen.HOME
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
BackHandler(enabled = screen != Screen.HOME) { screen = Screen.HOME }
|
BackHandler(enabled = screen != Screen.HOME) {
|
||||||
|
screen = if (screen == Screen.RECOMMENDED_APPS) Screen.SETTINGS else Screen.HOME
|
||||||
|
}
|
||||||
|
|
||||||
// Home stays upright; every other screen turns with the phone.
|
// Home stays upright; every other screen turns with the phone.
|
||||||
LaunchedEffect(screen) {
|
LaunchedEffect(screen) {
|
||||||
@@ -93,17 +98,21 @@ fun GearslipApp(onDisconnect: () -> Unit, onRequestCallScreening: () -> Unit) {
|
|||||||
onOpenSettings = { screen = Screen.SETTINGS },
|
onOpenSettings = { screen = Screen.SETTINGS },
|
||||||
onOpenCarPreview = { screen = Screen.CAR_PREVIEW },
|
onOpenCarPreview = { screen = Screen.CAR_PREVIEW },
|
||||||
onOpenHelp = { screen = Screen.HELP },
|
onOpenHelp = { screen = Screen.HELP },
|
||||||
onOpenWhatsNew = { screen = Screen.WHATS_NEW },
|
|
||||||
onDisconnect = onDisconnect,
|
onDisconnect = onDisconnect,
|
||||||
)
|
)
|
||||||
Screen.WHATS_NEW -> WhatsNewScreen(onBack = { screen = Screen.HOME })
|
Screen.WHATS_NEW -> WhatsNewScreen(onBack = { screen = Screen.SETTINGS })
|
||||||
Screen.LOGS -> LogsScreen(onBack = { screen = Screen.HOME })
|
Screen.LOGS -> LogsScreen(onBack = { screen = Screen.HOME })
|
||||||
Screen.SETTINGS -> SettingsScreen(onBack = { screen = Screen.HOME })
|
Screen.SETTINGS -> SettingsScreen(
|
||||||
|
onBack = { screen = Screen.HOME },
|
||||||
|
onOpenRecommendedApps = { screen = Screen.RECOMMENDED_APPS },
|
||||||
|
onOpenWhatsNew = { screen = Screen.WHATS_NEW },
|
||||||
|
onReplayTutorial = { screen = Screen.SETUP_GUIDE },
|
||||||
|
)
|
||||||
|
Screen.RECOMMENDED_APPS -> RecommendedAppsScreen(onBack = { screen = Screen.SETTINGS })
|
||||||
Screen.CAR_PREVIEW -> CarPreviewScreen(onBack = { screen = Screen.HOME })
|
Screen.CAR_PREVIEW -> CarPreviewScreen(onBack = { screen = Screen.HOME })
|
||||||
Screen.HELP -> HelpScreen(
|
Screen.HELP -> HelpScreen(
|
||||||
onBack = { screen = Screen.HOME },
|
onBack = { screen = Screen.HOME },
|
||||||
onRequestCallScreening = onRequestCallScreening,
|
onRequestCallScreening = onRequestCallScreening,
|
||||||
onReplayTutorial = { screen = Screen.SETUP_GUIDE },
|
|
||||||
)
|
)
|
||||||
Screen.CERT_SETUP -> CertSetupScreen(
|
Screen.CERT_SETUP -> CertSetupScreen(
|
||||||
onDone = {
|
onDone = {
|
||||||
@@ -114,8 +123,15 @@ fun GearslipApp(onDisconnect: () -> Unit, onRequestCallScreening: () -> Unit) {
|
|||||||
onRequestCallScreening = onRequestCallScreening,
|
onRequestCallScreening = onRequestCallScreening,
|
||||||
onDone = { screen = Screen.HOME },
|
onDone = { screen = Screen.HOME },
|
||||||
)
|
)
|
||||||
|
Screen.USAGE_NOTES -> SetupGuideScreen(
|
||||||
|
onRequestCallScreening = onRequestCallScreening,
|
||||||
|
onDone = { screen = Screen.HOME },
|
||||||
|
onlyUsageNotes = true,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// A car keyboard is open: offer the phone's own, for a passenger.
|
||||||
|
if (screen != Screen.CAR_PREVIEW) PhoneTypingBar()
|
||||||
}
|
}
|
||||||
|
|
||||||
CompatWarning()
|
CompatWarning()
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package app.seb3thehacker.gearslip.ui
|
package app.seb3thehacker.gearslip.ui
|
||||||
|
|
||||||
|
import app.seb3thehacker.gearslip.BuildConfig
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
@@ -61,14 +62,14 @@ private fun setupSteps(onRequestCallScreening: () -> Unit) = listOf(
|
|||||||
"Connect the phone to a computer with USB debugging turned on, and run this command. " +
|
"Connect the phone to a computer with USB debugging turned on, and run this command. " +
|
||||||
"It tells Android to trust Gearslip with audio from now on. Skip it and everything " +
|
"It tells Android to trust Gearslip with audio from now on. Skip it and everything " +
|
||||||
"still works, but you have to tap Start once each time you set off.",
|
"still works, but you have to tap Start once each time you set off.",
|
||||||
command = "adb shell appops set app.seb3thehacker.gearslip PROJECT_MEDIA allow",
|
command = "adb shell appops set ${BuildConfig.APPLICATION_ID} PROJECT_MEDIA allow",
|
||||||
),
|
),
|
||||||
Step(
|
Step(
|
||||||
"Optional: keep notifications readable while music plays.",
|
"Optional: keep notifications readable while music plays.",
|
||||||
"Android treats sending audio to the car like sharing your screen, and hides what your " +
|
"Android treats sending audio to the car like sharing your screen, and hides what your " +
|
||||||
"notifications say. Run this command too, and Gearslip switches that off only while " +
|
"notifications say. Run this command too, and Gearslip switches that off only while " +
|
||||||
"music is going to the car, then puts it back.",
|
"music is going to the car, then puts it back.",
|
||||||
command = "adb shell pm grant app.seb3thehacker.gearslip android.permission.WRITE_SECURE_SETTINGS",
|
command = "adb shell pm grant ${BuildConfig.APPLICATION_ID} android.permission.WRITE_SECURE_SETTINGS",
|
||||||
),
|
),
|
||||||
Step(
|
Step(
|
||||||
"Optional: let the car screen answer and decline calls.",
|
"Optional: let the car screen answer and decline calls.",
|
||||||
@@ -85,7 +86,7 @@ private fun setupSteps(onRequestCallScreening: () -> Unit) = listOf(
|
|||||||
* here too so it can be reread without repeating the guide itself. */
|
* here too so it can be reread without repeating the guide itself. */
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun HelpScreen(onBack: () -> Unit, onRequestCallScreening: () -> Unit, onReplayTutorial: () -> Unit) {
|
fun HelpScreen(onBack: () -> Unit, onRequestCallScreening: () -> Unit) {
|
||||||
val steps = remember(onRequestCallScreening) { setupSteps(onRequestCallScreening) }
|
val steps = remember(onRequestCallScreening) { setupSteps(onRequestCallScreening) }
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
@@ -109,10 +110,7 @@ fun HelpScreen(onBack: () -> Unit, onRequestCallScreening: () -> Unit, onReplayT
|
|||||||
.verticalScroll(rememberScrollState()),
|
.verticalScroll(rememberScrollState()),
|
||||||
) {
|
) {
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
FilledTonalButton(onClick = onReplayTutorial, modifier = Modifier.fillMaxWidth().height(52.dp)) {
|
Spacer(Modifier.height(16.dp))
|
||||||
Text("Replay the guided tutorial")
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(24.dp))
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(18.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(18.dp)) {
|
||||||
steps.forEachIndexed { index, step ->
|
steps.forEachIndexed { index, step ->
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ 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.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.Star
|
|
||||||
import androidx.compose.material.icons.filled.List
|
import androidx.compose.material.icons.filled.List
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
@@ -49,7 +48,6 @@ fun HomeScreen(
|
|||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
onOpenCarPreview: () -> Unit,
|
onOpenCarPreview: () -> Unit,
|
||||||
onOpenHelp: () -> Unit,
|
onOpenHelp: () -> Unit,
|
||||||
onOpenWhatsNew: () -> Unit,
|
|
||||||
onDisconnect: () -> Unit,
|
onDisconnect: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val status by SessionStatus.state.collectAsStateWithLifecycle()
|
val status by SessionStatus.state.collectAsStateWithLifecycle()
|
||||||
@@ -115,8 +113,6 @@ fun HomeScreen(
|
|||||||
ActionRow(Icons.Filled.List, "Live logs", onOpenLogs)
|
ActionRow(Icons.Filled.List, "Live logs", onOpenLogs)
|
||||||
RowDivider()
|
RowDivider()
|
||||||
ActionRow(Icons.Filled.Info, "Connection help", onOpenHelp)
|
ActionRow(Icons.Filled.Info, "Connection help", onOpenHelp)
|
||||||
RowDivider()
|
|
||||||
ActionRow(Icons.Filled.Star, "What's new", onOpenWhatsNew)
|
|
||||||
if (BuildConfig.DEBUG) {
|
if (BuildConfig.DEBUG) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
RowDivider()
|
RowDivider()
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package app.seb3thehacker.gearslip.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.imePadding
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.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.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.text.TextRange
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.input.TextFieldValue
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import app.seb3thehacker.gearslip.car.PhoneTyping
|
||||||
|
|
||||||
|
/**
|
||||||
|
* While the car screen shows its keyboard, the phone shows the same field with its own keyboard
|
||||||
|
* up, so a passenger can type comfortably. Each keystroke lands on the car screen at once.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun PhoneTypingBar(modifier: Modifier = Modifier) {
|
||||||
|
val field by PhoneTyping.field.collectAsState()
|
||||||
|
val open = field ?: return
|
||||||
|
var value by remember(open.id) { mutableStateOf(TextFieldValue(open.text, TextRange(open.text.length))) }
|
||||||
|
// The car keyboard typed too: take its text, cursor at the end.
|
||||||
|
if (open.text != value.text) value = TextFieldValue(open.text, TextRange(open.text.length))
|
||||||
|
val focus = remember { FocusRequester() }
|
||||||
|
LaunchedEffect(open.id) { runCatching { focus.requestFocus() } }
|
||||||
|
|
||||||
|
Surface(color = MaterialTheme.colorScheme.surfaceContainerHigh, modifier = modifier.fillMaxWidth().imePadding()) {
|
||||||
|
Row(Modifier.padding(horizontal = 12.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
IconButton(onClick = PhoneTyping::dismiss) {
|
||||||
|
Icon(Icons.Filled.Close, contentDescription = "Close the car keyboard")
|
||||||
|
}
|
||||||
|
OutlinedTextField(
|
||||||
|
value = value,
|
||||||
|
onValueChange = {
|
||||||
|
value = it
|
||||||
|
PhoneTyping.type(it.text)
|
||||||
|
},
|
||||||
|
label = { Text("Type for the car screen") },
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||||
|
keyboardActions = KeyboardActions(onSearch = { PhoneTyping.submit() }),
|
||||||
|
modifier = Modifier.weight(1f).focusRequester(focus),
|
||||||
|
)
|
||||||
|
IconButton(onClick = PhoneTyping::submit) {
|
||||||
|
Icon(Icons.Filled.Search, contentDescription = "Search on the car screen")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
package app.seb3thehacker.gearslip.ui
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.graphics.drawable.Drawable
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.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.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.CheckCircle
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.graphics.drawable.toBitmap
|
||||||
|
|
||||||
|
/** A free app that runs on Gearslip's car screen, and where to get it. */
|
||||||
|
private class RecommendedApp(val name: String, val packageName: String, val url: String, val about: String)
|
||||||
|
|
||||||
|
private val recommended = listOf(
|
||||||
|
"Maps" to listOf(
|
||||||
|
RecommendedApp("Vela Maps", "app.vela", "https://github.com/PimpinPumpkin/Vela", "Turn-by-turn navigation without Google."),
|
||||||
|
RecommendedApp("Organic Maps", "app.organicmaps", "https://github.com/organicmaps/organicmaps", "Offline maps and routing."),
|
||||||
|
),
|
||||||
|
"Music" to listOf(
|
||||||
|
RecommendedApp("ViVi Music", "com.vivi.vivimusic", "https://github.com/vivizzz007/vivi-music", "A YouTube Music player."),
|
||||||
|
RecommendedApp("Metrolist", "com.metrolist.music", "https://github.com/MetrolistGroup/Metrolist", "A YouTube Music player."),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Opened from Settings: the same list the setup guide ends with. */
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun RecommendedAppsScreen(onBack: () -> Unit) {
|
||||||
|
Scaffold(
|
||||||
|
containerColor = MaterialTheme.colorScheme.background,
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("Recommended apps") },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { padding ->
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding)
|
||||||
|
.padding(horizontal = 24.dp)
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
RECOMMENDED_INTRO,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
RecommendedAppsList()
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hidden for now: off, neither Settings nor the setup guide shows the list. The code stays. */
|
||||||
|
internal const val SHOW_RECOMMENDED_APPS = false
|
||||||
|
|
||||||
|
internal const val RECOMMENDED_INTRO =
|
||||||
|
"These free apps run on Gearslip's car screen. Each button opens the app's page on GitHub."
|
||||||
|
|
||||||
|
/** The same green as the launcher's "works" check. */
|
||||||
|
private val INSTALLED_GREEN = Color(0xFF43A047)
|
||||||
|
|
||||||
|
/** The apps by section, each with a GitHub button. Installed ones say so in green. */
|
||||||
|
@Composable
|
||||||
|
internal fun RecommendedAppsList(modifier: Modifier = Modifier) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
Column(modifier, verticalArrangement = Arrangement.spacedBy(20.dp)) {
|
||||||
|
for ((section, apps) in recommended) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Text(
|
||||||
|
section,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
for (app in apps) AppRow(app) { openPage(context, app.url) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AppRow(app: RecommendedApp, onOpen: () -> Unit) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val icon = remember(app.packageName) { installedIcon(context, app.packageName) }
|
||||||
|
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Box(
|
||||||
|
Modifier.size(44.dp).clip(CircleShape).background(MaterialTheme.colorScheme.surfaceContainerHigh),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
if (icon != null) {
|
||||||
|
Image(icon.toBitmap(96, 96).asImageBitmap(), contentDescription = null, Modifier.fillMaxSize())
|
||||||
|
} else {
|
||||||
|
Text(app.name.take(1), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Column(Modifier.weight(1f).padding(horizontal = 14.dp)) {
|
||||||
|
Text(app.name, style = MaterialTheme.typography.titleMedium)
|
||||||
|
Text(app.about, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
if (icon != null) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 4.dp)) {
|
||||||
|
Icon(Icons.Filled.CheckCircle, contentDescription = null, tint = INSTALLED_GREEN, modifier = Modifier.size(16.dp))
|
||||||
|
Text(
|
||||||
|
"Installed",
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = INSTALLED_GREEN,
|
||||||
|
modifier = Modifier.padding(start = 6.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
OutlinedButton(onClick = onOpen) { Text("GitHub") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun installedIcon(context: Context, packageName: String): Drawable? =
|
||||||
|
runCatching { context.packageManager.getApplicationIcon(packageName) }.getOrNull()
|
||||||
|
|
||||||
|
private fun openPage(context: Context, url: String) {
|
||||||
|
runCatching {
|
||||||
|
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package app.seb3thehacker.gearslip.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The pieces every phone settings screen is built from, so they all share one shape, colour and
|
||||||
|
* spacing: a section label, a card holding rows, and the rows themselves, with a footer note
|
||||||
|
* under the card when a section needs one.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** A titled group: label above, rows in one card, an optional note below. */
|
||||||
|
@Composable
|
||||||
|
internal fun SettingsSection(
|
||||||
|
title: String? = null,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
header: AnnotatedString? = null,
|
||||||
|
footer: String? = null,
|
||||||
|
content: @Composable ColumnScope.() -> Unit,
|
||||||
|
) {
|
||||||
|
Column(modifier.fillMaxWidth()) {
|
||||||
|
title?.let {
|
||||||
|
Text(
|
||||||
|
it,
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(start = 16.dp, bottom = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
header?.let {
|
||||||
|
Text(
|
||||||
|
it,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 12.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
SettingsCard(content = content)
|
||||||
|
footer?.let {
|
||||||
|
Text(
|
||||||
|
it,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The card behind a group of rows; also used on its own, as in the setup guide. */
|
||||||
|
@Composable
|
||||||
|
internal fun SettingsCard(modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) {
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
|
shape = MaterialTheme.shapes.extraLarge,
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Column(content = content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One row: an optional icon, a title with an optional line under it, and whatever sits at the
|
||||||
|
* end (a switch, a checkbox, an arrow). [modifier] carries the click or toggle, so the whole
|
||||||
|
* row is the touch target.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun SettingsRow(
|
||||||
|
title: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
subtitle: String? = null,
|
||||||
|
icon: ImageVector? = null,
|
||||||
|
tint: Color = MaterialTheme.colorScheme.primary,
|
||||||
|
titleColor: Color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
trailing: (@Composable () -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier.fillMaxWidth().heightIn(min = 56.dp).padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
if (icon != null) Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(24.dp))
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text(title, style = MaterialTheme.typography.bodyLarge, color = titleColor)
|
||||||
|
subtitle?.let {
|
||||||
|
Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
trailing?.let { Box { it() } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A thin line between rows in one card. */
|
||||||
|
@Composable
|
||||||
|
internal fun SettingsDivider() {
|
||||||
|
HorizontalDivider(Modifier.padding(horizontal = 16.dp), color = MaterialTheme.colorScheme.outlineVariant)
|
||||||
|
}
|
||||||
@@ -3,30 +3,37 @@ package app.seb3thehacker.gearslip.ui
|
|||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.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.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.selection.toggleable
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
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.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.Delete
|
||||||
|
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||||
|
import androidx.compose.material.icons.filled.KeyboardArrowUp
|
||||||
|
import androidx.compose.material.icons.filled.Lock
|
||||||
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
|
import androidx.compose.material.icons.filled.Settings
|
||||||
|
import androidx.compose.material.icons.filled.Star
|
||||||
|
import androidx.compose.material.icons.filled.Warning
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.FilledTonalButton
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.material3.OutlinedButton
|
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
@@ -39,14 +46,14 @@ import androidx.compose.runtime.produceState
|
|||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
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.text.font.FontWeight
|
import androidx.compose.ui.semantics.Role
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import app.seb3thehacker.gearslip.AppSettings
|
import app.seb3thehacker.gearslip.AppSettings
|
||||||
|
import app.seb3thehacker.gearslip.BuildConfig
|
||||||
import app.seb3thehacker.gearslip.CertProvider
|
import app.seb3thehacker.gearslip.CertProvider
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -54,7 +61,12 @@ import kotlinx.coroutines.withContext
|
|||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsScreen(onBack: () -> Unit) {
|
fun SettingsScreen(
|
||||||
|
onBack: () -> Unit,
|
||||||
|
onOpenRecommendedApps: () -> Unit,
|
||||||
|
onOpenWhatsNew: () -> Unit,
|
||||||
|
onReplayTutorial: () -> Unit,
|
||||||
|
) {
|
||||||
Scaffold(
|
Scaffold(
|
||||||
containerColor = MaterialTheme.colorScheme.background,
|
containerColor = MaterialTheme.colorScheme.background,
|
||||||
topBar = {
|
topBar = {
|
||||||
@@ -74,48 +86,76 @@ fun SettingsScreen(onBack: () -> Unit) {
|
|||||||
.padding(padding)
|
.padding(padding)
|
||||||
.padding(horizontal = 24.dp)
|
.padding(horizontal = 24.dp)
|
||||||
.verticalScroll(rememberScrollState()),
|
.verticalScroll(rememberScrollState()),
|
||||||
verticalArrangement = Arrangement.spacedBy(32.dp),
|
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||||
) {
|
) {
|
||||||
Spacer(Modifier.height(0.dp))
|
Spacer(Modifier.height(0.dp))
|
||||||
GearslipEnabledSection()
|
AboutSection(onOpenWhatsNew, onReplayTutorial)
|
||||||
|
ConnectionSection()
|
||||||
|
if (SHOW_RECOMMENDED_APPS) AppsSection(onOpenRecommendedApps)
|
||||||
CertificateSection()
|
CertificateSection()
|
||||||
|
SettingsSection("Usage notes", header = USAGE_STATS_INTRO) { UsageStatsRows() }
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SectionTitle(text: String) {
|
private fun Arrow() = Icon(
|
||||||
Text(
|
Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||||
text,
|
contentDescription = null,
|
||||||
style = MaterialTheme.typography.titleMedium,
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
fontWeight = FontWeight.SemiBold,
|
)
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
|
/** What's new and the setup guide, one tap from the top. */
|
||||||
|
@Composable
|
||||||
|
private fun AboutSection(onOpenWhatsNew: () -> Unit, onReplayTutorial: () -> Unit) {
|
||||||
|
SettingsSection {
|
||||||
|
SettingsRow(
|
||||||
|
"What's new",
|
||||||
|
Modifier.clickable(onClick = onOpenWhatsNew),
|
||||||
|
subtitle = "Version ${BuildConfig.VERSION_NAME}",
|
||||||
|
icon = Icons.Filled.Star,
|
||||||
|
trailing = { Arrow() },
|
||||||
)
|
)
|
||||||
|
SettingsDivider()
|
||||||
|
SettingsRow(
|
||||||
|
"Replay the setup guide",
|
||||||
|
Modifier.clickable(onClick = onReplayTutorial),
|
||||||
|
subtitle = "Go through the first-run steps again",
|
||||||
|
icon = Icons.Filled.Refresh,
|
||||||
|
trailing = { Arrow() },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Off, a stock Android Auto app or head unit answers the USB connection instead of Gearslip. */
|
/** Off, a stock Android Auto app or head unit answers the USB connection instead of Gearslip. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun GearslipEnabledSection() {
|
private fun ConnectionSection() {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
var enabled by remember { mutableStateOf(AppSettings.gearslipEnabled(context)) }
|
var enabled by remember { mutableStateOf(AppSettings.gearslipEnabled(context)) }
|
||||||
|
SettingsSection("Connection") {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
SettingsRow(
|
||||||
SectionTitle("Gearslip")
|
"Use Gearslip in the car",
|
||||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
Modifier.toggleable(enabled, role = Role.Switch) {
|
||||||
Text("Enabled", style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
|
|
||||||
Switch(checked = enabled, onCheckedChange = {
|
|
||||||
enabled = it
|
enabled = it
|
||||||
AppSettings.setGearslipEnabled(context, it)
|
AppSettings.setGearslipEnabled(context, it)
|
||||||
})
|
},
|
||||||
}
|
subtitle = if (enabled) "Gearslip answers when you plug into a car" else "Off: Android Auto can take the connection instead",
|
||||||
if (!enabled) {
|
icon = Icons.Filled.Settings,
|
||||||
Text(
|
trailing = { Switch(checked = enabled, onCheckedChange = null) },
|
||||||
"Off - stock Android Auto can take the USB connection instead.",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AppsSection(onOpenRecommendedApps: () -> Unit) {
|
||||||
|
SettingsSection("Apps") {
|
||||||
|
SettingsRow(
|
||||||
|
"Recommended apps",
|
||||||
|
Modifier.clickable(onClick = onOpenRecommendedApps),
|
||||||
|
icon = Icons.Filled.Star,
|
||||||
|
trailing = { Arrow() },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,37 +171,49 @@ private fun CertificateSection() {
|
|||||||
|
|
||||||
var pendingUri by remember { mutableStateOf<Uri?>(null) }
|
var pendingUri by remember { mutableStateOf<Uri?>(null) }
|
||||||
var notice by remember { mutableStateOf<String?>(null) }
|
var notice by remember { mutableStateOf<String?>(null) }
|
||||||
|
var downloading by remember { mutableStateOf(false) }
|
||||||
|
var detailsOpen by remember { mutableStateOf(false) }
|
||||||
val picker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
|
val picker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
|
||||||
if (uri != null) pendingUri = uri
|
if (uri != null) pendingUri = uri
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
SettingsSection("Certificate", footer = notice ?: "Changes take effect on the next connection.") {
|
||||||
SectionTitle("Certificate")
|
|
||||||
|
|
||||||
Surface(color = MaterialTheme.colorScheme.surfaceContainerHigh, shape = MaterialTheme.shapes.large) {
|
|
||||||
Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
val s = summary
|
val s = summary
|
||||||
if (s == null) {
|
val loaded = s != null && s.kind != CertProvider.Kind.SELF_SIGNED
|
||||||
Text("Checking…", style = MaterialTheme.typography.bodyMedium)
|
val hasDetails = s?.subject != null || s?.issuer != null
|
||||||
} else {
|
SettingsRow(
|
||||||
Text(s.headline, style = MaterialTheme.typography.bodyLarge)
|
s?.headline ?: "Checking…",
|
||||||
s.subject?.let { Detail("Subject", it) }
|
if (hasDetails) Modifier.clickable { detailsOpen = !detailsOpen } else Modifier,
|
||||||
s.issuer?.let { Detail("Issuer", it) }
|
subtitle = s?.validUntil?.let { "Valid until $it" },
|
||||||
s.validUntil?.let { Detail("Valid until", it) }
|
icon = if (s == null || loaded) Icons.Filled.Lock else Icons.Filled.Warning,
|
||||||
|
tint = if (s == null || loaded) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
|
||||||
|
trailing = if (hasDetails) {
|
||||||
|
{
|
||||||
|
Icon(
|
||||||
|
if (detailsOpen) Icons.Filled.KeyboardArrowUp else Icons.Filled.KeyboardArrowDown,
|
||||||
|
contentDescription = if (detailsOpen) "Hide details" else "Show details",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else null,
|
||||||
|
)
|
||||||
|
AnimatedVisibility(detailsOpen && hasDetails) {
|
||||||
|
Column(
|
||||||
|
Modifier.padding(start = 56.dp, end = 16.dp, bottom = 12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
s?.subject?.let { Detail("Subject", it) }
|
||||||
|
s?.issuer?.let { Detail("Issuer", it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
SettingsDivider()
|
||||||
|
SettingsRow(
|
||||||
Button(onClick = { picker.launch(arrayOf("*/*")) }, modifier = Modifier.fillMaxWidth().height(52.dp)) {
|
if (downloading) "Downloading…" else "Download certificate",
|
||||||
Text("Import certificate (.p12)")
|
Modifier.clickable(enabled = !downloading) {
|
||||||
}
|
downloading = true
|
||||||
|
|
||||||
FilledTonalButton(
|
|
||||||
onClick = {
|
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val result = withContext(Dispatchers.IO) {
|
val result = withContext(Dispatchers.IO) { runCatching { CertProvider.downloadAndImport(context) } }
|
||||||
runCatching { CertProvider.downloadAndImport(context) }
|
downloading = false
|
||||||
}
|
|
||||||
result.onSuccess {
|
result.onSuccess {
|
||||||
notice = "Certificate downloaded."
|
notice = "Certificate downloaded."
|
||||||
refresh++
|
refresh++
|
||||||
@@ -170,39 +222,34 @@ private fun CertificateSection() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
modifier = Modifier.fillMaxWidth().height(52.dp),
|
icon = Icons.Filled.Refresh,
|
||||||
) {
|
|
||||||
Text("Download certificate")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (summary?.kind == CertProvider.Kind.IMPORTED) {
|
|
||||||
OutlinedButton(
|
|
||||||
onClick = {
|
|
||||||
CertProvider.removeImported(context)
|
|
||||||
notice = "Removed the imported certificate."
|
|
||||||
refresh++
|
|
||||||
},
|
|
||||||
modifier = Modifier.fillMaxWidth().height(52.dp),
|
|
||||||
) { Text("Remove imported certificate") }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (summary?.kind == CertProvider.Kind.DOWNLOADED) {
|
|
||||||
OutlinedButton(
|
|
||||||
onClick = {
|
|
||||||
CertProvider.removeDownloaded(context)
|
|
||||||
notice = "Removed the downloaded certificate."
|
|
||||||
refresh++
|
|
||||||
},
|
|
||||||
modifier = Modifier.fillMaxWidth().height(52.dp),
|
|
||||||
) { Text("Remove downloaded certificate") }
|
|
||||||
}
|
|
||||||
|
|
||||||
notice?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
|
||||||
Text(
|
|
||||||
"Changes take effect on the next connection.",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
)
|
||||||
|
SettingsDivider()
|
||||||
|
SettingsRow(
|
||||||
|
"Import a certificate",
|
||||||
|
Modifier.clickable { picker.launch(arrayOf("*/*")) },
|
||||||
|
subtitle = "A .p12 file with its private key",
|
||||||
|
icon = Icons.Filled.Add,
|
||||||
|
)
|
||||||
|
val removable = when (s?.kind) {
|
||||||
|
CertProvider.Kind.IMPORTED -> "Remove imported certificate"
|
||||||
|
CertProvider.Kind.DOWNLOADED -> "Remove downloaded certificate"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
if (removable != null) {
|
||||||
|
SettingsDivider()
|
||||||
|
SettingsRow(
|
||||||
|
removable,
|
||||||
|
Modifier.clickable {
|
||||||
|
if (s?.kind == CertProvider.Kind.IMPORTED) CertProvider.removeImported(context) else CertProvider.removeDownloaded(context)
|
||||||
|
notice = if (s?.kind == CertProvider.Kind.IMPORTED) "Removed the imported certificate." else "Removed the downloaded certificate."
|
||||||
|
refresh++
|
||||||
|
},
|
||||||
|
icon = Icons.Filled.Delete,
|
||||||
|
tint = MaterialTheme.colorScheme.error,
|
||||||
|
titleColor = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pendingUri?.let { uri ->
|
pendingUri?.let { uri ->
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
package app.seb3thehacker.gearslip.ui
|
package app.seb3thehacker.gearslip.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.runtime.key
|
||||||
|
import app.seb3thehacker.gearslip.BuildConfig
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.app.AppOpsManager
|
import android.app.AppOpsManager
|
||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
@@ -39,14 +45,16 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
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 app.seb3thehacker.gearslip.AppSettings
|
import app.seb3thehacker.gearslip.AppSettings
|
||||||
import app.seb3thehacker.gearslip.call.CarCalls
|
import app.seb3thehacker.gearslip.call.CarCalls
|
||||||
import app.seb3thehacker.gearslip.car.CarSettings
|
|
||||||
import app.seb3thehacker.gearslip.car.Prefetch
|
import app.seb3thehacker.gearslip.car.Prefetch
|
||||||
import app.seb3thehacker.gearslip.host.AndroidAuto
|
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 androidx.compose.runtime.DisposableEffect
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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
|
||||||
@@ -56,11 +64,14 @@ import app.seb3thehacker.gearslip.notify.GearslipNotificationListener
|
|||||||
*/
|
*/
|
||||||
private class GuidePage(
|
private class GuidePage(
|
||||||
val title: String,
|
val title: String,
|
||||||
val explanation: String,
|
/** Plain text, or an [AnnotatedString] when part of it needs to stand out. */
|
||||||
|
val explanation: CharSequence,
|
||||||
val permissions: Array<String>? = null,
|
val permissions: Array<String>? = null,
|
||||||
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], inside the card. */
|
||||||
|
val content: (@Composable () -> Unit)? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** True once every permission in [permissions] is already granted - nothing left for this page to do. */
|
/** True once every permission in [permissions] is already granted - nothing left for this page to do. */
|
||||||
@@ -89,6 +100,21 @@ private fun hasNotificationAccess(context: Context): Boolean {
|
|||||||
return runCatching { manager.isNotificationListenerAccessGranted(component) }.getOrDefault(false)
|
return runCatching { manager.isNotificationListenerAccessGranted(component) }.getOrDefault(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The usage notes question: the setup guide's last page, and on its own the one screen people
|
||||||
|
* who set up before the notes existed see once. Leaving it without turning them on is a no.
|
||||||
|
*/
|
||||||
|
private fun usageNotesPage(context: Context) = GuidePage(
|
||||||
|
title = USAGE_STATS_TITLE,
|
||||||
|
explanation = USAGE_STATS_INTRO,
|
||||||
|
content = {
|
||||||
|
UsageStatsChoices()
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
onDispose { if (!UsageStats.asked(context)) UsageStats.set(context, false) }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<GuidePage> = buildList {
|
private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<GuidePage> = buildList {
|
||||||
// --- Safety: always first, and never skipped by an already-granted check ---------
|
// --- Safety: always first, and never skipped by an already-granted check ---------
|
||||||
add(
|
add(
|
||||||
@@ -102,13 +128,14 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
|
|||||||
)
|
)
|
||||||
|
|
||||||
// --- Permissions: skipped outright once already granted, nothing left to ask -----
|
// --- Permissions: skipped outright once already granted, nothing left to ask -----
|
||||||
if (CarSettings.pipeAudio.value && !granted(context, arrayOf(Manifest.permission.RECORD_AUDIO))) {
|
// Asked whether or not car audio is on: voice reply and the assistant need it too.
|
||||||
|
if (!granted(context, arrayOf(Manifest.permission.RECORD_AUDIO))) {
|
||||||
add(
|
add(
|
||||||
GuidePage(
|
GuidePage(
|
||||||
title = "Microphone",
|
title = "Microphone",
|
||||||
explanation = "Android will not pass a music app's audio to the car without this " +
|
explanation = "Gearslip needs the microphone for two jobs: passing your music " +
|
||||||
"permission. Gearslip does not listen to you: it copies what is already " +
|
"app's sound to the car, and hearing you when you reply to a message or ask " +
|
||||||
"playing, nothing else.",
|
"the assistant. It listens only then, and your speech stays on the phone.",
|
||||||
permissions = arrayOf(Manifest.permission.RECORD_AUDIO),
|
permissions = arrayOf(Manifest.permission.RECORD_AUDIO),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -234,7 +261,7 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
|
|||||||
explanation = "Connect the phone to a computer with USB debugging on, and run this " +
|
explanation = "Connect the phone to a computer with USB debugging on, and run this " +
|
||||||
"command. It tells Android to trust Gearslip with audio from now on. Skip it " +
|
"command. It tells Android to trust Gearslip with audio from now on. Skip it " +
|
||||||
"and everything still works, but you tap Start once each time you set off.",
|
"and everything still works, but you tap Start once each time you set off.",
|
||||||
command = "adb shell appops set app.seb3thehacker.gearslip PROJECT_MEDIA allow",
|
command = "adb shell appops set ${BuildConfig.APPLICATION_ID} PROJECT_MEDIA allow",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -245,7 +272,7 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
|
|||||||
explanation = "Android treats sending audio to the car like sharing your screen, and " +
|
explanation = "Android treats sending audio to the car like sharing your screen, and " +
|
||||||
"hides what your notifications say. This command switches that off only while " +
|
"hides what your notifications say. This command switches that off only while " +
|
||||||
"music is going to the car, then puts it back.",
|
"music is going to the car, then puts it back.",
|
||||||
command = "adb shell pm grant app.seb3thehacker.gearslip android.permission.WRITE_SECURE_SETTINGS",
|
command = "adb shell pm grant ${BuildConfig.APPLICATION_ID} android.permission.WRITE_SECURE_SETTINGS",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -261,6 +288,21 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Apps worth installing, once everything above is set up -----------------------
|
||||||
|
if (SHOW_RECOMMENDED_APPS) {
|
||||||
|
add(
|
||||||
|
GuidePage(
|
||||||
|
title = "Recommended apps",
|
||||||
|
explanation = RECOMMENDED_INTRO,
|
||||||
|
content = { RecommendedAppsList() },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- The very end, every time: usage notes. On a replay, the switch shows what was
|
||||||
|
// chosen before.
|
||||||
|
add(usageNotesPage(context))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -270,9 +312,9 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
|
|||||||
* driver takes on trust, with "Skip" sitting right next to it.
|
* driver takes on trust, with "Skip" sitting right next to it.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun SetupGuideScreen(onRequestCallScreening: () -> Unit, onDone: () -> Unit) {
|
fun SetupGuideScreen(onRequestCallScreening: () -> Unit, onDone: () -> Unit, onlyUsageNotes: Boolean = false) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val pages = remember { pages(context, onRequestCallScreening) }
|
val pages = remember { if (onlyUsageNotes) listOf(usageNotesPage(context)) else pages(context, onRequestCallScreening) }
|
||||||
var index by remember { mutableIntStateOf(0) }
|
var index by remember { mutableIntStateOf(0) }
|
||||||
|
|
||||||
// Only ours to handle once there is a previous page to return to; at the first page, the
|
// Only ours to handle once there is a previous page to return to; at the first page, the
|
||||||
@@ -307,8 +349,10 @@ fun SetupGuideScreen(onRequestCallScreening: () -> Unit, onDone: () -> Unit) {
|
|||||||
.padding(padding)
|
.padding(padding)
|
||||||
.padding(horizontal = 24.dp),
|
.padding(horizontal = 24.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.Center,
|
|
||||||
) {
|
) {
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
// A single page has no progress to show.
|
||||||
|
if (pages.size > 1) {
|
||||||
LinearProgressIndicator(
|
LinearProgressIndicator(
|
||||||
progress = { (index + 1f) / pages.size },
|
progress = { (index + 1f) / pages.size },
|
||||||
modifier = Modifier.fillMaxWidth().height(10.dp).clip(MaterialTheme.shapes.extraLarge),
|
modifier = Modifier.fillMaxWidth().height(10.dp).clip(MaterialTheme.shapes.extraLarge),
|
||||||
@@ -320,8 +364,19 @@ fun SetupGuideScreen(onRequestCallScreening: () -> Unit, onDone: () -> Unit) {
|
|||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(48.dp))
|
}
|
||||||
|
// The page scrolls between the progress bar and the buttons, so a tall page (the usage
|
||||||
|
// note's choices, say) never pushes Next off the screen. Short pages stay centred.
|
||||||
|
BoxWithConstraints(Modifier.weight(1f).fillMaxWidth()) {
|
||||||
|
key(index) {
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.heightIn(min = maxHeight)
|
||||||
|
.padding(vertical = 24.dp),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
) {
|
||||||
Surface(color = MaterialTheme.colorScheme.surfaceContainerLow, shape = MaterialTheme.shapes.extraLarge) {
|
Surface(color = MaterialTheme.colorScheme.surfaceContainerLow, shape = MaterialTheme.shapes.extraLarge) {
|
||||||
Column(Modifier.fillMaxWidth().padding(24.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
Column(Modifier.fillMaxWidth().padding(24.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
Text(
|
Text(
|
||||||
@@ -331,15 +386,17 @@ fun SetupGuideScreen(onRequestCallScreening: () -> Unit, onDone: () -> Unit) {
|
|||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
page.explanation,
|
page.explanation as? AnnotatedString ?: AnnotatedString(page.explanation.toString()),
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
page.command?.let { CopyableCommand(it) }
|
page.command?.let { CopyableCommand(it) }
|
||||||
|
page.content?.invoke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(Modifier.height(32.dp))
|
|
||||||
|
|
||||||
when {
|
when {
|
||||||
page.permissions != null && alreadyGranted -> {
|
page.permissions != null && alreadyGranted -> {
|
||||||
@@ -372,11 +429,14 @@ fun SetupGuideScreen(onRequestCallScreening: () -> Unit, onDone: () -> Unit) {
|
|||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
PrimaryRow(showBack = index > 0, onBack = { index -= 1 }) {
|
PrimaryRow(showBack = index > 0, onBack = { index -= 1 }) {
|
||||||
Button(onClick = { index += 1 }, modifier = it) { Text("Next") }
|
Button(onClick = { index += 1 }, modifier = it) {
|
||||||
|
Text(if (index == pages.lastIndex) "Done" else "Next")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
package app.seb3thehacker.gearslip.ui
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.selection.toggleable
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Info
|
||||||
|
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||||
|
import androidx.compose.material.icons.filled.KeyboardArrowUp
|
||||||
|
import androidx.compose.material.icons.filled.Send
|
||||||
|
import androidx.compose.material3.Checkbox
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
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.platform.LocalContext
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.SpanStyle
|
||||||
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.withStyle
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import app.seb3thehacker.gearslip.BuildConfig
|
||||||
|
import app.seb3thehacker.gearslip.stats.UsageStats
|
||||||
|
|
||||||
|
internal const val USAGE_STATS_TITLE = "Help show which cars work"
|
||||||
|
|
||||||
|
private const val INTRO_START =
|
||||||
|
"Gearslip can't tell which cars it works in unless drivers say so. Turn this on, and Gearslip " +
|
||||||
|
"sends a short note when you open it and after each drive, never in the background. The " +
|
||||||
|
"note carries the Gearslip version and whatever you choose to share. "
|
||||||
|
private const val INTRO_PRIVACY =
|
||||||
|
"It holds no name, account, or location, only a random number so your phone counts once. " +
|
||||||
|
"Cloudflare carries the note, and the server keeps no IP address."
|
||||||
|
private const val INTRO_END =
|
||||||
|
" Nothing helps the project more. The notes show which cars work, so the next driver knows " +
|
||||||
|
"before installing. When a car fails, the note says where the connection broke, which is " +
|
||||||
|
"often enough to fix a car no one working on Gearslip owns."
|
||||||
|
|
||||||
|
/** The pitch for the usage notes, with the privacy promise in bold so it isn't missed. */
|
||||||
|
internal val USAGE_STATS_INTRO: AnnotatedString = buildAnnotatedString {
|
||||||
|
append(INTRO_START)
|
||||||
|
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(INTRO_PRIVACY) }
|
||||||
|
append(INTRO_END)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The usage notes card for the setup guide: the same rows Settings shows. */
|
||||||
|
@Composable
|
||||||
|
internal fun UsageStatsChoices(modifier: Modifier = Modifier) {
|
||||||
|
SettingsCard(modifier) { UsageStatsRows() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The on switch, what to share, and the note itself, saved as they change.
|
||||||
|
* Rows only: the caller puts them in a card.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun ColumnScope.UsageStatsRows() {
|
||||||
|
val context = LocalContext.current
|
||||||
|
var on by remember { mutableStateOf(UsageStats.enabled(context)) }
|
||||||
|
var choices by remember { mutableStateOf(UsageStats.choices(context)) }
|
||||||
|
var noteOpen by remember { mutableStateOf(false) }
|
||||||
|
fun save() = UsageStats.set(context, on, choices)
|
||||||
|
// Nothing goes out while the choices are on screen; leaving them sends the first note.
|
||||||
|
DisposableEffect(Unit) { onDispose { UsageStats.choicesClosed(context) } }
|
||||||
|
|
||||||
|
SettingsRow(
|
||||||
|
"Send usage notes",
|
||||||
|
Modifier.toggleable(on, role = Role.Switch) { on = it; save() },
|
||||||
|
subtitle = when {
|
||||||
|
BuildConfig.DEBUG -> "Dev build: nothing is ever sent"
|
||||||
|
BuildConfig.STATS_URL.isBlank() -> "This build has no stats server, so it sends nothing"
|
||||||
|
else -> null
|
||||||
|
},
|
||||||
|
icon = Icons.Filled.Send,
|
||||||
|
trailing = { Switch(checked = on, onCheckedChange = null) },
|
||||||
|
)
|
||||||
|
AnimatedVisibility(on) {
|
||||||
|
Column {
|
||||||
|
SettingsDivider()
|
||||||
|
Text(
|
||||||
|
"Also share",
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(start = 56.dp, end = 16.dp, top = 12.dp, bottom = 4.dp),
|
||||||
|
)
|
||||||
|
Choice("Android version, and whether it's GrapheneOS", choices.android) { choices = choices.copy(android = it); save() }
|
||||||
|
Choice("Phone model", choices.phone) { choices = choices.copy(phone = it); save() }
|
||||||
|
Choice("Each car's head unit, model, year, and screen size, and whether it connected", choices.cars) {
|
||||||
|
choices = choices.copy(cars = it)
|
||||||
|
save()
|
||||||
|
}
|
||||||
|
SettingsDivider()
|
||||||
|
SettingsRow(
|
||||||
|
"What gets sent",
|
||||||
|
Modifier.clickable { noteOpen = !noteOpen },
|
||||||
|
subtitle = "Each car goes after the drive ends. The Android version and phone model go only when they change, and once a month",
|
||||||
|
icon = Icons.Filled.Info,
|
||||||
|
trailing = {
|
||||||
|
Icon(
|
||||||
|
if (noteOpen) Icons.Filled.KeyboardArrowUp else Icons.Filled.KeyboardArrowDown,
|
||||||
|
contentDescription = if (noteOpen) "Hide the note" else "Show the note",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
AnimatedVisibility(noteOpen) {
|
||||||
|
// Rebuilt from the saved choices each time one changes, so it always matches.
|
||||||
|
val note = remember(choices) { UsageStats.preview(context, everything = true).toString(2) }
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainerLowest,
|
||||||
|
shape = MaterialTheme.shapes.medium,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp, bottom = 16.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
note,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
modifier = Modifier.padding(12.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A share choice: the label lines up with the rows' titles, the checkbox sits at the end. */
|
||||||
|
@Composable
|
||||||
|
private fun Choice(label: String, checked: Boolean, onChange: (Boolean) -> Unit) {
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth().toggleable(checked, role = Role.Checkbox, onValueChange = onChange)
|
||||||
|
.padding(start = 56.dp, end = 16.dp, top = 4.dp, bottom = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(label, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f).padding(end = 12.dp))
|
||||||
|
Checkbox(checked = checked, onCheckedChange = null, modifier = Modifier.padding(vertical = 8.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package app.seb3thehacker.gearslip
|
|||||||
|
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertNotNull
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -12,6 +13,10 @@ import org.junit.Test
|
|||||||
* video sink on channel 1 and a microphone source on channel 3. Picking the right one out of that
|
* video sink on channel 1 and a microphone source on channel 3. Picking the right one out of that
|
||||||
* set is the whole job of [ServiceDiscovery.findAudioServices], and getting it wrong is silent:
|
* set is the whole job of [ServiceDiscovery.findAudioServices], and getting it wrong is silent:
|
||||||
* audio simply never arrives.
|
* audio simply never arrives.
|
||||||
|
*
|
||||||
|
* The 2022 Dacia Jogger (LGE ULC 4.5, protocol 1.5) offers four: AAC media on channel 3, AAC
|
||||||
|
* guidance on 4, PCM media on 5 and PCM guidance on 6. Gearslip sends PCM, so it has to pick
|
||||||
|
* channel 5 even though channel 3 comes first; PCM pushed into the AAC sink plays as silence.
|
||||||
*/
|
*/
|
||||||
class ServiceDiscoveryAudioTest {
|
class ServiceDiscoveryAudioTest {
|
||||||
|
|
||||||
@@ -91,4 +96,34 @@ class ServiceDiscoveryAudioTest {
|
|||||||
assertNotNull(ServiceDiscovery.findVideoService(uconnectResponse()))
|
assertNotNull(ServiceDiscovery.findVideoService(uconnectResponse()))
|
||||||
assertEquals(1, ServiceDiscovery.findVideoService(uconnectResponse())!!.serviceId)
|
assertEquals(1, ServiceDiscovery.findVideoService(uconnectResponse())!!.serviceId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun daciaResponse(): ByteArray =
|
||||||
|
audioSink(id = 3, codec = 4, audioType = 3, config = audioConfig(48_000, 16, 2)) +
|
||||||
|
audioSink(id = 4, codec = 4, audioType = 1, config = audioConfig(16_000, 16, 1)) +
|
||||||
|
audioSink(id = 5, codec = 1, audioType = 3, config = audioConfig(48_000, 16, 2)) +
|
||||||
|
audioSink(id = 6, codec = 1, audioType = 1, config = audioConfig(16_000, 16, 1))
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the Uconnect's only media sink is picked`() {
|
||||||
|
assertEquals(5, ServiceDiscovery.pickMediaSink(ServiceDiscovery.findAudioServices(uconnectResponse()))!!.serviceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a PCM media sink wins over an earlier AAC one`() {
|
||||||
|
val sink = ServiceDiscovery.pickMediaSink(ServiceDiscovery.findAudioServices(daciaResponse()))!!
|
||||||
|
assertEquals(5, sink.serviceId)
|
||||||
|
assertEquals("AUDIO_PCM", sink.codecName)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `with only an AAC media sink, that one is used`() {
|
||||||
|
val aacOnly = audioSink(id = 3, codec = 4, audioType = 3, config = audioConfig(48_000, 16, 2))
|
||||||
|
assertEquals(3, ServiceDiscovery.pickMediaSink(ServiceDiscovery.findAudioServices(aacOnly))!!.serviceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `no media sink means none is picked`() {
|
||||||
|
val guidanceOnly = audioSink(id = 6, codec = 1, audioType = 1, config = audioConfig(16_000, 16, 1))
|
||||||
|
assertNull(ServiceDiscovery.pickMediaSink(ServiceDiscovery.findAudioServices(guidanceOnly)))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package app.seb3thehacker.gearslip
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LIVI 2.0.1 offers a second screen alongside the main one, and lists that screen's input first:
|
||||||
|
* channel 3: video sink, no display id (the main screen)
|
||||||
|
* channel 19: video sink, display_id = 1, display_type = 1 (a cluster)
|
||||||
|
* channel 20: input service with only display_id = 1
|
||||||
|
* channel 8: input service with a 1280x720 touchscreen and keys (the main screen)
|
||||||
|
* Taking the first input service left every touch on the main screen ignored.
|
||||||
|
*/
|
||||||
|
class ServiceDiscoveryDisplayTest {
|
||||||
|
|
||||||
|
private fun varintField(field: Int, value: Long) = Protobuf.varintField(field, value)
|
||||||
|
|
||||||
|
private fun lenField(field: Int, body: ByteArray): ByteArray =
|
||||||
|
Protobuf.varint(((field shl 3) or 2).toLong()) + Protobuf.varint(body.size.toLong()) + body
|
||||||
|
|
||||||
|
private fun videoSink(id: Int, displayId: Int?): ByteArray {
|
||||||
|
val config = varintField(1, 2L) + varintField(2, 1L) + varintField(5, 180L)
|
||||||
|
var sink = varintField(1, 3L) + lenField(4, config)
|
||||||
|
if (displayId != null) sink += varintField(6, displayId.toLong()) + varintField(7, 1L)
|
||||||
|
return lenField(1, varintField(1, id.toLong()) + lenField(3, sink))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun input(id: Int, body: ByteArray) = lenField(1, varintField(1, id.toLong()) + lenField(4, body))
|
||||||
|
|
||||||
|
private fun liviResponse(): ByteArray =
|
||||||
|
videoSink(3, null) +
|
||||||
|
videoSink(19, 1) +
|
||||||
|
input(20, varintField(5, 1L)) +
|
||||||
|
input(8, lenField(1, Protobuf.varint(3L) + Protobuf.varint(4L)) + lenField(2, varintField(1, 1280L) + varintField(2, 720L)))
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun picksTheMainScreensVideo() {
|
||||||
|
val video = ServiceDiscovery.findVideoService(liviResponse())!!
|
||||||
|
assertEquals(3, video.serviceId)
|
||||||
|
assertEquals(0, video.displayId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun picksTheInputForTheProjectedScreen() {
|
||||||
|
val input = ServiceDiscovery.findInputService(liviResponse(), displayId = 0)!!
|
||||||
|
assertEquals(8, input.serviceId)
|
||||||
|
assertEquals(1280, input.touchWidth)
|
||||||
|
assertEquals(720, input.touchHeight)
|
||||||
|
assertEquals(listOf(3, 4), input.keycodesSupported)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package app.seb3thehacker.gearslip
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The 2018 Uconnect's sensor and mic services, from `archive/spike/car-run-2026-09-16-run2.log`.
|
||||||
|
* Channel 7 offers sensors in Service field 2; channel 3 is the mic in field 5. Reading the wrong
|
||||||
|
* field finds nothing at all, so the sensor channel never opens.
|
||||||
|
*/
|
||||||
|
class ServiceDiscoverySensorTest {
|
||||||
|
|
||||||
|
private fun varintField(field: Int, value: Long) = Protobuf.varintField(field, value)
|
||||||
|
|
||||||
|
private fun lenField(field: Int, body: ByteArray): ByteArray =
|
||||||
|
Protobuf.varint(((field shl 3) or 2).toLong()) + Protobuf.varint(body.size.toLong()) + body
|
||||||
|
|
||||||
|
private fun micSource() = lenField(
|
||||||
|
1,
|
||||||
|
varintField(1, 3L) + lenField(5, varintField(1, 1L) + lenField(2, varintField(1, 16_000L))),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun sensorSource(types: List<Int>): ByteArray {
|
||||||
|
val sensors = types.fold(ByteArray(0)) { acc, t -> acc + lenField(1, varintField(1, t.toLong())) }
|
||||||
|
return lenField(1, varintField(1, 7L) + lenField(2, sensors + varintField(2, 256L)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun findsUconnectSensorsInFieldTwo() {
|
||||||
|
val response = micSource() + sensorSource(listOf(1, 3, 10, 13, 20, 21))
|
||||||
|
val sensors = ServiceDiscovery.findSensorService(response)
|
||||||
|
assertNotNull(sensors)
|
||||||
|
sensors!!
|
||||||
|
assertEquals(7, sensors.serviceId)
|
||||||
|
assertEquals(listOf(1, 3, 10, 13, 20, 21), sensors.types)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,3 +22,5 @@ kotlin.code.style=official
|
|||||||
# thereby reducing the size of the R class for that library
|
# thereby reducing the size of the R class for that library
|
||||||
android.nonTransitiveRClass=true
|
android.nonTransitiveRClass=true
|
||||||
android.dependency.useConstraints=false
|
android.dependency.useConstraints=false
|
||||||
|
# Where opted-in phones send their daily usage note (stats-worker/). Blank sends nothing.
|
||||||
|
gearslipStatsUrl=https://gearslip-stats.gearslip.workers.dev
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
.dev.vars
|
||||||
|
.wrangler/
|
||||||
|
node_modules/
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
-- One row per phone per day: the first note that day counts, later ones update it.
|
||||||
|
CREATE TABLE IF NOT EXISTS pings (
|
||||||
|
day TEXT NOT NULL, -- YYYY-MM-DD, UTC
|
||||||
|
uid TEXT NOT NULL, -- salted hash of the phone's random install ID
|
||||||
|
version TEXT NOT NULL,
|
||||||
|
code INTEGER,
|
||||||
|
build TEXT,
|
||||||
|
android TEXT, -- these four are from older builds; details now live in phones
|
||||||
|
os TEXT,
|
||||||
|
phone TEXT,
|
||||||
|
car_today INTEGER,
|
||||||
|
PRIMARY KEY (day, uid)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Each car a phone saw that day, and how the session ended.
|
||||||
|
CREATE TABLE IF NOT EXISTS cars (
|
||||||
|
day TEXT NOT NULL,
|
||||||
|
uid TEXT NOT NULL,
|
||||||
|
name TEXT, car TEXT, year TEXT, make TEXT, model TEXT,
|
||||||
|
protocol TEXT,
|
||||||
|
result TEXT,
|
||||||
|
screen TEXT, -- usable pixels, e.g. 1280x720
|
||||||
|
dpi INTEGER,
|
||||||
|
PRIMARY KEY (day, uid, name, car, year, protocol, result)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS pings_uid ON pings (uid);
|
||||||
|
|
||||||
|
-- GitHub's running download count for each release's APK, saved once a day.
|
||||||
|
CREATE TABLE IF NOT EXISTS downloads (
|
||||||
|
day TEXT NOT NULL,
|
||||||
|
tag TEXT NOT NULL,
|
||||||
|
count INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (day, tag)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Which releases are pre-releases, so the dashboard can pick out the latest full release.
|
||||||
|
CREATE TABLE IF NOT EXISTS releases (
|
||||||
|
tag TEXT PRIMARY KEY,
|
||||||
|
prerelease INTEGER NOT NULL,
|
||||||
|
published TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Each phone's latest details. Phones send them only when they change (and once a month), so
|
||||||
|
-- a field a note leaves out keeps its last value.
|
||||||
|
CREATE TABLE IF NOT EXISTS phones (
|
||||||
|
uid TEXT PRIMARY KEY,
|
||||||
|
android TEXT,
|
||||||
|
os TEXT,
|
||||||
|
phone TEXT,
|
||||||
|
updated TEXT
|
||||||
|
);
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
// Gearslip usage notes: opt-in phones POST one short note a day, and a private dashboard counts
|
||||||
|
// them. No IP address is stored, and the phone's random ID is kept only as a salted hash.
|
||||||
|
|
||||||
|
const MAX_BODY = 8 * 1024
|
||||||
|
const MAX_CARS = 10
|
||||||
|
const short = (v, n = 80) => (typeof v === 'string' ? v.slice(0, n) : null)
|
||||||
|
|
||||||
|
// The tables as schema.sql has them, plus the columns added since, so a database made by an
|
||||||
|
// older version catches up on its own. Each runs once per Worker instance; an ALTER whose
|
||||||
|
// column already exists fails, and that's fine.
|
||||||
|
const SCHEMA = [
|
||||||
|
`CREATE TABLE IF NOT EXISTS pings (day TEXT NOT NULL, uid TEXT NOT NULL, version TEXT NOT NULL,
|
||||||
|
code INTEGER, build TEXT, android TEXT, os TEXT, phone TEXT, car_today INTEGER, PRIMARY KEY (day, uid))`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS cars (day TEXT NOT NULL, uid TEXT NOT NULL, name TEXT, car TEXT, year TEXT,
|
||||||
|
make TEXT, model TEXT, protocol TEXT, result TEXT, screen TEXT, dpi INTEGER,
|
||||||
|
PRIMARY KEY (day, uid, name, car, year, protocol, result))`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS pings_uid ON pings (uid)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS downloads (day TEXT NOT NULL, tag TEXT NOT NULL, count INTEGER NOT NULL, PRIMARY KEY (day, tag))`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS releases (tag TEXT PRIMARY KEY, prerelease INTEGER NOT NULL, published TEXT)`,
|
||||||
|
// Each phone's latest details. Phones send them only when they change, so a field a note
|
||||||
|
// leaves out keeps its last value.
|
||||||
|
`CREATE TABLE IF NOT EXISTS phones (uid TEXT PRIMARY KEY, android TEXT, os TEXT, phone TEXT, updated TEXT)`,
|
||||||
|
]
|
||||||
|
const ADDED_COLUMNS = [
|
||||||
|
`ALTER TABLE pings ADD COLUMN os TEXT`,
|
||||||
|
`ALTER TABLE cars ADD COLUMN screen TEXT`,
|
||||||
|
`ALTER TABLE cars ADD COLUMN dpi INTEGER`,
|
||||||
|
// Older builds sent details in every note: carry the latest of those over once.
|
||||||
|
`INSERT OR IGNORE INTO phones (uid, android, os, phone, updated)
|
||||||
|
SELECT uid, android, os, phone, day FROM pings p
|
||||||
|
WHERE day = (SELECT MAX(day) FROM pings q WHERE q.uid = p.uid)
|
||||||
|
AND (android IS NOT NULL OR phone IS NOT NULL)`,
|
||||||
|
]
|
||||||
|
let schemaReady = null
|
||||||
|
function ensureSchema(env) {
|
||||||
|
schemaReady ??= (async () => {
|
||||||
|
for (const sql of SCHEMA) await env.DB.prepare(sql).run()
|
||||||
|
for (const sql of ADDED_COLUMNS) await env.DB.prepare(sql).run().catch(() => {})
|
||||||
|
})().catch((e) => { schemaReady = null; throw e })
|
||||||
|
return schemaReady
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
async fetch(request, env) {
|
||||||
|
await ensureSchema(env)
|
||||||
|
const url = new URL(request.url)
|
||||||
|
if (request.method === 'POST' && url.pathname === '/ping') return ping(request, env)
|
||||||
|
if (request.method === 'GET' && (url.pathname === '/' || url.pathname === '/api/summary')) {
|
||||||
|
if (!env.DASHBOARD_KEY || url.searchParams.get('key') !== env.DASHBOARD_KEY) {
|
||||||
|
return new Response('Not found', { status: 404 })
|
||||||
|
}
|
||||||
|
// Opening the dashboard refreshes today's counts, at most every few minutes, so it matches
|
||||||
|
// GitHub instead of showing the morning's numbers. GitHub allows 60 requests an hour.
|
||||||
|
const stale = Date.now() - lastFetched > REFRESH_MS
|
||||||
|
await snapshotDownloads(env, stale).catch((e) => console.log('github:', e.message))
|
||||||
|
const summary = await summarize(env)
|
||||||
|
if (url.pathname === '/api/summary') return Response.json(summary)
|
||||||
|
return new Response(dashboard(summary), { headers: { 'content-type': 'text/html; charset=utf-8' } })
|
||||||
|
}
|
||||||
|
return new Response('Not found', { status: 404 })
|
||||||
|
},
|
||||||
|
|
||||||
|
async scheduled(event, env) {
|
||||||
|
await ensureSchema(env)
|
||||||
|
await snapshotDownloads(env, true)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Saves each release's APK download count for today. GitHub only keeps a running total, so
|
||||||
|
// downloads per day come from the difference between days.
|
||||||
|
let lastFetched = 0
|
||||||
|
const REFRESH_MS = 5 * 60 * 1000
|
||||||
|
|
||||||
|
async function snapshotDownloads(env, force) {
|
||||||
|
if (!env.GITHUB_REPO) return
|
||||||
|
const day = today()
|
||||||
|
// Today's counts and the release list both have to be there; either missing means fetch again.
|
||||||
|
if (!force && (await env.DB.prepare(`SELECT 1 WHERE EXISTS (SELECT 1 FROM downloads WHERE day = ?)
|
||||||
|
AND EXISTS (SELECT 1 FROM releases)`).bind(day).first())) return
|
||||||
|
const headers = { 'user-agent': 'gearslip-stats', accept: 'application/vnd.github+json' }
|
||||||
|
if (env.GITHUB_TOKEN) headers.authorization = `Bearer ${env.GITHUB_TOKEN}`
|
||||||
|
const res = await fetch(`https://api.github.com/repos/${env.GITHUB_REPO}/releases?per_page=100`, { headers })
|
||||||
|
if (!res.ok) throw new Error(`GitHub answered ${res.status}`)
|
||||||
|
const statements = (await res.json()).filter((release) => !release.draft).flatMap((release) => {
|
||||||
|
const count = release.assets.filter((a) => a.name.endsWith('.apk')).reduce((n, a) => n + a.download_count, 0)
|
||||||
|
return [
|
||||||
|
env.DB.prepare('INSERT OR REPLACE INTO downloads (day, tag, count) VALUES (?, ?, ?)').bind(day, release.tag_name, count),
|
||||||
|
env.DB.prepare('INSERT OR REPLACE INTO releases (tag, prerelease, published) VALUES (?, ?, ?)')
|
||||||
|
.bind(release.tag_name, release.prerelease ? 1 : 0, release.published_at),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
if (statements.length) await env.DB.batch(statements)
|
||||||
|
lastFetched = Date.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
const today = () => new Date().toISOString().slice(0, 10)
|
||||||
|
// Tags are "v0.1.10-alpha" or "0.0.05-alpha"; the app reports "0.1.10-alpha".
|
||||||
|
const versionOf = (tag) => tag.replace(/^v/, '')
|
||||||
|
|
||||||
|
async function ping(request, env) {
|
||||||
|
const text = await request.text()
|
||||||
|
if (text.length > MAX_BODY) return new Response('Too large', { status: 413 })
|
||||||
|
let note
|
||||||
|
try {
|
||||||
|
note = JSON.parse(text)
|
||||||
|
} catch {
|
||||||
|
return new Response('Bad JSON', { status: 400 })
|
||||||
|
}
|
||||||
|
const id = short(note.id, 64)
|
||||||
|
const version = short(note.version, 32)
|
||||||
|
if (!id || !version) return new Response('Missing id or version', { status: 400 })
|
||||||
|
|
||||||
|
const uid = await hash(`${env.SALT}:${id}`)
|
||||||
|
const day = today()
|
||||||
|
const android = short(note.android, 16), os = short(note.os, 16), phone = short(note.phone)
|
||||||
|
const statements = [
|
||||||
|
env.DB.prepare(
|
||||||
|
`INSERT INTO pings (day, uid, version, code, build) VALUES (?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT (day, uid) DO UPDATE SET version = excluded.version, code = excluded.code, build = excluded.build`,
|
||||||
|
).bind(day, uid, version, Number.isInteger(note.code) ? note.code : null, short(note.build, 16)),
|
||||||
|
]
|
||||||
|
if (android || os || phone) {
|
||||||
|
statements.push(
|
||||||
|
env.DB.prepare(
|
||||||
|
`INSERT INTO phones (uid, android, os, phone, updated) VALUES (?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT (uid) DO UPDATE SET android = COALESCE(excluded.android, android),
|
||||||
|
os = COALESCE(excluded.os, os), phone = COALESCE(excluded.phone, phone), updated = excluded.updated`,
|
||||||
|
).bind(uid, android, os, phone, day),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
for (const car of Array.isArray(note.cars) ? note.cars.slice(0, MAX_CARS) : []) {
|
||||||
|
statements.push(
|
||||||
|
env.DB.prepare(
|
||||||
|
`INSERT OR IGNORE INTO cars (day, uid, name, car, year, make, model, protocol, result, screen, dpi)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
).bind(day, uid, short(car.name) ?? '', short(car.car) ?? '', short(car.year, 8) ?? '',
|
||||||
|
short(car.make) ?? '', short(car.model) ?? '', short(car.protocol, 8) ?? '', short(car.result) ?? '',
|
||||||
|
short(car.screen, 16), Number.isInteger(car.dpi) ? car.dpi : null),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
await env.DB.batch(statements)
|
||||||
|
return new Response(null, { status: 204 })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hash(text) {
|
||||||
|
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text))
|
||||||
|
return [...new Uint8Array(digest)].slice(0, 16).map((b) => b.toString(16).padStart(2, '0')).join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function summarize(env) {
|
||||||
|
const all = async (sql) => (await env.DB.prepare(sql).all()).results
|
||||||
|
const one = async (sql) => (await env.DB.prepare(sql).first()) ?? {}
|
||||||
|
const latest = await all(`SELECT d.tag, d.count, r.prerelease, r.published FROM downloads d
|
||||||
|
LEFT JOIN releases r ON r.tag = d.tag
|
||||||
|
WHERE d.day = (SELECT MAX(day) FROM downloads)`)
|
||||||
|
const seen = Object.fromEntries((await all(`SELECT version, COUNT(DISTINCT uid) AS n FROM pings
|
||||||
|
WHERE build = 'release' GROUP BY version`)).map((r) => [r.version, r.n]))
|
||||||
|
// The newest full release, the one most people are sent to; pre-releases don't count.
|
||||||
|
const newest = await one(`SELECT tag FROM releases WHERE prerelease = 0 ORDER BY published DESC LIMIT 1`)
|
||||||
|
const latestRelease = newest.tag ? { tag: newest.tag, downloads: latest.find((r) => r.tag === newest.tag)?.count ?? 0 } : null
|
||||||
|
const releases = latest.map((r) => ({
|
||||||
|
tag: r.tag,
|
||||||
|
kind: r.prerelease ? 'Pre-release' : r.prerelease === 0 ? 'Release' : '',
|
||||||
|
published: (r.published ?? '').slice(0, 10),
|
||||||
|
downloads: r.count,
|
||||||
|
phones: seen[versionOf(r.tag)] ?? 0,
|
||||||
|
}))
|
||||||
|
.map((r) => ({ ...r, share: r.phones && r.downloads ? `${Math.round((100 * r.phones) / r.downloads)}%` : '' }))
|
||||||
|
.sort((a, b) => b.published.localeCompare(a.published))
|
||||||
|
// Only releases that have sent anything can say what share of people send; older ones predate the notes.
|
||||||
|
const counted = releases.filter((r) => r.phones > 0)
|
||||||
|
const countedDownloads = counted.reduce((n, r) => n + r.downloads, 0)
|
||||||
|
const countedPhones = counted.reduce((n, r) => n + r.phones, 0)
|
||||||
|
const totals = await all(`SELECT day, SUM(count) AS total FROM downloads GROUP BY day ORDER BY day`)
|
||||||
|
const newPhones = Object.fromEntries((await all(`SELECT first_day AS day, COUNT(*) AS n FROM
|
||||||
|
(SELECT uid, MIN(day) AS first_day FROM pings WHERE build = 'release' GROUP BY uid)
|
||||||
|
GROUP BY first_day`)).map((r) => [r.day, r.n]))
|
||||||
|
const perDay = totals.slice(1).map((r, i) => ({ day: r.day, downloads: r.total - totals[i].total, new_phones: newPhones[r.day] ?? 0 }))
|
||||||
|
return {
|
||||||
|
latestRelease,
|
||||||
|
releases,
|
||||||
|
totalDownloads: releases.reduce((n, r) => n + r.downloads, 0),
|
||||||
|
share: countedDownloads ? Math.round((100 * countedPhones) / countedDownloads) : null,
|
||||||
|
perDay: perDay.slice(-60).reverse(),
|
||||||
|
daily: await all(`SELECT p.day, COUNT(*) AS phones,
|
||||||
|
SUM(EXISTS (SELECT 1 FROM cars c WHERE c.uid = p.uid AND c.day = p.day
|
||||||
|
AND c.result LIKE 'connected%')) AS in_a_car,
|
||||||
|
SUM(p.day = f.first_day) AS new_phones
|
||||||
|
FROM pings p JOIN (SELECT uid, MIN(day) AS first_day FROM pings GROUP BY uid) f
|
||||||
|
ON f.uid = p.uid
|
||||||
|
WHERE p.day >= date('now', '-60 days') GROUP BY p.day ORDER BY p.day`),
|
||||||
|
// Android runs no code at uninstall, so a phone that stopped sending is the nearest sign.
|
||||||
|
new30: (await one(`SELECT COUNT(*) AS n FROM (SELECT uid, MIN(day) AS first_day FROM pings GROUP BY uid)
|
||||||
|
WHERE first_day >= date('now', '-30 days')`)).n ?? 0,
|
||||||
|
quiet: (await one(`SELECT COUNT(*) AS n FROM (SELECT uid, MAX(day) AS last_day FROM pings GROUP BY uid)
|
||||||
|
WHERE last_day < date('now', '-14 days')`)).n ?? 0,
|
||||||
|
everyone: (await one(`SELECT COUNT(DISTINCT uid) AS n FROM pings`)).n ?? 0,
|
||||||
|
active7: (await one(`SELECT COUNT(DISTINCT uid) AS n FROM pings WHERE day >= date('now', '-7 days')`)).n ?? 0,
|
||||||
|
active30: (await one(`SELECT COUNT(DISTINCT uid) AS n FROM pings WHERE day >= date('now', '-30 days')`)).n ?? 0,
|
||||||
|
driving30: (await one(`SELECT COUNT(DISTINCT uid) AS n FROM cars
|
||||||
|
WHERE result LIKE 'connected%' AND day >= date('now', '-30 days')`)).n ?? 0,
|
||||||
|
versions: await all(`SELECT version, COUNT(DISTINCT uid) AS phones FROM pings
|
||||||
|
WHERE day >= date('now', '-7 days') GROUP BY version ORDER BY phones DESC`),
|
||||||
|
// Details come from each phone's latest, counted for phones that sent anything in 30 days.
|
||||||
|
android: await all(`SELECT android, COUNT(*) AS phones FROM phones
|
||||||
|
WHERE android IS NOT NULL AND uid IN (SELECT uid FROM pings WHERE day >= date('now', '-30 days'))
|
||||||
|
GROUP BY android ORDER BY phones DESC`),
|
||||||
|
screens: await all(`SELECT screen, COUNT(DISTINCT uid) AS phones FROM cars
|
||||||
|
WHERE screen IS NOT NULL GROUP BY screen ORDER BY phones DESC`),
|
||||||
|
os: await all(`SELECT os, COUNT(*) AS phones FROM phones
|
||||||
|
WHERE os IS NOT NULL AND uid IN (SELECT uid FROM pings WHERE day >= date('now', '-30 days'))
|
||||||
|
GROUP BY os ORDER BY phones DESC`),
|
||||||
|
phones: await all(`SELECT phone, COUNT(*) AS phones FROM phones
|
||||||
|
WHERE phone IS NOT NULL AND uid IN (SELECT uid FROM pings WHERE day >= date('now', '-30 days'))
|
||||||
|
GROUP BY phone ORDER BY phones DESC LIMIT 30`),
|
||||||
|
cars: await all(`SELECT name, car, year, make, model, protocol,
|
||||||
|
GROUP_CONCAT(DISTINCT screen || CASE WHEN dpi THEN ' @ ' || dpi || ' dpi' ELSE '' END) AS screen,
|
||||||
|
SUM(result = 'connected') AS connected,
|
||||||
|
SUM(result != 'connected') AS failed,
|
||||||
|
GROUP_CONCAT(DISTINCT CASE WHEN result != 'connected' THEN result END) AS failures,
|
||||||
|
COUNT(DISTINCT uid) AS phones, MAX(day) AS last_seen
|
||||||
|
FROM cars GROUP BY name, car, year, make, model, protocol
|
||||||
|
ORDER BY phones DESC, connected DESC LIMIT 200`),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const esc = (v) => String(v ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c])
|
||||||
|
|
||||||
|
function table(rows, columns) {
|
||||||
|
if (!rows.length) return '<p class="muted">Nothing yet.</p>'
|
||||||
|
const head = columns.map(([, label]) => `<th>${esc(label)}</th>`).join('')
|
||||||
|
const body = rows.map((r) => `<tr>${columns.map(([key]) => `<td>${esc(r[key])}</td>`).join('')}</tr>`).join('')
|
||||||
|
return `<div class="scroll"><table><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table></div>`
|
||||||
|
}
|
||||||
|
|
||||||
|
function chart(daily) {
|
||||||
|
if (!daily.length) return '<p class="muted">No notes yet.</p>'
|
||||||
|
const w = 600, h = 160, max = Math.max(...daily.map((d) => d.phones), 1), bw = w / daily.length
|
||||||
|
const bars = daily.map((d, i) => {
|
||||||
|
const ph = (d.phones / max) * (h - 20), ch = ((d.in_a_car ?? 0) / max) * (h - 20)
|
||||||
|
return `<rect x="${i * bw + 1}" y="${h - ph}" width="${Math.max(bw - 2, 1)}" height="${ph}" class="all"><title>${esc(d.day)}: ${d.phones} phones</title></rect>` +
|
||||||
|
`<rect x="${i * bw + 1}" y="${h - ch}" width="${Math.max(bw - 2, 1)}" height="${ch}" class="car"><title>${esc(d.day)}: ${d.in_a_car ?? 0} in a car</title></rect>`
|
||||||
|
}).join('')
|
||||||
|
return `<svg viewBox="0 0 ${w} ${h}" role="img" aria-label="Phones per day">${bars}</svg>
|
||||||
|
<p class="muted"><span class="key all"></span> phones that day <span class="key car"></span> of those, in a car</p>`
|
||||||
|
}
|
||||||
|
|
||||||
|
function dashboard(s) {
|
||||||
|
return `<!doctype html><html lang="en"><head><meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1"><title>Gearslip usage</title>
|
||||||
|
<style>
|
||||||
|
:root { --bg:#fafafa; --fg:#1a1a1a; --muted:#666; --line:#ddd; --all:#9db4d6; --car:#2e7d32; }
|
||||||
|
@media (prefers-color-scheme: dark) { :root { --bg:#121212; --fg:#eee; --muted:#999; --line:#333; --all:#3d5a80; --car:#66bb6a; } }
|
||||||
|
body { background:var(--bg); color:var(--fg); font:16px/1.5 system-ui, sans-serif; margin:0 auto; padding:16px; max-width:960px; }
|
||||||
|
h1 { font-size:1.5rem; margin:0 0 4px; } h2 { font-size:1.1rem; margin:28px 0 8px; }
|
||||||
|
.muted { color:var(--muted); font-size:.9rem; }
|
||||||
|
.stats { display:grid; grid-template-columns:repeat(auto-fit, minmax(150px, 1fr)); gap:12px; }
|
||||||
|
.stat.big { margin-bottom:12px; padding:20px; } .stat.big b { font-size:3.2rem; line-height:1.1; }
|
||||||
|
.stat { border:1px solid var(--line); border-radius:12px; padding:12px; } .stat b { display:block; font-size:1.8rem; }
|
||||||
|
svg { width:100%; height:auto; } rect.all { fill:var(--all); } rect.car { fill:var(--car); }
|
||||||
|
.key { display:inline-block; width:10px; height:10px; border-radius:2px; margin:0 4px 0 12px; }
|
||||||
|
.key.all { background:var(--all); } .key.car { background:var(--car); }
|
||||||
|
.scroll { overflow-x:auto; } table { border-collapse:collapse; width:100%; font-size:.9rem; }
|
||||||
|
th, td { text-align:left; padding:6px 8px; border-bottom:1px solid var(--line); white-space:nowrap; }
|
||||||
|
</style></head><body>
|
||||||
|
<h1>Gearslip usage</h1><p class="muted">From phones that opted in to daily notes.</p>
|
||||||
|
${s.latestRelease ? `<div class="stat big"><b>${s.latestRelease.downloads}</b>downloads of ${esc(s.latestRelease.tag)}, the latest release</div>` : ''}
|
||||||
|
<div class="stats">
|
||||||
|
<div class="stat"><b>${s.active7}</b>phones, last 7 days</div>
|
||||||
|
<div class="stat"><b>${s.active30}</b>phones, last 30 days</div>
|
||||||
|
<div class="stat"><b>${s.driving30}</b>used in a car, last 30 days</div>
|
||||||
|
<div class="stat"><b>${s.new30}</b>new phones, last 30 days</div>
|
||||||
|
<div class="stat"><b>${s.quiet}</b>gone quiet (nothing in 14 days)</div>
|
||||||
|
<div class="stat"><b>${s.everyone}</b>phones ever</div>
|
||||||
|
</div>
|
||||||
|
<p class="muted">Android tells an app nothing when it's uninstalled, so "gone quiet" is the nearest
|
||||||
|
sign: phones that sent notes before and stopped. Turning the notes off looks the same.</p>
|
||||||
|
<h2>Downloads and phones</h2>
|
||||||
|
<div class="stats">
|
||||||
|
<div class="stat"><b>${s.totalDownloads}</b>APK downloads on GitHub, all releases</div>
|
||||||
|
<div class="stat"><b>${s.share == null ? '–' : s.share + '%'}</b>of downloads send notes</div>
|
||||||
|
</div>
|
||||||
|
<p class="muted">The share counts only releases that have sent at least one note, and only release builds.
|
||||||
|
A download isn't always a new person: updating means downloading again, and some people download
|
||||||
|
twice. So read it as a rough guide, and multiply the phones above by about ${s.share ? Math.round(100 / s.share) : '–'} to estimate everyone.</p>
|
||||||
|
${table(s.releases, [['tag', 'Release'], ['kind', 'Type'], ['published', 'Published'], ['downloads', 'Downloads'], ['phones', 'Phones sending'], ['share', 'Share']])}
|
||||||
|
<h2>Downloads per day</h2>${s.perDay.length ? table(s.perDay, [['day', 'Day'], ['downloads', 'Downloads'], ['new_phones', 'New phones']])
|
||||||
|
: '<p class="muted">Counts are saved once a day, so this fills in from tomorrow.</p>'}
|
||||||
|
<h2>Phones per day</h2>${chart(s.daily)}
|
||||||
|
<h2>New phones per day</h2>${table(s.daily.filter((d) => d.new_phones > 0).slice().reverse(), [['day', 'Day'], ['new_phones', 'New phones']])}
|
||||||
|
<h2>Cars</h2>${table(s.cars, [['name', 'Head unit'], ['car', 'Car'], ['year', 'Year'], ['make', 'Maker'], ['model', 'Unit model'],
|
||||||
|
['protocol', 'Protocol'], ['screen', 'Screen'], ['connected', 'Connected'], ['failed', 'Failed'], ['failures', 'Why it failed'], ['phones', 'Phones'], ['last_seen', 'Last seen']])}
|
||||||
|
<h2>Car screens</h2>${table(s.screens, [['screen', 'Usable size'], ['phones', 'Phones']])}
|
||||||
|
<h2>Versions, last 7 days</h2>${table(s.versions, [['version', 'Version'], ['phones', 'Phones']])}
|
||||||
|
<h2>Phones</h2>${table(s.phones, [['phone', 'Phone'], ['phones', 'Count']])}
|
||||||
|
<h2>GrapheneOS, last 30 days</h2>${table(s.os, [['os', 'System'], ['phones', 'Phones']])}
|
||||||
|
<h2>Android</h2>${table(s.android, [['android', 'Android'], ['phones', 'Count']])}
|
||||||
|
</body></html>`
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Gearslip's opt-in usage notes. Deploy with `npx wrangler deploy` from this folder.
|
||||||
|
# Setup, once:
|
||||||
|
# npx wrangler d1 create gearslip-stats (paste the id it prints below)
|
||||||
|
# npx wrangler d1 execute gearslip-stats --remote --file schema.sql
|
||||||
|
# npx wrangler secret put SALT (any long random string; keep it)
|
||||||
|
# npx wrangler secret put DASHBOARD_KEY (the key for the dashboard link)
|
||||||
|
name = "gearslip-stats"
|
||||||
|
main = "src/index.js"
|
||||||
|
compatibility_date = "2026-10-01"
|
||||||
|
|
||||||
|
[[d1_databases]]
|
||||||
|
binding = "DB"
|
||||||
|
database_name = "gearslip-stats"
|
||||||
|
database_id = "201521a7-0b4a-4fff-ab11-d575b821528a"
|
||||||
|
|
||||||
|
# The public repo whose release downloads the dashboard compares against. If GitHub starts
|
||||||
|
# refusing (60 requests an hour without a token), add one: npx wrangler secret put GITHUB_TOKEN
|
||||||
|
[vars]
|
||||||
|
GITHUB_REPO = "Seb3thehacker/gearslip"
|
||||||
|
|
||||||
|
# Save the download counts once a day, so the dashboard can show downloads per day.
|
||||||
|
[triggers]
|
||||||
|
crons = ["17 6 * * *"]
|
||||||
Reference in new issue
Block a user