Author SHA1 Message Date
Seb3thehacker 01a7ae1930 v0.1.10-alpha 2026-10-01 20:48:10 -07:00
Seb3thehacker c52a6c32b7 screen size auto negotiation 2026-10-01 20:43:26 -07:00
Seb3thehacker f21df41506 audi 32 to 64 bit fix 2026-10-01 20:37:04 -07:00
Seb3thehacker ff8a6a4cff app organization and movement options 2026-10-01 20:34:35 -07:00
Seb3thehacker a2d9ed7008 maps UI tweaks 2026-10-01 20:17:44 -07:00
Seb3thehacker 3994c9bd2e new supported apps, redesign UI for apps not fully supported 2026-10-01 14:28:44 -07:00
Seb3thehacker 4e4a246a51 voice prompt timer fixed 2026-10-01 12:55:40 -07:00
Seb3thehacker ebd0ba4add map screen wont rebuild on each app/page switch 2026-10-01 12:35:49 -07:00
Seb3thehacker 42785ce6be Merge pull request #8 from Brian5647/fix-map-freeze
fix the drawing after coming back from the media menu to navigation app
2026-10-01 12:19:40 -07:00
Seb3thehacker db205919c8 Point contributors at the experimental branch 2026-10-01 11:42:32 -07:00
Seb3thehacker f452099926 docs updates for developers 2026-10-01 11:33:27 -07:00
Seb3thehacker 03b74b1150 issue templates, doc updates 2026-10-01 11:20:14 -07:00
Brian5647 fbaeb0cc57 This is to fix the drawing after coming back from the media menu to the navigtion app. For organic maps the change from yesterday was fine. For flitsmeister it freezes the screen. 2026-10-01 19:03:45 +02:00
Seb3thehacker 5f557a5973 changelog update 2026-10-01 00:45:09 -07:00
Seb3thehacker 86edc42b98 ok real final commit 2026-10-01 00:35:10 -07:00
Seb3thehacker 68f9bf35a1 Final tweaks for Gearslip v0.1.0 2026-10-01 00:34:53 -07:00
Seb3thehacker e314aa8546 Voice replies, on-device speech, message cards and 0.1.0-alpha
Gearslip now reads messages aloud and takes replies by voice. It asks
whether to reply, reads the reply back and sends it after a countdown.
Say "cancel" or "change it" to stop or redo it, or tap a quick reply.

Speech-to-text runs on the phone with whisper.cpp and a small Whisper
model, so voice works without Google services. The model ships in the
repo, so a fresh clone builds with nothing to download.

New messages show as one card at the top right, with a bar counting
down to when it closes or sends.

Other changes:
- Steering wheel buttons, knobs and D-pads drive the car screen.
- Smoother car audio: chunks wait for the car instead of dropping.
- Sharper, more responsive video.
- The Gearslip switch in Settings now stops it taking the USB
  connection.
- Apps that refuse Gearslip say so instead of loading forever.
- The phone asks for notification permission.
- The home screen stays upright.
- The car screen shows what's new after an update.
2026-10-01 00:03:53 -07:00
Seb3thehacker 1b2010f939 car rejection leason logging 2026-09-30 13:28:29 -07:00
Seb3thehacker f25eafcc48 Keep the map loaded behind other screens
Leaving Home used to tear down the map's surface, so coming back showed black for a second or two while the nav app redrew. Home now stays composed under every other screen.
2026-09-30 13:17:37 -07:00
Seb3thehacker 473c12ad8f Merge branch 'pr-5' into try-pr-5
Fix map loading by drawing a virtual display.
2026-09-30 13:04:16 -07:00
Seb3thehacker 89b8157eb9 Messaging screen, units setting, turn pill, messaging app support 2026-09-30 13:03:55 -07:00
Brian5647 304c1de3bc Update CarAppStage.kt 2026-09-30 20:34:12 +02:00
Brian5647andClaude Opus 5.5 6156da4b67 Keep car apps online in the background and fix surface resizes
Car apps are now bound as a foreground service, so they keep network access when Gearslip isn't the top app on the phone. Before, an app bound in the background could lose network under Doze: its map and GPS kept working while search and routing went dead.

The app surface is also recreated whenever the map area changes size, the way Android Auto does it, instead of being resized in place. Apps that draw through their own VirtualDisplay rebuild it on the reused surface, and the old display's teardown then cuts the new one off, freezing the map for good. Found with Flitsmeister after picking a route.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:29:58 +02:00
Seb3thehacker ae7c3ec308 Voice assistant, vehicle data, and template rendering fixes
Push-to-talk voice control, gated behind experimental settings alongside a new raw vehicle sensor screen. Spotify now shows as partial instead of working since its browse tile rejects Gearslip as a host. Fixed a gap where a live nav app couldn't take a fresh destination without a full reconnect.
2026-09-29 18:07:48 -07:00
Seb3thehacker e7f0aef43d LIVI supported 2026-09-28 15:09:51 -07:00
Seb3thehacker 97c6a26f61 update readme 2026-09-28 01:09:33 -07:00
95 changed files with 7506 additions and 1022 deletions

No files matched your search

+59
View File
@@ -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
+65
View File
@@ -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.
+1
View File
@@ -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.
+7 -1
View File
@@ -8,6 +8,8 @@ on:
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: write
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -16,6 +18,8 @@ jobs:
distribution: temurin distribution: temurin
java-version: "21" java-version: "21"
- uses: gradle/actions/setup-gradle@v4
- name: Write signing key and properties - name: Write signing key and properties
run: | run: |
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > gearslip-release.jks echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > gearslip-release.jks
@@ -32,7 +36,9 @@ jobs:
- name: Locate APK - name: Locate APK
id: apk id: apk
run: | run: |
apk=$(find gearslip/build/outputs/apk/release -name "*.apk" | head -n1) # The build moves its APKs to <root>/build/Gearslip-<variant>.apk (see renameApk).
apk=build/Gearslip-release.apk
test -f "$apk" || { echo "no APK at $apk"; ls -R build | head -50; exit 1; }
echo "path=$apk" >> "$GITHUB_OUTPUT" echo "path=$apk" >> "$GITHUB_OUTPUT"
- name: Create release - name: Create release
+17 -12
View File
@@ -6,22 +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
# Local investigation script; not meant for the public repo. # Native build cache.
.cxx/
# Testing
/docs/cert-sweep.sh /docs/cert-sweep.sh
archive/
CLAUDE.md
*.pdf
extraction/ extraction/
/cloned-repos
# Internal
*.pdf
todo
logs/
# For AI code
.claude/ .claude/
CLAUDE.md
# Other
archive/
+51
View File
@@ -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.
+3 -10
View File
@@ -1,16 +1,9 @@
# License # License
Gearslip is licensed under the **GNU Affero General Public License v3.0** (see `LICENSE`). Gearslip is licensed under the GNU Affero General Public License v3.0. The full text is in `LICENSE`.
In short: you're free to use, run, and modify it. If you modify it — or run a modified You may use, run, and modify Gearslip freely. If you distribute a modified version, or let others use one over a network, you must publish your changes under the same license. The obligation follows the code even when you hand out no binary, which suits software that runs as a background service.
version as a service others use over a network — the AGPL requires you to make your source
changes available on the same terms. That obligation follows the code even when you don't
redistribute a binary, which is what makes the AGPL fit for something that can run as a
background service.
## Commercial licensing ## Commercial licensing
If the AGPL's terms don't work for you — for example, you want to use Gearslip privately If the AGPL's terms don't work for you, the author sells a separate commercial license. The usual case is a company that wants to use Gearslip privately without publishing its changes. Terms, including any limits on modification, are set case by case. Contact me on Matrix; the address is on my profile.
without being required to publish your changes — a separate commercial license is available
directly from the author. Terms (including any restrictions on modification) are set on a
case-by-case basis. Contact me though Matrix (on my profile) to discuss one.
+47 -19
View File
@@ -13,31 +13,53 @@ 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.
The author reverse-engineered the protocol for interoperability, so that a client other ## Features
than Google's own can talk to a car head unit.
- **Phone calls.** Dial from a keypad, or search your contacts, then confirm before the
call goes out.
- **Navigation.** Gearslip hosts real Car App Library map apps on the car screen; Organic
Maps and OsmAnd both work today.
- **Music.** Browse, play, and queue tracks from a car-enabled media app, with a Now
Playing view, an Up Next queue, and lyrics.
- **The web.** A browser template with its own on-screen keyboard, for anything that
doesn't ship a car interface of its own.
- **Weather and the day ahead.** A dashboard screen shows the week's calendar next to
the current and coming weather.
- **Messages.** New messages appear as a small card on the car screen. Gearslip reads
each one aloud, and you reply by voice or with a quick reply. 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.
- **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
- **The connection is wired only, for now.** Plug the phone into the head unit's USB port;
Gearslip does not yet offer a wireless option.
- **Setup walks you through it.** First launch asks for the Android Auto certificate,
then one permission at a time, each with a plain reason and an Allow or Skip. A help
screen inside the car answers anything setup didn't cover.
- **Templated apps work.** Gearslip renders all 16 template types that the Car App - **Templated apps work.** Gearslip renders all 16 template types that the Car App
Library defines, and it draws its own keyboard for search and sign-in. It has been Library defines, and it draws its own keyboard for search, sign-in, and text fields.
tested against several real navigation and media apps. It has been tested against several real navigation and media apps.
- **Media playback works.** Gearslip lets you browse and control car-enabled media apps - **Gearslip works with [LIVI](https://github.com/f-io/LIVI),** the open-source head unit
from the car screen, with lyrics and playback state, over the same host connection. 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.
- **For now, Gearslip works with cars from before 2020.** 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; the app's own setup appears once per drive.
steps include two optional adb commands that reduce it to a one-time grant.
- **A real head unit needs a certificate that Google controls.** A head unit accepts only
a phone identity that chains to Google's Automotive Link certificate authority, and
Google alone issues those certificates. Gearslip does not ship one. Without it,
Gearslip runs and renders correctly against a debug harness, but a real car rejects the
connection. A leaked certificate exists that older head units accept; newer firmware
and Google's reference tooling reject it.
- **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.
## Building ## Building
@@ -57,6 +79,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
+65 -9
View File
@@ -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.
+34 -2
View File
@@ -32,8 +32,9 @@ android {
applicationId = "app.seb3thehacker.gearslip" applicationId = "app.seb3thehacker.gearslip"
minSdk = 31 minSdk = 31
targetSdk = 37 targetSdk = 37
versionCode = 5 versionCode = 7
versionName = "0.0.05-alpha" versionName = "0.1.10-alpha"
manifestPlaceholders["appLabel"] = "Gearslip"
} }
signingConfigs { signingConfigs {
@@ -53,6 +54,18 @@ android {
release { release {
signingConfig = signingConfigs.findByName("release") signingConfig = signingConfigs.findByName("release")
} }
// Its own package, so a dev build installs beside the release one instead of over it.
// Signed with the release key when it's here: both builds declare the car template
// permission, and Android refuses a second app declaring it under a different key.
debug {
applicationIdSuffix = ".dev"
signingConfigs.findByName("release")?.let { signingConfig = it }
manifestPlaceholders["appLabel"] = "Gearslip Dev"
}
}
buildFeatures {
buildConfig = true
} }
// Keeps build output out of the module folder: <root>/build/gearslip/... instead of // Keeps build output out of the module folder: <root>/build/gearslip/... instead of
@@ -65,6 +78,23 @@ android {
targetCompatibility = JavaVersion.VERSION_21 targetCompatibility = JavaVersion.VERSION_21
} }
defaultConfig {
// Speech-to-text ships for 64-bit ARM only: every phone Gearslip runs on, and one copy
// of the native library instead of four.
ndk { abiFilters += "arm64-v8a" }
}
ndkVersion = "27.1.12297006"
externalNativeBuild {
cmake {
path = file("src/main/cpp/CMakeLists.txt")
version = "3.22.1"
}
}
androidResources {
// Model weights barely compress, and stored plainly they stream straight out of the APK.
noCompress += "bin"
}
testOptions { testOptions {
// GearslipLog calls android.util.Log, which is a stub on the local JVM. // GearslipLog calls android.util.Log, which is a stub on the local JVM.
unitTests.isReturnDefaultValues = true unitTests.isReturnDefaultValues = true
@@ -104,6 +134,7 @@ dependencies {
// their Bundleables deserialize into. // their Bundleables deserialize into.
implementation(libs.androidx.car.app) implementation(libs.androidx.car.app)
implementation(libs.androidx.car.app.projected) implementation(libs.androidx.car.app.projected)
implementation(libs.androidx.media)
testImplementation(libs.junit) testImplementation(libs.junit)
} }
@@ -112,3 +143,4 @@ tasks.withType<KotlinJvmCompile>().configureEach {
jvmTarget.set(JvmTarget.JVM_21) jvmTarget.set(JvmTarget.JVM_21)
} }
} }
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Debug-only override of the main icon background (src/debug/res wins over src/main/res),
so a debug install is never mistaken for a release one on the home screen. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:pathData="M0,0h108v108h-108z"
android:fillColor="#f5c518"/>
<path
android:pathData="M54,2 A52,52 0 1,1 53.99,2 Z"
android:fillColor="#e6b400"/>
</vector>
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Debug-only override of the main icon foreground, to go with the yellow debug background:
a charcoal gear rather than light grey, and a yellow centre hole to match the background
instead of the release icon's near-black. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M 66.69,49.48 L 80.91,52.61 L 82.31,42.81 L 66.92,47.91 Z">
<aapt:attr name="android:fillColor">
<gradient
android:type="linear"
android:startX="66.80"
android:startY="48.70"
android:endX="81.61"
android:endY="47.71">
<item android:offset="0" android:color="#00E5484D"/>
<item android:offset="1" android:color="#E5484D"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="M 54.00,36.18 L 58.64,30.70 L 67.20,34.24 L 66.60,41.40 L 71.82,54.00 L 77.30,58.64 L 73.76,67.20 L 66.60,66.60 L 66.60,66.60 L 67.20,73.76 L 58.64,77.30 L 54.00,71.82 L 54.00,71.82 L 49.36,77.30 L 40.80,73.76 L 41.40,66.60 L 41.40,66.60 L 34.24,67.20 L 30.70,58.64 L 36.18,54.00 L 36.18,54.00 L 30.70,49.36 L 34.24,40.80 L 41.40,41.40 L 41.40,41.40 L 40.80,34.24 L 49.36,30.70 L 54.00,36.18 Z"
android:fillColor="#2E3033"/>
<path
android:pathData="M 78.61,40.70 L 85.31,43.29 L 84.74,52.55 L 77.78,54.31 Z"
android:fillColor="#E5484D"/>
<path
android:pathData="M54,46.08 A7.92,7.92 0 1,1 53.99,46.08 Z"
android:fillColor="#e6b400"/>
</vector>
+33 -4
View File
@@ -12,6 +12,7 @@
--> -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
@@ -85,13 +86,21 @@
<intent> <intent>
<action android:name="android.media.browse.MediaBrowserService" /> <action android:name="android.media.browse.MediaBrowserService" />
</intent> </intent>
<!-- Speech: without these, Android hides the recognizer and the voice from us and
SpeechRecognizer reports none installed even when one is. -->
<intent>
<action android:name="android.speech.RecognitionService" />
</intent>
<intent>
<action android:name="android.intent.action.TTS_SERVICE" />
</intent>
</queries> </queries>
<application <application
android:allowBackup="false" android:allowBackup="false"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher"
android:label="Gearslip" android:label="${appLabel}"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.Gearslip"> android:theme="@style/Theme.Gearslip">
@@ -121,6 +130,18 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity>
<!--
Answers the car's USB connection on GearslipActivity's behalf. Kept apart from the
activity so Settings can switch off just this (AppSettings.setGearslipEnabled) and leave
the head unit to Android Auto, without disabling the screen the switch is on.
-->
<activity-alias
android:name=".GearslipUsbAccessory"
android:targetActivity=".GearslipActivity"
android:exported="true">
<intent-filter> <intent-filter>
<action android:name="android.hardware.usb.action.USB_ACCESSORY_ATTACHED" /> <action android:name="android.hardware.usb.action.USB_ACCESSORY_ATTACHED" />
</intent-filter> </intent-filter>
@@ -128,7 +149,12 @@
<meta-data <meta-data
android:name="android.hardware.usb.action.USB_ACCESSORY_ATTACHED" android:name="android.hardware.usb.action.USB_ACCESSORY_ATTACHED"
android:resource="@xml/accessory_filter" /> android:resource="@xml/accessory_filter" />
</activity> </activity-alias>
<!-- Dev builds' test message: its Reply and Mark as read actions land here. -->
<receiver
android:name=".notify.TestMessageReceiver"
android:exported="false" />
<service <service
android:name=".mirror.MirrorService" android:name=".mirror.MirrorService"
@@ -140,11 +166,14 @@
android:exported="false" android:exported="false"
android:foregroundServiceType="location" /> android:foregroundServiceType="location" />
<!-- Holds the capture session that carries a media app's audio to the car. --> <!--
Holds the capture session that carries a media app's audio to the car. It also claims
the microphone, so voice replies can hear the driver with the phone locked.
-->
<service <service
android:name=".audio.AudioCaptureService" android:name=".audio.AudioCaptureService"
android:exported="false" android:exported="false"
android:foregroundServiceType="mediaProjection" /> android:foregroundServiceType="mediaProjection|microphone" />
<!-- <!--
Touch on the car screen is replayed onto the phone through this service. Touch on the car screen is replayed onto the phone through this service.
Binary file not shown.
+36
View File
@@ -0,0 +1,36 @@
# Speech-to-text for voice replies and the assistant: whisper.cpp, pinned to one commit and
# fetched at build time, so nothing outside this folder (cloned-repos included) is needed.
cmake_minimum_required(VERSION 3.22)
project(gearslip_speech CXX C)
set(CMAKE_CXX_STANDARD 17)
# Optimised in debug builds too. Unoptimised, Whisper runs several times slower, and a dev build
# would make voice look far worse than what ships.
foreach(lang C CXX)
set(CMAKE_${lang}_FLAGS_DEBUG "-O3 -DNDEBUG")
endforeach()
include(FetchContent)
FetchContent_Declare(
whisper
URL https://github.com/ggml-org/whisper.cpp/archive/6e4ab854f67f743900934a703d5603419384c961.tar.gz
URL_HASH SHA256=55b585aec5e1bc03e2a5cea61da97c6406fa833996cf7daeed704972273cc039
)
set(BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE)
set(WHISPER_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE)
set(WHISPER_BUILD_TESTS OFF CACHE BOOL "" FORCE)
set(WHISPER_BUILD_SERVER OFF CACHE BOOL "" FORCE)
set(GGML_OPENMP OFF CACHE BOOL "" FORCE)
set(GGML_NATIVE OFF CACHE BOOL "" FORCE)
# ARMv8.2 with half floats and dot products: every phone that runs Android 12 or later in
# practice, and what makes Whisper fast enough to answer in under a second.
set(GGML_CPU_ARM_ARCH "armv8.2-a+dotprod+fp16" CACHE STRING "" FORCE)
FetchContent_MakeAvailable(whisper)
add_library(gearslip_speech SHARED speech_jni.cpp)
target_compile_options(gearslip_speech PRIVATE -O3)
find_library(LOG_LIB log)
target_link_libraries(gearslip_speech whisper android ${LOG_LIB})
target_link_options(gearslip_speech PRIVATE -Wl,--gc-sections -Wl,-z,max-page-size=16384)
+86
View File
@@ -0,0 +1,86 @@
// The bridge between Kotlin's SpeechEngine and whisper.cpp: load a model, turn one recorded
// phrase into text, free the model. Recording and deciding when a phrase ends stay in Kotlin.
#include <jni.h>
#include <android/asset_manager.h>
#include <android/asset_manager_jni.h>
#include <android/log.h>
#include <algorithm>
#include <cmath>
#include <string>
#include "whisper.h"
#define TAG "GearslipSpeech"
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, TAG, __VA_ARGS__)
static whisper_context_params context_params() {
whisper_context_params p = whisper_context_default_params();
p.use_gpu = false;
return p;
}
// Streams a model straight out of the APK, so the bundled one is never copied to storage.
static size_t asset_read(void *ctx, void *output, size_t size) {
int n = AAsset_read(static_cast<AAsset *>(ctx), output, size);
return n > 0 ? static_cast<size_t>(n) : 0;
}
static bool asset_eof(void *ctx) { return AAsset_getRemainingLength64(static_cast<AAsset *>(ctx)) <= 0; }
static void asset_close(void *ctx) { AAsset_close(static_cast<AAsset *>(ctx)); }
extern "C" JNIEXPORT jlong JNICALL
Java_app_seb3thehacker_gearslip_speech_SpeechEngine_nativeInitAsset(JNIEnv *env, jobject, jobject assets, jstring jpath) {
AAssetManager *manager = AAssetManager_fromJava(env, assets);
const char *path = env->GetStringUTFChars(jpath, nullptr);
AAsset *asset = AAssetManager_open(manager, path, AASSET_MODE_STREAMING);
env->ReleaseStringUTFChars(jpath, path);
if (!asset) return 0;
whisper_model_loader loader = {asset, asset_read, asset_eof, asset_close};
return reinterpret_cast<jlong>(whisper_init_with_params(&loader, context_params()));
}
// Bytes, not a jstring: NewStringUTF wants modified UTF-8 and aborts on some of what Whisper can
// write (four-byte characters), so Kotlin decodes the plain UTF-8 instead.
extern "C" JNIEXPORT jbyteArray JNICALL
Java_app_seb3thehacker_gearslip_speech_SpeechEngine_nativeTranscribe(
JNIEnv *env, jobject, jlong handle, jfloatArray jsamples, jstring jlang, jint threads) {
auto *ctx = reinterpret_cast<whisper_context *>(handle);
if (!ctx) return env->NewByteArray(0);
jsize n = env->GetArrayLength(jsamples);
jfloat *samples = env->GetFloatArrayElements(jsamples, nullptr);
const char *lang = env->GetStringUTFChars(jlang, nullptr);
whisper_full_params p = whisper_full_default_params(WHISPER_SAMPLING_GREEDY);
p.n_threads = threads;
p.language = lang;
p.translate = false;
p.no_context = true;
p.no_timestamps = true;
p.single_segment = true;
p.print_progress = false;
p.print_realtime = false;
p.print_special = false;
p.print_timestamps = false;
p.suppress_blank = true;
// The ACFT models are trained for a context cut to the audio's own length (1500 frames is
// 30 s, so 50 per second), which is what makes a three-second phrase fast. A little extra
// keeps the last word from being clipped.
p.audio_ctx = std::min(1500, static_cast<int>(std::ceil(n / 320.0)) + 32);
int rc = whisper_full(ctx, p, samples, n);
env->ReleaseStringUTFChars(jlang, lang);
env->ReleaseFloatArrayElements(jsamples, samples, JNI_ABORT);
if (rc != 0) {
LOGI("whisper_full failed: %d", rc);
return env->NewByteArray(0);
}
std::string text;
for (int i = 0; i < whisper_full_n_segments(ctx); i++) text += whisper_full_get_segment_text(ctx, i);
jbyteArray out = env->NewByteArray(static_cast<jsize>(text.size()));
env->SetByteArrayRegion(out, 0, static_cast<jsize>(text.size()), reinterpret_cast<const jbyte *>(text.data()));
return out;
}
extern "C" JNIEXPORT void JNICALL
Java_app_seb3thehacker_gearslip_speech_SpeechEngine_nativeFree(JNIEnv *, jobject, jlong handle) {
if (handle) whisper_free(reinterpret_cast<whisper_context *>(handle));
}
@@ -1,6 +1,8 @@
package app.seb3thehacker.gearslip package app.seb3thehacker.gearslip
import android.content.ComponentName
import android.content.Context import android.content.Context
import android.content.pm.PackageManager
/** User-facing preferences. Read fresh on every session, so changes apply on the next connect. */ /** User-facing preferences. Read fresh on every session, so changes apply on the next connect. */
object AppSettings { object AppSettings {
@@ -9,8 +11,8 @@ object AppSettings {
private const val KEY_STARTUP_URL = "startup_url" private const val KEY_STARTUP_URL = "startup_url"
private const val KEY_SEEN_COMPAT_WARNING = "seen_compat_warning" private const val KEY_SEEN_COMPAT_WARNING = "seen_compat_warning"
private const val KEY_SKIPPED_CERT_SETUP = "skipped_cert_setup" private const val KEY_SKIPPED_CERT_SETUP = "skipped_cert_setup"
private const val KEY_DEBUG_MODE = "debug_mode"
private const val KEY_SEEN_PERMISSIONS_SETUP = "seen_permissions_setup" private const val KEY_SEEN_PERMISSIONS_SETUP = "seen_permissions_setup"
private const val KEY_GEARSLIP_ENABLED = "gearslip_enabled"
private fun prefs(context: Context) = private fun prefs(context: Context) =
context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE) context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
@@ -55,14 +57,41 @@ object AppSettings {
} }
/** /**
* Debug mode: full protocol tracing in the log, and the developer screens (live logs, car * Whether Gearslip is allowed to claim the USB accessory connection. Off leaves the app
* preview) on the home screen. On by default while the project is pre-release. * installed but disables the GearslipUsbAccessory alias that answers
* USB_ACCESSORY_ATTACHED, so a real Android Auto head unit (or Google's own app) gets the
* connection instead - the two fight over it otherwise, since only one app can win the
* accessory handoff. The alias is separate from GearslipActivity itself - the activity that
* shows this very switch - so turning it off can't disable the screen you're looking at.
* On by default.
*/ */
fun debugMode(context: Context): Boolean = fun gearslipEnabled(context: Context): Boolean =
prefs(context).getBoolean(KEY_DEBUG_MODE, true) prefs(context).getBoolean(KEY_GEARSLIP_ENABLED, true)
fun setDebugMode(context: Context, value: Boolean) { fun setGearslipEnabled(context: Context, value: Boolean) {
prefs(context).edit().putBoolean(KEY_DEBUG_MODE, value).apply() prefs(context).edit().putBoolean(KEY_GEARSLIP_ENABLED, value).apply()
GearslipLog.debug = value applyGearslipEnabled(context)
}
/**
* Makes the USB alias match the saved switch. Called at every launch as well, so a switch
* saved without the alias following it (an older build crashed right there) heals itself.
* Only the alias changes: the driver's "always open with Gearslip" choice for the car is kept.
*/
fun applyGearslipEnabled(context: Context) {
val value = gearslipEnabled(context)
val appContext = context.applicationContext
// The class name comes from the code's namespace, not the package: a debug build installs
// as ".dev", but the alias is still app.seb3thehacker.gearslip.GearslipUsbAccessory.
val component = ComponentName(appContext.packageName, "${GearslipActivity::class.java.`package`!!.name}.GearslipUsbAccessory")
val state = if (value) {
PackageManager.COMPONENT_ENABLED_STATE_DEFAULT
} else {
PackageManager.COMPONENT_ENABLED_STATE_DISABLED
}
val pm = appContext.packageManager
if (pm.getComponentEnabledSetting(component) == state) return
runCatching { pm.setComponentEnabledSetting(component, state, PackageManager.DONT_KILL_APP) }
.onFailure { GearslipLog.e("could not switch the USB connection ${if (value) "on" else "off"}", it) }
} }
} }
@@ -0,0 +1,55 @@
package app.seb3thehacker.gearslip
/**
* What changed in each release, newest first, shown on the car screen after an update (and on
* the phone). Only the changes a driver would notice. Plain words, short lines: this is read in
* a parked car.
*/
object Changelog {
class Release(val versionCode: Int, val versionName: String, val changes: List<String>)
val releases = listOf(
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(
6, "0.1.0-alpha",
listOf(
"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.",
"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.",
"Steering wheel buttons, control knobs, and D-pads now work. If yours don't, open an issue on GitHub.",
"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.",
"Try the voice assistant and the vehicle data screen under Settings > Experimental features.",
"Logs now say why a car ended the connection.",
"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.",
),
),
)
/** The release that matches this build, if it has notes. */
val current: Release? get() = releases.firstOrNull { it.versionCode == BuildConfig.VERSION_CODE }
}
@@ -113,6 +113,7 @@ class GearslipActivity : ComponentActivity(), Projection {
GearslipLog.installCrashHandler() GearslipLog.installCrashHandler()
GearslipLog.i("Gearslip ready") GearslipLog.i("Gearslip ready")
CarSettings.init(this) CarSettings.init(this)
AppSettings.applyGearslipEnabled(this)
// A capture that died without tidying up would leave notifications exposed to every // A capture that died without tidying up would leave notifications exposed to every
// later screen share. Nothing is capturing if the service is not running, so undo it. // later screen share. Nothing is capturing if the service is not running, so undo it.
if (!app.seb3thehacker.gearslip.audio.AudioCaptureService.running) { if (!app.seb3thehacker.gearslip.audio.AudioCaptureService.running) {
@@ -245,6 +246,12 @@ class GearslipActivity : ComponentActivity(), Projection {
private fun handleIntent(intent: Intent?) { private fun handleIntent(intent: Intent?) {
if (intent?.action != UsbManager.ACTION_USB_ACCESSORY_ATTACHED) return if (intent?.action != UsbManager.ACTION_USB_ACCESSORY_ATTACHED) return
// Switched off in Settings: the system shouldn't route the car here at all, but if it
// does, leave the accessory alone.
if (!AppSettings.gearslipEnabled(this)) {
GearslipLog.i("accessory attached, but Gearslip is switched off - not connecting")
return
}
val accessory = intent.getParcelableExtra(UsbManager.EXTRA_ACCESSORY, UsbAccessory::class.java) val accessory = intent.getParcelableExtra(UsbManager.EXTRA_ACCESSORY, UsbAccessory::class.java)
if (accessory == null) { if (accessory == null) {
GearslipLog.e("attach intent carried no accessory") GearslipLog.e("attach intent carried no accessory")
@@ -256,10 +263,13 @@ class GearslipActivity : ComponentActivity(), Projection {
} }
private fun start(accessory: UsbAccessory) { private fun start(accessory: UsbAccessory) {
if (worker?.isAlive == true) { // A fresh ATTACHED intent means the system wants a new session, whatever state the old
GearslipLog.w("a run is already in progress") // one is in - closing first guarantees openAccessory() below never fights a descriptor
return // this same process forgot to release. The old worker thread often isn't alive by this
} // point anyway (its read loop already died when the head unit dropped the transport),
// but the accessory it opened was never explicitly closed, which is what actually blocks
// a re-open - a dead thread does not release the file descriptor on its own.
closeAccessory()
describe(accessory) describe(accessory)
val manager = getSystemService(UsbManager::class.java) val manager = getSystemService(UsbManager::class.java)
@@ -306,6 +316,9 @@ class GearslipActivity : ComponentActivity(), Projection {
CarEnvironment.setDisplay(width, height, densityDpi) CarEnvironment.setDisplay(width, height, densityDpi)
CarEnvironment.start(this) CarEnvironment.start(this)
showCarUi() showCarUi()
app.seb3thehacker.gearslip.car.CarKeys.focusSink = { code, down ->
runOnUiThread { screenProjector?.dispatchKey(code, down) }
}
} }
} }
@@ -320,6 +333,7 @@ class GearslipActivity : ComponentActivity(), Projection {
override fun onProjectionStopped() { override fun onProjectionStopped() {
runOnUiThread { runOnUiThread {
PhoneMirror.requestStop() PhoneMirror.requestStop()
app.seb3thehacker.gearslip.car.CarKeys.focusSink = null
screenProjector?.stop() screenProjector?.stop()
screenProjector = null screenProjector = null
surface = null surface = null
@@ -32,9 +32,9 @@ object GearslipLog {
private var file: File? = null private var file: File? = null
/** /**
* Set from [AppSettings.debugMode]. Off drops the noisy trace output (hex dumps, and * Off drops the noisy trace output (hex dumps, and logcat copies of info lines); warnings,
* logcat copies of info lines); warnings, errors and the file copy are always kept, so a * errors and the file copy are always kept, so a crash report is never empty. Always on for
* crash report is never empty. * now, while the project is pre-release.
*/ */
@Volatile var debug = true @Volatile var debug = true
@@ -47,13 +47,11 @@ object GearslipLog {
val version: StateFlow<Int> = liveVersion val version: StateFlow<Int> = liveVersion
fun init(context: Context) { fun init(context: Context) {
debug = AppSettings.debugMode(context)
val dir = context.getExternalFilesDir(null) ?: return val dir = context.getExternalFilesDir(null) ?: return
val name = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date()) val name = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date())
file = File(dir, "gearslip-$name.log") file = File(dir, "gearslip-$name.log")
i("log file: ${file?.absolutePath}") i("log file: ${file?.absolutePath}")
SessionReport.init(context) SessionReport.init(context)
i("debug mode: $debug")
} }
fun snapshot(): List<String> = synchronized(live) { live.toList() } fun snapshot(): List<String> = synchronized(live) { live.toList() }
@@ -2,7 +2,12 @@ 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.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.CarSensors
import app.seb3thehacker.gearslip.car.CarServices
import app.seb3thehacker.gearslip.car.CarSettings
import java.io.InputStream import java.io.InputStream
import java.io.OutputStream import java.io.OutputStream
@@ -50,6 +55,9 @@ class GearslipRunner(
@Volatile private var framesSent = 0 @Volatile private var framesSent = 0
@Volatile private var dropped = 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 sensorChannelId: Int = -1
private var sensorTypes: List<Int> = emptyList()
@Volatile private var audioLink: AudioLink? = null @Volatile private var audioLink: AudioLink? = null
private var audioMessagesSeen = 0 private var audioMessagesSeen = 0
private var touchWidth = 0 private var touchWidth = 0
@@ -111,6 +119,7 @@ class GearslipRunner(
videoSource = null videoSource = null
audioLink?.close() audioLink?.close()
audioLink = null audioLink = null
CarSensors.clear()
projection?.onProjectionStopped() projection?.onProjectionStopped()
SessionStatus.disconnected() SessionStatus.disconnected()
} }
@@ -142,6 +151,8 @@ class GearslipRunner(
onVideoMessage(messageId, body) onVideoMessage(messageId, body)
} else if (frame.channel == inputChannelId) { } else if (frame.channel == inputChannelId) {
onInputMessage(messageId, body) onInputMessage(messageId, body)
} else if (frame.channel == sensorChannelId) {
onSensorMessage(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.
@@ -166,11 +177,34 @@ class GearslipRunner(
MSG_AUTH_COMPLETE -> onAuthComplete(body) MSG_AUTH_COMPLETE -> onAuthComplete(body)
MSG_SERVICE_DISCOVERY_RESPONSE -> onServiceDiscoveryResponse(body) MSG_SERVICE_DISCOVERY_RESPONSE -> onServiceDiscoveryResponse(body)
MSG_PING_REQUEST -> onPingRequest(body) MSG_PING_REQUEST -> onPingRequest(body)
MSG_BYEBYE_REQUEST -> onByeByeRequest(body)
AudioLink.MSG_FOCUS_RESPONSE -> audioLink?.onFocus(Protobuf.readInt32Field(body, 1) ?: 0) AudioLink.MSG_FOCUS_RESPONSE -> audioLink?.onFocus(Protobuf.readInt32Field(body, 1) ?: 0)
else -> log.i("unhandled control message id=$messageId (${body.size} bytes) - continuing") else -> log.i("unhandled control message id=$messageId (${body.size} bytes) - continuing")
} }
} }
/**
* The head unit asking to end the session. It drops USB a few seconds later whether or not
* we answer, so the point here is the reason: an Audi MIB2+ sent this about a second after
* video started, every time, and the old build logged it only as "id=15".
*/
private fun onByeByeRequest(body: ByteArray) {
val reason = Protobuf.readInt32Field(body, 1)
val name = when (reason) {
1 -> "USER_SELECTION"
2 -> "DEVICE_SWITCH"
3 -> "NOT_SUPPORTED"
4 -> "NOT_CURRENTLY_SUPPORTED"
5 -> "PROBE_SUPPORTED"
else -> "unknown"
}
log.w("<- ByeByeRequest: reason=$reason ($name) in state $state")
log.hex(" byebye raw", body)
SessionReport.fail(SessionReport.Category.BYEBYE, "ByeByeRequest reason=$reason ($name)", state.name)
send(MSG_BYEBYE_RESPONSE, ByteArray(0), encrypted = true)
log.i("-> ByeByeResponse")
}
private fun onVersionRequest(body: ByteArray) { private fun onVersionRequest(body: ByteArray) {
if (body.size < 4) { if (body.size < 4) {
log.w("VersionRequest too short (${body.size} bytes)") log.w("VersionRequest too short (${body.size} bytes)")
@@ -350,17 +384,19 @@ class GearslipRunner(
openChannel(video.serviceId) openChannel(video.serviceId)
startAudioChannel(serviceDiscoveryResponse) startAudioChannel(serviceDiscoveryResponse)
startSensorChannel(serviceDiscoveryResponse)
val input = ServiceDiscovery.findInputService(serviceDiscoveryResponse) val input = ServiceDiscovery.findInputService(serviceDiscoveryResponse)
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 {
inputChannelId = input.serviceId inputChannelId = input.serviceId
inputKeycodes = input.keycodesSupported
touchWidth = input.touchWidth touchWidth = input.touchWidth
touchHeight = input.touchHeight touchHeight = input.touchHeight
log.i( log.i(
"input service: channel=${input.serviceId} touchscreen=" + "input service: channel=${input.serviceId} touchscreen=" +
"${input.touchWidth}x${input.touchHeight}", "${input.touchWidth}x${input.touchHeight} keycodes=${input.keycodesSupported}",
) )
openChannel(input.serviceId) openChannel(input.serviceId)
} }
@@ -395,6 +431,57 @@ class GearslipRunner(
openChannel(media.serviceId) openChannel(media.serviceId)
} }
/**
* 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
* every request with "unsupported" today per BUILDING_APPS.md's line to third-party apps, but
* that's a promise made to apps Gearslip hosts, not to what Gearslip itself may ask a real
* head unit for over this same channel.
*/
private fun startSensorChannel(serviceDiscoveryResponse: ByteArray) {
val sensors = ServiceDiscovery.findSensorService(serviceDiscoveryResponse)
if (sensors == null) {
log.i("no sensor service advertised - this head unit offers no vehicle data channel")
return
}
sensorChannelId = sensors.serviceId
sensorTypes = sensors.types
log.i("sensor service: channel=${sensors.serviceId} types=${sensors.types}")
CarSensors.onDiscovered(sensors.types)
openChannel(sensors.serviceId)
}
private fun onSensorMessage(messageId: Int, body: ByteArray) {
when (messageId) {
MSG_CHANNEL_OPEN_RESPONSE -> {
val status = Protobuf.readInt32Field(body, 1)
log.i("<- ChannelOpenResponse (sensor): status=$status")
if (status != 0) {
log.w("head unit refused the sensor channel (status=$status)")
return
}
// SensorRequest { required int32 sensor_type = 1; }
sensorTypes.forEach { type ->
send(
MSG_SENSOR_START_REQUEST, Protobuf.varintField(1, type.toLong()),
encrypted = true, channel = sensorChannelId,
)
log.i("-> SensorStartRequest(sensor_type=$type)")
}
}
MSG_SENSOR_START_RESPONSE ->
log.i("<- SensorStartResponse: status=${Protobuf.readInt32Field(body, 1)}")
MSG_SENSOR_EVENT_INDICATION -> {
CarSensors.onEvent(body)
log.i("<- SensorEvent:\n" + Protobuf.describe(body))
}
else -> log.i("<- unhandled sensor message id=$messageId (${body.size} bytes)")
}
}
private fun openChannel(serviceId: Int) { private fun openChannel(serviceId: Int) {
val request = Protobuf.varintField(1, 0L) + // priority (sint32 zigzag: 0 -> 0) val request = Protobuf.varintField(1, 0L) + // priority (sint32 zigzag: 0 -> 0)
Protobuf.varintField(2, serviceId.toLong()) // service_id Protobuf.varintField(2, serviceId.toLong()) // service_id
@@ -420,14 +507,21 @@ class GearslipRunner(
*/ */
private fun onInputMessage(messageId: Int, body: ByteArray) { private fun onInputMessage(messageId: Int, body: ByteArray) {
when (messageId) { when (messageId) {
MSG_CHANNEL_OPEN_RESPONSE -> MSG_CHANNEL_OPEN_RESPONSE -> {
log.i("<- ChannelOpenResponse (input): status=${Protobuf.readInt32Field(body, 1)}") val status = Protobuf.readInt32Field(body, 1)
log.i("<- ChannelOpenResponse (input): status=$status")
if (status == 0) sendKeyBinding()
}
MSG_KEY_BINDING_RESPONSE ->
log.i("<- KeyBindingResponse: status=${Protobuf.readInt32Field(body, 1)}")
MSG_INPUT_REPORT -> { MSG_INPUT_REPORT -> {
val report = Wire.fields(body) val report = Wire.fields(body)
Wire.bytes(report, 4)?.let(::onKeyEvent)
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)
if (touch == null) { if (touch == null) {
log.i("<- InputReport with no touch payload (key or rotary event)")
return return
} }
val touchFields = Wire.fields(touch) val touchFields = Wire.fields(touch)
@@ -460,6 +554,54 @@ class GearslipRunner(
} }
} }
/**
* Asks the head unit to forward its buttons. Many units send nothing from the steering wheel
* until the phone names the keys it wants. What the unit offered is asked for; a unit that
* offered nothing gets [CarKeys.WANTED], since some leave the list empty and send keys anyway.
*
* KeyBindingRequest { repeated int32 keycodes = 1 [packed = true]; }
*/
private fun sendKeyBinding() {
val keycodes = inputKeycodes.ifEmpty { CarKeys.WANTED }
val packed = keycodes.fold(ByteArray(0)) { acc, code -> acc + Protobuf.varint(code.toLong()) }
val body = Protobuf.varint(((1 shl 3) or 2).toLong()) + Protobuf.varint(packed.size.toLong()) + packed
send(MSG_KEY_BINDING_REQUEST, body, encrypted = true, channel = inputChannelId)
log.i("-> KeyBindingRequest(keycodes=$keycodes)")
}
/**
* Steering-wheel and head-unit buttons: InputReport.key_event (field 4), shaped as
* KeyEvent { repeated Key keys = 1; }
* Key { keycode = 1; down = 2; metastate = 3; longpress = 4; }
*
* The codes are android.view.KeyEvent's; [CarKeys] decides what each one does.
*/
private fun onKeyEvent(keyEvent: ByteArray) {
for (key in Wire.allBytes(Wire.fields(keyEvent), 1)) {
val fields = Wire.fields(key)
val keycode = Wire.varint(fields, 1)?.toInt() ?: continue
val down = Wire.varint(fields, 2) == 1L
val longPress = Wire.varint(fields, 4) == 1L
log.i("<- key event: keycode=$keycode ${if (down) "down" else "up"}${if (longPress) " long" else ""}")
if (!CarKeys.onKey(keycode, down, longPress) && down) log.i("unhandled keycode=$keycode")
}
}
/**
* The rotary knob: InputReport.relative_event (field 6), shaped as
* RelativeEvent { repeated Rel data = 1; } Rel { keycode = 1; delta = 2 (int32); }
*/
private fun onRelativeEvent(event: ByteArray) {
for (rel in Wire.allBytes(Wire.fields(event), 1)) {
val fields = Wire.fields(rel)
val keycode = Wire.varint(fields, 1)?.toInt() ?: continue
// int32, so a turn the other way arrives as a 64-bit two's complement varint.
val delta = Wire.varint(fields, 2)?.toInt() ?: continue
log.i("<- relative event: keycode=$keycode delta=$delta")
CarKeys.onRotate(delta)
}
}
private fun onVideoMessage(messageId: Int, body: ByteArray) { private fun onVideoMessage(messageId: Int, body: ByteArray) {
when (messageId) { when (messageId) {
MSG_CHANNEL_OPEN_RESPONSE -> { MSG_CHANNEL_OPEN_RESPONSE -> {
@@ -489,15 +631,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 ?: "?"} " +
@@ -543,6 +681,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
@@ -560,6 +712,13 @@ class GearslipRunner(
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(
@@ -658,9 +817,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(
@@ -809,6 +970,8 @@ class GearslipRunner(
const val MSG_SERVICE_DISCOVERY_RESPONSE = 6 const val MSG_SERVICE_DISCOVERY_RESPONSE = 6
const val MSG_PING_REQUEST = 11 const val MSG_PING_REQUEST = 11
const val MSG_PING_RESPONSE = 12 const val MSG_PING_RESPONSE = 12
const val MSG_BYEBYE_REQUEST = 15
const val MSG_BYEBYE_RESPONSE = 16
const val MSG_CHANNEL_OPEN_REQUEST = 7 const val MSG_CHANNEL_OPEN_REQUEST = 7
const val MSG_CHANNEL_OPEN_RESPONSE = 8 const val MSG_CHANNEL_OPEN_RESPONSE = 8
@@ -823,13 +986,22 @@ class GearslipRunner(
const val MSG_VIDEO_FOCUS_REQUEST = 32775 const val MSG_VIDEO_FOCUS_REQUEST = 32775
const val MSG_VIDEO_FOCUS_NOTIFICATION = 32776 const val MSG_VIDEO_FOCUS_NOTIFICATION = 32776
// aap_protobuf/service/sensor/... - same "first specific message on this channel type
// starts at 0x8000" numbering as media/input above.
const val MSG_SENSOR_START_REQUEST = 32768
const val MSG_SENSOR_START_RESPONSE = 32769
const val MSG_SENSOR_EVENT_INDICATION = 32770
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 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_RESPONSE = 32771
const val ACTION_DOWN = 0 const val ACTION_DOWN = 0
const val ACTION_UP = 1 const val ACTION_UP = 1
const val ACK_STALL_MS = 2_000L const val ACK_STALL_MS = 2_000L
const val AUDIO_TRACE_MESSAGES = 6 const val AUDIO_TRACE_MESSAGES = 6
const val PERIODIC_KEYFRAME_MS = 5_000L const val PERIODIC_KEYFRAME_MS = 5_000L
@@ -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,
) )
@@ -179,6 +191,28 @@ class ScreenProjector(private val context: Context) {
if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) downTime = 0L if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) downTime = 0L
} }
/**
* A key from the car's D-pad or knob, as a hardware key event on the car display. Compose
* moves focus between buttons on arrows and Tab, and clicks the focused one on OK/Enter.
* [keyCode] of [CarKeys.KEYCODE_SHIFT_TAB] is Tab with Shift held.
*/
fun dispatchKey(keyCode: Int, down: Boolean) {
val target = root ?: return
// Nothing on the display takes keys until something in it holds focus.
if (target.findFocus() == null) target.requestFocus()
val shift = keyCode == app.seb3thehacker.gearslip.car.CarKeys.KEYCODE_SHIFT_TAB
val code = if (shift) android.view.KeyEvent.KEYCODE_TAB else keyCode
val now = SystemClock.uptimeMillis()
val event = android.view.KeyEvent(
now, now,
if (down) android.view.KeyEvent.ACTION_DOWN else android.view.KeyEvent.ACTION_UP,
code, 0,
if (shift) android.view.KeyEvent.META_SHIFT_ON or android.view.KeyEvent.META_SHIFT_LEFT_ON else 0,
android.view.KeyCharacterMap.VIRTUAL_KEYBOARD, 0, 0, android.view.InputDevice.SOURCE_DPAD,
)
target.dispatchKeyEvent(event)
}
fun stop() { fun stop() {
runCatching { presentation?.dismiss() } runCatching { presentation?.dismiss() }
runCatching { owner?.destroy() } runCatching { owner?.destroy() }
@@ -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,6 +38,15 @@ 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"
} }
@@ -106,29 +115,60 @@ object ServiceDiscovery {
) )
/** /**
* The head unit's touchscreen, from InputSourceService (Service field 4): * The head unit's touchscreen and button set, from InputSourceService (Service field 4):
* InputSourceService { keycodes = 1; repeated TouchScreen touchscreen = 2; ... } * InputSourceService { repeated int32 keycodes_supported = 1 [packed=true];
* repeated TouchScreen touchscreen = 2; ... }
* TouchScreen { required int32 width = 1; required int32 height = 2; type = 3; } * TouchScreen { required int32 width = 1; required int32 height = 2; type = 3; }
* *
* Touch coordinates arrive in this space (1258x708 on the 2018 Uconnect) and must be * Touch coordinates arrive in this space (1258x708 on the 2018 Uconnect) and must be
* scaled to the projected video size before injection. * scaled to the projected video size before injection. [keycodesSupported] is what the
* head unit says it will actually send over the Input channel as key events (steering-wheel
* buttons, etc.) - not every unit reports the same set, so it's kept for diagnostics and to
* know ahead of time whether a button like voice/PTT is even wired up on this hardware.
*/ */
class InputService(val serviceId: Int, val touchWidth: Int, val touchHeight: Int) class InputService(
val serviceId: Int,
val touchWidth: Int,
val touchHeight: Int,
val keycodesSupported: List<Int> = emptyList(),
)
fun findInputService(response: ByteArray): InputService? { fun findInputService(response: ByteArray): InputService? {
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 input = Wire.bytes(service, 4) ?: continue val input = Wire.bytes(service, 4) ?: continue
val touch = Wire.bytes(Wire.fields(input), 2) ?: continue val inputFields = Wire.fields(input)
val touchFields = Wire.fields(touch) // A unit with only a knob and buttons has no touchscreen entry; it still has keys.
val width = Wire.varint(touchFields, 1)?.toInt() ?: continue val touchFields = Wire.bytes(inputFields, 2)?.let { Wire.fields(it) }.orEmpty()
val height = Wire.varint(touchFields, 2)?.toInt() ?: continue val width = Wire.varint(touchFields, 1)?.toInt() ?: 0
return InputService(id, width, height) val height = Wire.varint(touchFields, 2)?.toInt() ?: 0
// Declared packed, but some units send each keycode as its own field.
val keycodes = inputFields.filter { it.number == 1 }.flatMap { f ->
f.bytes?.let(::packedVarints) ?: listOf(f.varint.toInt())
}
return InputService(id, width, height, keycodes)
} }
return null return null
} }
/** Decodes a `[packed=true]` repeated varint field's raw bytes into individual values. */
private fun packedVarints(data: ByteArray): List<Int> = buildList {
var pos = 0
while (pos < data.size) {
var result = 0L
var shift = 0
while (pos < data.size) {
val b = data[pos].toInt() and 0xFF
result = result or ((b and 0x7F).toLong() shl shift)
pos++
if (b and 0x80 == 0) break
shift += 7
}
add(result.toInt())
}
}
fun findVideoService(response: ByteArray): VideoService? { fun findVideoService(response: ByteArray): VideoService? {
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)
@@ -155,6 +195,31 @@ object ServiceDiscovery {
return null return null
} }
/**
* The head unit's sensor feed, from SensorSourceService (Service field 5):
* SensorSourceService { repeated SensorType sensors = 1; }
* SensorType { required int32 type = 1; }
*
* Field numbers for what actually rides inside each SensorEvent once subscribed are not
* pinned down here - unlike video/audio/input, there's no independent source to check them
* against, so [CarSensors] shows the raw decode instead of trusting a guessed field map.
*/
class SensorService(val serviceId: Int, val types: List<Int>)
fun findSensorService(response: ByteArray): SensorService? {
for (channel in Wire.allBytes(Wire.fields(response), 1)) {
val service = Wire.fields(channel)
val id = Wire.varint(service, 1)?.toInt() ?: continue
val sensorSource = Wire.bytes(service, 5) ?: continue
val types = Wire.allBytes(Wire.fields(sensorSource), 1).mapNotNull {
Wire.varint(Wire.fields(it), 1)?.toInt()
}
if (types.isEmpty()) continue
return SensorService(id, types)
}
return null
}
/** /**
* Who the head unit says it is, from the top-level strings of ServiceDiscoveryResponse. * Who the head unit says it is, from the top-level strings of ServiceDiscoveryResponse.
* Field numbers as seen from a 2018 Dodge Challenger: 2="Uconnect", 3="Dodge Challenger", * Field numbers as seen from a 2018 Dodge Challenger: 2="Uconnect", 3="Dodge Challenger",
@@ -23,6 +23,7 @@ object SessionReport {
AUTH("authentication failed"), AUTH("authentication failed"),
SERVICE_DISCOVERY("service discovery failed"), SERVICE_DISCOVERY("service discovery failed"),
VIDEO("video stream failed"), VIDEO("video stream failed"),
BYEBYE("head unit ended the session"),
CRASH("app crash"), CRASH("app crash"),
UNKNOWN("unclassified"), UNKNOWN("unclassified"),
} }
@@ -45,7 +45,8 @@ class VideoSource(
private var frameIndex = 0L private var frameIndex = 0L
fun start() { fun start() {
val format = MediaFormat.createVideoFormat(MIME, width, height).apply { val bitRate = (width.toLong() * height * frameRate * BITS_PER_PIXEL).toInt().coerceIn(MIN_BIT_RATE, MAX_BIT_RATE)
fun format(tuned: Boolean) = MediaFormat.createVideoFormat(MIME, width, height).apply {
setInteger( setInteger(
MediaFormat.KEY_COLOR_FORMAT, MediaFormat.KEY_COLOR_FORMAT,
if (mode == Mode.SURFACE) { if (mode == Mode.SURFACE) {
@@ -54,21 +55,37 @@ class VideoSource(
MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420Flexible MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420Flexible
}, },
) )
setInteger(MediaFormat.KEY_BIT_RATE, 2_000_000) setInteger(MediaFormat.KEY_BIT_RATE, bitRate)
setInteger(MediaFormat.KEY_FRAME_RATE, frameRate) setInteger(MediaFormat.KEY_FRAME_RATE, frameRate)
setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1) // Drops already ask for a fresh keyframe, so a long interval costs nothing in recovery
// and spares the link a big frame every second.
setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, I_FRAME_INTERVAL_S)
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) {
// Hints only: a car screen wants each frame now, not the best frame a moment later.
setInteger(MediaFormat.KEY_BITRATE_MODE, MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_VBR)
setInteger(MediaFormat.KEY_PRIORITY, 0)
setInteger(MediaFormat.KEY_LATENCY, 1)
}
} }
val encoder = MediaCodec.createEncoderByType(MIME) var encoder = MediaCodec.createEncoderByType(MIME)
encoder.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE) try {
encoder.configure(format(tuned = true), null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
} catch (e: Exception) {
// Some encoders refuse a hint they don't know. The plain format is what always worked.
log.w("encoder refused the tuned format (${e.message}) - using the plain one")
encoder.release()
encoder = MediaCodec.createEncoderByType(MIME)
encoder.configure(format(tuned = false), null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
}
if (mode == Mode.SURFACE) inputSurface = encoder.createInputSurface() if (mode == Mode.SURFACE) inputSurface = encoder.createInputSurface()
encoder.start() encoder.start()
codec = encoder codec = encoder
running = true running = true
log.i("encoder started: ${width}x$height @ ${frameRate}fps, H.264 Baseline, mode=$mode") log.i("encoder started: ${width}x$height @ ${frameRate}fps, ${bitRate / 1000} kbps, H.264 Baseline, mode=$mode")
worker = Thread { worker = Thread {
runCatching { pump(encoder) } runCatching { pump(encoder) }
@@ -123,7 +140,10 @@ class VideoSource(
frameIndex++ frameIndex++
} }
var outIndex = encoder.dequeueOutputBuffer(info, TIMEOUT_US) // A surface-fed encoder has nothing to do between frames, so block until one is ready
// instead of sleeping a fixed time: less delay on screen, fewer wakeups for the CPU.
val wait = if (mode == Mode.SURFACE) SURFACE_TIMEOUT_US else TIMEOUT_US
var outIndex = encoder.dequeueOutputBuffer(info, wait)
while (outIndex >= 0) { while (outIndex >= 0) {
val buffer = encoder.getOutputBuffer(outIndex) val buffer = encoder.getOutputBuffer(outIndex)
if (buffer != null && info.size > 0) { if (buffer != null && info.size > 0) {
@@ -144,7 +164,7 @@ class VideoSource(
outIndex = encoder.dequeueOutputBuffer(info, 0) outIndex = encoder.dequeueOutputBuffer(info, 0)
} }
Thread.sleep(maxOf(1L, frameIntervalUs / 2000)) if (mode != Mode.SURFACE) Thread.sleep(maxOf(1L, frameIntervalUs / 2000))
} }
} }
@@ -188,6 +208,13 @@ class VideoSource(
private companion object { private companion object {
const val MIME = MediaFormat.MIMETYPE_VIDEO_AVC const val MIME = MediaFormat.MIMETYPE_VIDEO_AVC
const val TIMEOUT_US = 10_000L const val TIMEOUT_US = 10_000L
const val SURFACE_TIMEOUT_US = 100_000L
/** Enough for sharp text and map lines; 800x480 at 30fps comes out at 4 Mbps. */
const val BITS_PER_PIXEL = 0.3
const val MIN_BIT_RATE = 4_000_000
const val MAX_BIT_RATE = 12_000_000
const val I_FRAME_INTERVAL_S = 5
/** (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(
@@ -7,6 +7,7 @@ import android.app.PendingIntent
import android.app.Service import android.app.Service
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager
import android.content.pm.ServiceInfo import android.content.pm.ServiceInfo
import android.media.AudioAttributes import android.media.AudioAttributes
import android.media.AudioFormat import android.media.AudioFormat
@@ -83,7 +84,13 @@ class AudioCaptureService : Service() {
return START_NOT_STICKY return START_NOT_STICKY
} }
startForeground(NOTIFICATION_ID, notification(), ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION) // Android silences the microphone for apps in the background unless a running service
// claims it. Claiming it here lets voice replies hear the driver with the phone locked.
// Without the permission, claiming it would crash, so it's left off.
val micGranted = checkSelfPermission(android.Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED
val type = ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION or
(if (micGranted) ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE else 0)
startForeground(NOTIFICATION_ID, notification(), type)
ScreenShareGuard.lift(this) ScreenShareGuard.lift(this)
projection = try { projection = try {
getSystemService(MediaProjectionManager::class.java).getMediaProjection(resultCode, data) getSystemService(MediaProjectionManager::class.java).getMediaProjection(resultCode, data)
@@ -129,7 +136,7 @@ class AudioCaptureService : Service() {
AudioRecord.Builder() AudioRecord.Builder()
.setAudioPlaybackCaptureConfig(config) .setAudioPlaybackCaptureConfig(config)
.setAudioFormat(format) .setAudioFormat(format)
.setBufferSizeInBytes(maxOf(minimum, CHUNK_BYTES * 4)) .setBufferSizeInBytes(maxOf(minimum, CHUNK_BYTES * BUFFER_CHUNKS))
.build() .build()
} catch (t: Throwable) { } catch (t: Throwable) {
GearslipLog.e("audio: could not set up playback capture", t) GearslipLog.e("audio: could not set up playback capture", t)
@@ -233,6 +240,8 @@ class AudioCaptureService : Service() {
/** About 21 ms of 48 kHz stereo: small enough for lip-sync, big enough not to flood the link. */ /** About 21 ms of 48 kHz stereo: small enough for lip-sync, big enough not to flood the link. */
private const val CHUNK_BYTES = 4096 private const val CHUNK_BYTES = 4096
/** About half a second: room to wait out a slow ack from the car without losing sound. */
private const val BUFFER_CHUNKS = 24
private const val LEVEL_LOG_MS = 3_000L private const val LEVEL_LOG_MS = 3_000L
private const val SILENCE_MS = 5_000L private const val SILENCE_MS = 5_000L
@@ -37,6 +37,7 @@ class AudioLink(
private var bytes = 0L private var bytes = 0L
private var dropped = 0 private var dropped = 0
@Volatile private var configTimer: Thread? = null @Volatile private var configTimer: Thread? = null
private val ackLock = Object()
private val config get() = service.configs.getOrNull(configIndex) ?: service.configs.firstOrNull() private val config get() = service.configs.getOrNull(configIndex) ?: service.configs.firstOrNull()
override val sampleRate get() = config?.sampleRate ?: 48_000 override val sampleRate get() = config?.sampleRate ?: 48_000
@@ -115,8 +116,11 @@ class AudioLink(
MSG_ACK -> { MSG_ACK -> {
val acked = Wire.varint(Wire.fields(body), 2)?.toInt() ?: 1 val acked = Wire.varint(Wire.fields(body), 2)?.toInt() ?: 1
synchronized(ackLock) {
inFlight = maxOf(0, inFlight - maxOf(1, acked)) inFlight = maxOf(0, inFlight - maxOf(1, acked))
lastAckAt = System.currentTimeMillis() lastAckAt = System.currentTimeMillis()
ackLock.notifyAll()
}
} }
// Whatever the head unit answers Setup with, we need its bytes to identify it. // Whatever the head unit answers Setup with, we need its bytes to identify it.
@@ -166,6 +170,7 @@ class AudioLink(
override fun stop() { override fun stop() {
if (!started) return if (!started) return
started = false started = false
synchronized(ackLock) { ackLock.notifyAll() }
runCatching { runCatching {
sendOnChannel(MSG_STOP, Protobuf.varintField(1, SESSION_ID.toLong())) sendOnChannel(MSG_STOP, Protobuf.varintField(1, SESSION_ID.toLong()))
if (focusRequested) sendControl(MSG_FOCUS_REQUEST, Protobuf.varintField(1, FOCUS_RELEASE.toLong())) if (focusRequested) sendControl(MSG_FOCUS_REQUEST, Protobuf.varintField(1, FOCUS_RELEASE.toLong()))
@@ -179,16 +184,24 @@ class AudioLink(
override fun write(pcm: ByteArray, size: Int) { override fun write(pcm: ByteArray, size: Int) {
if (!started) return if (!started) return
if (inFlight >= maxUnacked) { // Wait for the car to catch up rather than throw the chunk away: a dropped chunk is a gap
// the driver hears, while a short wait is soaked up by the capture buffer behind us.
synchronized(ackLock) {
while (started && inFlight >= maxUnacked) {
val waited = System.currentTimeMillis() - lastAckAt
if (waited > STALL_MS) {
// A lost ack must not silence the stream for good; after a stall assume the window cleared. // A lost ack must not silence the stream for good; after a stall assume the window cleared.
if (System.currentTimeMillis() - lastAckAt > STALL_MS) {
GearslipLog.w("audio: no ack for ${STALL_MS}ms with $inFlight in flight - reopening the window") GearslipLog.w("audio: no ack for ${STALL_MS}ms with $inFlight in flight - reopening the window")
inFlight = 0 inFlight = 0
} else { break
}
ackLock.wait(STALL_MS - waited + 1)
}
}
if (!started) {
dropped++ dropped++
return return
} }
}
val timestamp = ByteArray(8) val timestamp = ByteArray(8)
val micros = System.nanoTime() / 1_000 val micros = System.nanoTime() / 1_000
for (i in 0 until 8) timestamp[i] = ((micros shr ((7 - i) * 8)) and 0xFF).toByte() for (i in 0 until 8) timestamp[i] = ((micros shr ((7 - i) * 8)) and 0xFF).toByte()
@@ -199,7 +212,7 @@ class AudioLink(
started = false started = false
return return
} }
inFlight++ synchronized(ackLock) { inFlight++ }
chunks++ chunks++
bytes += size bytes += size
// The first chunk proves the whole path; after that, an occasional line is enough to show // The first chunk proves the whole path; after that, an occasional line is enough to show
@@ -0,0 +1,182 @@
package app.seb3thehacker.gearslip.car
import android.content.Context
import java.text.DateFormat
import java.util.Date
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* Turns what [CarAssistant] heard into an action plus something to say back.
*
* Deliberately small and keyword-matched rather than run through any NLU or bundled model: a
* fixed set of things a driver actually wants hands-free (media, calling, opening an app,
* getting back Home), covering the same ground the rest of the car UI already does rather than
* trying to be a general assistant.
*/
object AssistantCommands {
suspend fun handle(context: Context, navigator: CarNavigator, frame: CarEnvironment.Frame, heardRaw: String): String {
val heard = heardRaw.lowercase().trim()
navigateCommand(context, navigator, frame, heard)?.let { return it }
mediaCommand(heard)?.let { return it }
navCommand(navigator, heard)?.let { return it }
infoCommand(heard)?.let { return it }
callCommand(context, heard)?.let { return it }
openAppCommand(context, navigator, frame, heard)?.let { return it }
return "I don't know how to do that yet."
}
/**
* "navigate to X [with|using|on|via Y]" - Y picks the nav app by (partial) label; without it,
* whichever nav app was used last, or the only one installed, or the first one found. Starts
* it straight into navigating there via [CarAppConnection]'s `ACTION_NAVIGATE` deep link -
* the same one Android Auto's own Assistant integration uses - rather than opening the app
* and leaving the driver to type the address in on the car keyboard.
*/
private suspend fun navigateCommand(
context: Context,
navigator: CarNavigator,
frame: CarEnvironment.Frame,
heard: String,
): String? {
val trigger = NAVIGATE_TRIGGERS.firstOrNull { heard.startsWith(it) } ?: return null
var destination = heard.removePrefix(trigger).trim()
if (destination.isBlank()) return "Where to?"
var appName: String? = null
for (sep in listOf(" with ", " using ", " on ", " via ")) {
val at = destination.lastIndexOf(sep)
if (at > 0) {
appName = destination.substring(at + sep.length).trim()
destination = destination.substring(0, at).trim()
break
}
}
if (destination.isBlank()) return "Where to?"
val entries = LauncherCache.entries ?: withContext(Dispatchers.IO) { LauncherCache.load(context) }
val navApps = entries.filter { it.template?.isNavigation == true }
if (navApps.isEmpty()) return "You don't have any navigation apps installed."
val requestedApp = appName
val chosen = when {
requestedApp != null -> navApps.firstOrNull { it.label.lowercase().contains(requestedApp) }
?: return "I couldn't find a navigation app called $requestedApp."
navApps.size == 1 -> navApps.first()
else -> navApps.firstOrNull { it.componentId == CarSettings.lastNav.value } ?: navApps.first()
}
val template = chosen.template ?: return "That's not a navigation app."
CarServices.connectNav(template, frame, destination)
navigator.home()
return "Navigating to $destination with ${chosen.label}."
}
private fun mediaCommand(heard: String): String? {
val media = CarServices.media
return when {
"pause" in heard || heard.startsWith("stop") -> {
if (media.now.value.playing) media.togglePlay()
"Paused."
}
"resume" in heard || (heard.startsWith("play") && "playing" !in heard) -> {
if (!media.now.value.playing) media.togglePlay()
"Playing."
}
"next" in heard || "skip" in heard -> {
media.next()
"Skipping."
}
"previous" in heard || "last song" in heard || "back song" in heard -> {
media.previous()
"Playing the previous track."
}
"what" in heard && "playing" in heard -> {
val now = media.now.value
if (now.isActive) listOf(now.title, now.artist).filter { it.isNotBlank() }.joinToString(" by ")
else "Nothing's playing."
}
else -> null
}
}
private fun navCommand(navigator: CarNavigator, heard: String): String? = when {
"go home" in heard || heard == "home" -> {
navigator.home()
"Heading home."
}
"open apps" in heard || heard == "apps" || "app list" in heard -> {
navigator.apps()
"Here are your apps."
}
"settings" in heard -> {
navigator.settings()
"Opening settings."
}
"vehicle data" in heard || "car data" in heard -> {
if (CarSettings.experimentalFeaturesEnabled.value && CarSettings.carSensorsEnabled.value) {
navigator.vehicleData()
"Opening vehicle data."
} else {
"Vehicle data is off. Turn it on under experimental features in settings."
}
}
else -> null
}
private fun infoCommand(heard: String): String? = when {
"what time" in heard || "what's the time" in heard ->
"It's ${DateFormat.getTimeInstance(DateFormat.SHORT).format(Date())}."
"weather" in heard -> {
val data = (Weather.state.value as? WeatherState.Ready)?.data
if (data != null) "${data.temp}${data.tempUnit} and ${Weather.describe(data.code)} in ${data.place}."
else "I don't have the weather yet."
}
else -> null
}
private suspend fun callCommand(context: Context, heard: String): String? {
val name = when {
heard.startsWith("call ") -> heard.removePrefix("call ")
heard.startsWith("phone ") -> heard.removePrefix("phone ")
heard.startsWith("dial ") -> heard.removePrefix("dial ")
else -> return null
}.trim()
if (name.isBlank()) return "Who should I call?"
if (!hasCallPermissions(context)) return "Gearslip needs the phone and contacts permissions first, from the phone screen."
val match = withContext(Dispatchers.IO) {
loadContacts(context).firstOrNull { it.name.lowercase().contains(name) }
} ?: return "I couldn't find $name in your contacts."
placeCall(context, match.number)
return "Calling ${match.name}."
}
private suspend fun openAppCommand(
context: Context,
navigator: CarNavigator,
frame: CarEnvironment.Frame,
heard: String,
): String? {
val name = when {
heard.startsWith("open ") -> heard.removePrefix("open ")
heard.startsWith("launch ") -> heard.removePrefix("launch ")
heard.startsWith("start ") -> heard.removePrefix("start ")
else -> return null
}.trim()
if (name.isBlank()) return null
val entries = LauncherCache.entries ?: withContext(Dispatchers.IO) { LauncherCache.load(context) }
val match = entries.firstOrNull { it.label.lowercase().contains(name) }
?: return "I couldn't find an app called $name."
launchEntry(match, navigator, frame)
return "Opening ${match.label}."
}
private val NAVIGATE_TRIGGERS = listOf("navigate to ", "directions to ", "take me to ", "drive to ")
}
@@ -0,0 +1,107 @@
package app.seb3thehacker.gearslip.car
import app.seb3thehacker.gearslip.car.theme.*
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
import app.seb3thehacker.gearslip.R
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
/**
* The band that appears while [CarAssistant] is listening, thinking or speaking - a thin strip
* across the top, same idea as [CallOverlay]'s active-call banner, so it never blocks the screen
* the driver was already looking at.
*/
@Composable
fun AssistantOverlay(modifier: Modifier = Modifier) {
val state by CarAssistant.state.collectAsState()
if (state.phase == CarAssistant.Phase.IDLE) return
val context = LocalContext.current
Surface(
color = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = modifier.fillMaxWidth(),
) {
Row(
Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
PulsingMic(state.phase == CarAssistant.Phase.LISTENING)
Column(Modifier.weight(1f)) {
Text(
when (state.phase) {
CarAssistant.Phase.LISTENING -> "Listening…"
CarAssistant.Phase.THINKING -> "…"
CarAssistant.Phase.SPEAKING -> state.reply
CarAssistant.Phase.ERROR -> state.reply
CarAssistant.Phase.IDLE -> ""
},
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
)
if (state.heard.isNotBlank() && state.phase != CarAssistant.Phase.SPEAKING) {
Text(
"\"${state.heard}\"",
style = MaterialTheme.typography.bodySmall,
)
}
}
if (state.missingPermission) {
GsButton(tone = GsTone.Tonal, onClick = {
context.startActivity(
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", context.packageName, null)),
)
}) { Text("Grant") }
}
GsIconButton(Icons.Filled.Close, "Dismiss", CarAssistant::cancel)
}
}
}
/** A filled circle with a mic glyph, pulsing gently while actually listening. */
@Composable
private fun PulsingMic(listening: Boolean, modifier: Modifier = Modifier) {
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary,
modifier = modifier.size(36.dp),
) {
Row(horizontalArrangement = Arrangement.Center) {
MicGlyph(Modifier.padding(8.dp).size(20.dp))
}
}
// `listening` only changes what's drawn elsewhere in the row (the "Listening…" label);
// the glyph itself doesn't need to animate for this to read clearly on a glance.
}
/** The assistant's mic, from `R.drawable.mic_24`, tinted with the current content colour. */
@Composable
fun MicGlyph(modifier: Modifier = Modifier) {
Icon(ImageVector.vectorResource(R.drawable.mic_24), contentDescription = "Assistant", modifier = modifier)
}
@@ -1,5 +1,7 @@
package app.seb3thehacker.gearslip.car package app.seb3thehacker.gearslip.car
import androidx.compose.foundation.shape.CircleShape
import app.seb3thehacker.gearslip.car.theme.*
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -12,9 +14,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Call import androidx.compose.material.icons.filled.Call
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButtonDefaults
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
@@ -98,12 +98,14 @@ private fun IncomingCall(s: CallUiState.Ringing, onAnswer: () -> Unit, onDecline
@Composable @Composable
private fun CallButton(icon: ImageVector, label: String, color: Color, enabled: Boolean, onClick: () -> Unit) { private fun CallButton(icon: ImageVector, label: String, color: Color, enabled: Boolean, onClick: () -> Unit) {
Column(horizontalAlignment = Alignment.CenterHorizontally) { Column(horizontalAlignment = Alignment.CenterHorizontally) {
FilledIconButton( GsIconButton(
onClick = onClick, icon, label, onClick,
colors = GsColors(color, Color.White),
enabled = enabled, enabled = enabled,
modifier = Modifier.size(72.dp), size = 72.dp,
colors = IconButtonDefaults.filledIconButtonColors(containerColor = color, contentColor = Color.White), iconSize = 30.dp,
) { Icon(icon, contentDescription = label, modifier = Modifier.size(30.dp)) } shape = CircleShape,
)
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Text(label, style = MaterialTheme.typography.labelLarge) Text(label, style = MaterialTheme.typography.labelLarge)
} }
@@ -1,9 +1,15 @@
package app.seb3thehacker.gearslip.car package app.seb3thehacker.gearslip.car
import app.seb3thehacker.gearslip.car.theme.*
import android.view.SurfaceHolder 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
@@ -13,11 +19,11 @@ import androidx.compose.foundation.layout.padding
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.runtime.Composable import androidx.compose.runtime.Composable
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
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@@ -60,7 +66,6 @@ fun CarAppStage(
AppSurface(connection, frame, template, Modifier.fillMaxSize()) AppSurface(connection, frame, template, Modifier.fillMaxSize())
} }
TemplateChrome(template, Modifier.fillMaxSize().padding(top = if (embedded) 0.dp else CHROME_BAR)) TemplateChrome(template, Modifier.fillMaxSize().padding(top = if (embedded) 0.dp else CHROME_BAR))
if (embedded) CornerMask(EMBEDDED_RADIUS, MaterialTheme.colorScheme.background)
if (!embedded) ChromeBar(status, onDisconnect, Modifier.align(Alignment.TopCenter)) if (!embedded) ChromeBar(status, onDisconnect, Modifier.align(Alignment.TopCenter))
} }
} }
@@ -90,6 +95,29 @@ private fun AppSurface(
} }
} }
// A fresh SurfaceView - and so a fresh Surface - for every size, the way Android Auto hands an
// app a new surface rather than resizing the old one. A resized SurfaceView keeps the same
// BufferQueue, and apps that draw through their own VirtualDisplay build the
// new one on it before releasing the old: the old display's disconnect then takes the new one's
// connection with it and the map freezes for good. The nav bar hiding for the search keyboard
// and the lyrics pane opening both resize this area.
androidx.compose.foundation.layout.BoxWithConstraints(modifier) {
val epoch by connection.surfaceEpoch.collectAsState()
androidx.compose.runtime.key(constraints.maxWidth, constraints.maxHeight, epoch) {
SurfaceHost(connection, frame, onSize = { size = it }, Modifier.fillMaxSize())
}
}
DisposableEffect(connection) { onDispose { connection.detachSurface() } }
}
@Composable
private fun SurfaceHost(
connection: CarAppConnection,
frame: CarEnvironment.Frame,
onSize: (Pair<Int, Int>) -> Unit,
modifier: Modifier,
) {
AndroidView( AndroidView(
modifier = modifier modifier = modifier
.pointerInput(connection) { .pointerInput(connection) {
@@ -102,6 +130,30 @@ private fun AppSurface(
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 {
@@ -115,20 +167,20 @@ private fun AppSurface(
height: Int, height: Int,
) { ) {
GearslipLog.i("stage: surface ready at ${width}x$height") GearslipLog.i("stage: surface ready at ${width}x$height")
size = width to height onSize(width to height)
connection.attachSurface(holder.surface, width, height, frame.densityDpi) connection.attachSurface(holder.surface, width, height, frame.densityDpi)
} }
override fun surfaceDestroyed(holder: SurfaceHolder) { override fun surfaceDestroyed(holder: SurfaceHolder) {
GearslipLog.i("stage: surface destroyed") GearslipLog.i("stage: surface destroyed")
connection.detachSurface() // Only this view's own surface: a replaced view's teardown can land after
// its successor has already lent the app a new one.
connection.detachSurface(holder.surface)
} }
}) })
} }
}, },
) )
DisposableEffect(connection) { onDispose { connection.detachSurface() } }
} }
@Composable @Composable
@@ -158,34 +210,13 @@ private fun ChromeBar(
}.joinToString(" - ") }.joinToString(" - ")
Text(detail, style = MaterialTheme.typography.bodySmall) Text(detail, style = MaterialTheme.typography.bodySmall)
} }
TextButton(onClick = onDisconnect) { Text("Stop", color = Color.White) } GsButton(onClick = onDisconnect, tone = GsTone.Tonal) { Text("Stop") }
} }
} }
} }
/** Corner radius of the home screen's map pane and player. */
val EMBEDDED_RADIUS = 18.dp
/**
* Rounds the corners of a SurfaceView, which Compose cannot clip: the app draws into its own
* layer, so the corners are covered from above with the surrounding colour instead.
*/
@Composable
private fun CornerMask(radius: androidx.compose.ui.unit.Dp, color: Color) {
androidx.compose.foundation.Canvas(Modifier.fillMaxSize()) {
val bounds = androidx.compose.ui.geometry.Rect(0f, 0f, size.width, size.height)
val cut = androidx.compose.ui.graphics.Path().apply {
addRect(bounds)
addRoundRect(
androidx.compose.ui.geometry.RoundRect(
bounds, androidx.compose.ui.geometry.CornerRadius(radius.toPx()),
),
)
fillType = androidx.compose.ui.graphics.PathFillType.EvenOdd
}
drawPath(cut, color)
}
}
/** 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
@@ -0,0 +1,265 @@
package app.seb3thehacker.gearslip.car
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.speech.RecognitionListener
import android.speech.RecognizerIntent
import android.speech.SpeechRecognizer
import android.speech.tts.TextToSpeech
import android.speech.tts.UtteranceProgressListener
import androidx.core.content.ContextCompat
import app.seb3thehacker.gearslip.call.CallUiState
import app.seb3thehacker.gearslip.call.CarCalls
import app.seb3thehacker.gearslip.speech.SpeechEngine
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* A voice assistant: Gearslip's own [SpeechEngine] (whisper.cpp) to listen, the system's
* [TextToSpeech] to answer. Listening used to go through the system's [SpeechRecognizer] alone,
* but many phones have no recognizer at all (GrapheneOS ships only text-to-speech), so the
* system one is now just the fallback for when the bundled engine can't run.
*
* Listening and speaking live here; deciding what a command *means* is [AssistantCommands], kept
* separate because it needs a [CarNavigator] and a frame to act on, neither of which this object
* has - see [pendingCommand].
*/
object CarAssistant {
enum class Phase { IDLE, LISTENING, THINKING, SPEAKING, ERROR }
data class State(
val phase: Phase = Phase.IDLE,
val heard: String = "",
val reply: String = "",
val missingPermission: Boolean = false,
)
private val _state = MutableStateFlow(State())
val state: StateFlow<State> = _state
/** Set once a recognition result comes in; [reply] clears it once handled. */
private val _pendingCommand = MutableStateFlow<String?>(null)
val pendingCommand: StateFlow<String?> = _pendingCommand
private var appContext: Context? = null
private var recognizer: SpeechRecognizer? = null
private var tts: TextToSpeech? = null
private var ttsReady = false
fun init(context: Context) {
appContext = context.applicationContext
if (tts != null) return
tts = TextToSpeech(appContext) { status -> ttsReady = status == TextToSpeech.SUCCESS }
}
private val mainHandler = Handler(Looper.getMainLooper())
/**
* Starts listening, or does nothing if the assistant is already busy with the last request.
* [SpeechRecognizer] must be driven from a thread with a Looper, but this is also called from
* the raw wire-protocol worker thread ([GearslipRunner]'s key-event handling for the head
* unit's own PTT button) which has none - so this always hops to the main thread itself
* rather than asking every caller to remember to.
*/
fun start() {
if (Looper.myLooper() == Looper.getMainLooper()) startOnMainThread() else mainHandler.post(::startOnMainThread)
}
private fun startOnMainThread() {
val context = appContext ?: return
if (_state.value.phase == Phase.LISTENING || _state.value.phase == Phase.THINKING) return
if (ContextCompat.checkSelfPermission(context, android.Manifest.permission.RECORD_AUDIO) !=
PackageManager.PERMISSION_GRANTED
) {
_state.value = State(Phase.ERROR, reply = "Needs the microphone permission.", missingPermission = true)
return
}
// Android hands every app but the dialer a silent microphone during a call.
if (CarCalls.state.value is CallUiState.Active) {
_state.value = State(Phase.ERROR, reply = "Voice commands work once the call ends.")
return
}
tts?.stop()
_state.value = State(Phase.LISTENING)
listenOnce(
onPartial = { text -> _state.value = _state.value.copy(heard = text) },
onHeard = { _state.value = _state.value.copy(phase = Phase.THINKING) },
onResult = { text ->
_state.value = _state.value.copy(phase = Phase.THINKING, heard = text)
_pendingCommand.value = text
},
onError = { message -> _state.value = State(Phase.ERROR, reply = message) },
)
}
/** Closes the overlay from wherever it's showing: mid-listen, mid-think, or after speaking. */
fun cancel() {
releaseRecognizer()
tts?.stop()
_pendingCommand.value = null
_state.value = State(Phase.IDLE)
}
/**
* Called once [pendingCommand] has been turned into an answer - from [CarUi], the one place
* that actually has a [CarNavigator] to have acted on it with.
*/
fun reply(text: String) {
_pendingCommand.value = null
_state.value = _state.value.copy(phase = Phase.SPEAKING, reply = text)
val engine = tts
if (engine == null || !ttsReady || text.isBlank()) {
_state.value = _state.value.copy(phase = Phase.IDLE)
return
}
engine.setOnUtteranceProgressListener(object : UtteranceProgressListener() {
override fun onStart(utteranceId: String?) {}
override fun onDone(utteranceId: String?) {
_state.value = _state.value.copy(phase = Phase.IDLE)
}
@Deprecated("Deprecated in Java, no replacement on this API level")
override fun onError(utteranceId: String?) {
_state.value = _state.value.copy(phase = Phase.IDLE)
}
})
engine.speak(text, TextToSpeech.QUEUE_FLUSH, null, "gearslip-assistant")
}
/**
* Speaks [text] for someone other than the assistant (a message read aloud), then runs
* [onDone] on the main thread - straight away if speech isn't available.
*/
fun say(text: String, onDone: () -> Unit) {
val engine = tts
if (engine == null || !ttsReady || text.isBlank()) {
mainHandler.post(onDone)
return
}
engine.setOnUtteranceProgressListener(object : UtteranceProgressListener() {
override fun onStart(utteranceId: String?) {}
override fun onDone(utteranceId: String?) { mainHandler.post(onDone) }
@Deprecated("Deprecated in Java, no replacement on this API level")
override fun onError(utteranceId: String?) { mainHandler.post(onDone) }
// Stopped part way: whoever stopped it has already moved on.
override fun onStop(utteranceId: String?, interrupted: Boolean) {}
})
engine.speak(text, TextToSpeech.QUEUE_FLUSH, null, "gearslip-say")
}
fun stopSpeaking() { tts?.stop() }
/**
* Listens once, the same way the assistant does, and hands back what was said without acting
* on it. Callbacks arrive on the main thread; [onError] gets a sentence fit to show.
*/
fun dictate(
onPartial: (String) -> Unit,
onResult: (String) -> Unit,
onError: (String) -> Unit,
cue: Boolean = true,
onSpeaking: () -> Unit = {},
) {
cancel()
listenOnce(onPartial, onHeard = {}, onResult, onError, cue, onSpeaking)
}
/**
* One phrase, through Gearslip's own [SpeechEngine]. The phone's recognizer is only a fallback
* for when the engine itself can't run, since plenty of phones (GrapheneOS among them) have none.
*/
private fun listenOnce(
onPartial: (String) -> Unit,
onHeard: () -> Unit,
onResult: (String) -> Unit,
onError: (String) -> Unit,
cue: Boolean = true,
onSpeaking: () -> Unit = {},
) {
val context = appContext ?: return onError("Speech isn't ready yet.")
SpeechEngine.listen(
context,
cue = cue,
onSpeaking = onSpeaking,
onHeard = onHeard,
onResult = onResult,
onError = { message ->
if (message == SpeechEngine.NOT_LOADED && SpeechRecognizer.isRecognitionAvailable(context)) {
systemListen(context, onPartial, onHeard, onResult, onError)
} else {
onError(message)
}
},
)
}
private fun systemListen(
context: Context,
onPartial: (String) -> Unit,
onHeard: () -> Unit,
onResult: (String) -> Unit,
onError: (String) -> Unit,
) {
val r = SpeechRecognizer.createSpeechRecognizer(context)
recognizer = r
r.setRecognitionListener(object : RecognitionListener {
override fun onReadyForSpeech(params: Bundle?) {}
override fun onBeginningOfSpeech() {}
override fun onRmsChanged(rmsdB: Float) {}
override fun onBufferReceived(buffer: ByteArray?) {}
override fun onEndOfSpeech() = onHeard()
override fun onEvent(eventType: Int, params: Bundle?) {}
override fun onError(error: Int) {
releaseRecognizer()
onError(errorMessage(error))
}
override fun onResults(results: Bundle?) {
releaseRecognizer()
val text = results?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)?.firstOrNull().orEmpty()
if (text.isBlank()) onError("Didn't catch that.") else onResult(text)
}
override fun onPartialResults(partialResults: Bundle?) {
partialResults?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)?.firstOrNull()?.let(onPartial)
}
})
r.startListening(
Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true)
putExtra(RecognizerIntent.EXTRA_CALLING_PACKAGE, context.packageName)
},
)
}
fun stopListening() = releaseRecognizer()
private fun releaseRecognizer() {
SpeechEngine.cancel()
recognizer?.let { runCatching { it.cancel() }; it.destroy() }
recognizer = null
}
private fun errorMessage(code: Int): String = when (code) {
SpeechRecognizer.ERROR_NO_MATCH, SpeechRecognizer.ERROR_SPEECH_TIMEOUT -> "Didn't catch that."
SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS -> "Needs the microphone permission."
SpeechRecognizer.ERROR_RECOGNIZER_BUSY -> "The speech recognizer is busy."
SpeechRecognizer.ERROR_NETWORK, SpeechRecognizer.ERROR_NETWORK_TIMEOUT -> "The speech recognizer needs a network it doesn't have."
else -> "Speech recognition failed."
}
fun shutdown() {
releaseRecognizer()
tts?.shutdown()
tts = null
ttsReady = false
}
}
@@ -22,7 +22,8 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.produceState import androidx.compose.runtime.produceState
import androidx.compose.foundation.clickable import app.seb3thehacker.gearslip.car.theme.GsColors
import app.seb3thehacker.gearslip.car.theme.GsIconBox
import androidx.compose.ui.Alignment 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
@@ -43,7 +44,8 @@ import kotlinx.coroutines.withContext
import org.json.JSONObject import org.json.JSONObject
/** /**
* Opened from the nav bar clock: the week's calendar and the current weather, at a glance. Both * Opened from the nav bar clock: the current weather, the week's calendar and the phone's
* notifications, at a glance. The first two
* need permissions granted on the phone (READ_CALENDAR, location) - if either was declined * need permissions granted on the phone (READ_CALENDAR, location) - if either was declined
* this just shows an empty state instead of asking again from inside the car UI. * this just shows an empty state instead of asking again from inside the car UI.
*/ */
@@ -61,6 +63,7 @@ fun CarDashboardScreen() {
Text("Today", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold) Text("Today", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
WeatherCard(context) WeatherCard(context)
AgendaCard(context) AgendaCard(context)
NotificationsSection()
} }
} }
@@ -70,10 +73,10 @@ private fun WeatherCard(context: Context) {
val navigator = LocalCarNavigator.current val navigator = LocalCarNavigator.current
LaunchedEffect(Unit) { Weather.refresh(context) } LaunchedEffect(Unit) { Weather.refresh(context) }
Surface( GsIconBox(
color = MaterialTheme.colorScheme.surfaceContainerHigh, onClick = { navigator.weather() },
colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHigh, MaterialTheme.colorScheme.onSurface),
shape = MaterialTheme.shapes.extraLarge, shape = MaterialTheme.shapes.extraLarge,
modifier = Modifier.clickable { navigator.weather() },
) { ) {
Row( Row(
Modifier.fillMaxWidth().padding(20.dp), Modifier.fillMaxWidth().padding(20.dp),
@@ -1,5 +1,6 @@
package app.seb3thehacker.gearslip.car package app.seb3thehacker.gearslip.car
import app.seb3thehacker.gearslip.car.theme.*
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -13,20 +14,46 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.FavoriteBorder
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
import android.support.v4.media.session.PlaybackStateCompat
import app.seb3thehacker.gearslip.R
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import android.media.session.PlaybackState
import app.seb3thehacker.gearslip.media.MediaArt
import kotlinx.coroutines.delay
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
import app.seb3thehacker.gearslip.media.ArtColor
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
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.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -37,11 +64,11 @@ import app.seb3thehacker.gearslip.media.NowPlaying
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
/** /**
* The home screen: the map on the left, lyrics on the right when asked for. * The home screen: the map on the left, the docked player on the right when asked for.
* *
* The map is whatever navigation app was used last, brought back on its own. Playback itself is * The map is whatever navigation app was used last, brought back on its own. The player sits in
* controlled from the nav bar's pill everywhere, including here - this column only opens when the * the nav bar's pill until its dock button moves it here; the column closes itself back to a
* pill's lyrics button is on, and closes itself back to a full-width map once nothing is playing. * full-width map once nothing is playing.
*/ */
@Composable @Composable
fun CarHome() { fun CarHome() {
@@ -54,19 +81,35 @@ fun CarHome() {
LaunchedEffect(phase) { if (phase == CarMedia.Phase.READY) CarServices.autoplayIfDue() } LaunchedEffect(phase) { if (phase == CarMedia.Phase.READY) CarServices.autoplayIfDue() }
val navigator = LocalCarNavigator.current val navigator = LocalCarNavigator.current
val showLyrics = phase == CarMedia.Phase.READY && now.isActive && navigator.lyricsOpen val panel = navigator.sidePanel
Row(Modifier.fillMaxSize().padding(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { // Coming back to the map from another screen: the map stayed loaded underneath, so its surface
// 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 seenHome = remember { booleanArrayOf(false) }
LaunchedEffect(onHome) {
if (onHome && seenHome[0]) CarServices.nav.replaceDeadSurface()
if (onHome) seenHome[0] = true
}
val splitScreen = phase == CarMedia.Phase.READY && now.isActive && panel != SidePanel.NONE
// No outer padding on any side, split screen or not - only the gap between the two panes
// when the player is sharing the screen with the map.
Row(
Modifier.fillMaxSize(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Box( Box(
Modifier Modifier
.weight(if (showLyrics) 2.1f else 1f) .weight(if (splitScreen) 2.1f else 1f)
.fillMaxHeight() .fillMaxHeight(),
.clip(RoundedCornerShape(EMBEDDED_RADIUS)),
) { ) {
MapPane(navStatus, frame) MapPane(navStatus, frame)
} }
if (showLyrics) { if (splitScreen) {
LyricsColumn(now, Modifier.weight(1f).fillMaxHeight()) val side = Modifier.weight(1f).fillMaxHeight()
if (panel == SidePanel.LYRICS) SideLyrics(now, side) else DockedPlayer(now, side)
} }
} }
} }
@@ -85,6 +128,7 @@ private fun MapPane(status: CarAppConnection.Status, frame: CarEnvironment.Frame
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
val hasLast = CarSettings.lastNav.value != null val hasLast = CarSettings.lastNav.value != null
val hasNavApps by CarServices.hasNavApps.collectAsStateWithLifecycle()
val scope = androidx.compose.runtime.rememberCoroutineScope() val scope = androidx.compose.runtime.rememberCoroutineScope()
when (status.phase) { when (status.phase) {
CarAppConnection.Phase.BINDING, CarAppConnection.Phase.HANDSHAKE -> CarAppConnection.Phase.BINDING, CarAppConnection.Phase.HANDSHAKE ->
@@ -92,59 +136,123 @@ private fun MapPane(status: CarAppConnection.Status, frame: CarEnvironment.Frame
CarAppConnection.Phase.REJECTED, CarAppConnection.Phase.FAILED -> { CarAppConnection.Phase.REJECTED, CarAppConnection.Phase.FAILED -> {
Text("${status.app ?: "The map app"} would not start.", style = MaterialTheme.typography.titleMedium) Text("${status.app ?: "The map app"} would not start.", style = MaterialTheme.typography.titleMedium)
status.detail?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) } status.detail?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) }
TextButton(onClick = navigator::apps) { Text("Choose another app") } GsButton(onClick = navigator::apps, tone = GsTone.Tonal) { Text("Choose another app") }
} }
else -> { else -> if (!hasNavApps) {
// IDLE with a last app on record means the driver stopped it themselves - it will // Nothing to auto-select and nothing to pick from Car apps either - this is the
// not come back on its own, so the screen must offer a way to bring it back rather // only case worth a distinct screen, since every other IDLE case resolves itself.
// than sit on a "starting" message that never resolves. Text("No mapping apps found.", style = MaterialTheme.typography.titleMedium)
Text( Text(
if (hasLast) "Map stopped." else "No map app chosen yet.", "Install a navigation app that supports Android Auto and it will start here on its own.",
style = MaterialTheme.typography.titleMedium,
)
if (hasLast) {
TextButton(onClick = { scope.launch { CarServices.reconnectNav(frame) } }) { Text("Reopen") }
} else {
Text(
"Open Car apps and connect a navigation app; it will start here next time.",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} } else if (hasLast) {
TextButton(onClick = { navigator.apps() }) { Text("Car apps") } // IDLE with a last app on record means the driver stopped it themselves - it will
// not come back on its own, so the screen must offer a way to bring it back rather
// than sit on a "starting" message that never resolves.
Text("Map stopped.", style = MaterialTheme.typography.titleMedium)
GsButton(onClick = { scope.launch { CarServices.reconnectNav(frame) } }, tone = GsTone.Tonal) { Text("Reopen") }
GsButton(onClick = { navigator.apps() }, tone = GsTone.Tonal) { Text("Car apps") }
} else {
// Autostart is still picking one - a navigation app is installed, so this resolves
// to a "Starting..." state itself, in the next frame or two.
} }
} }
} }
} }
/** /**
* Lyrics for whatever the nav bar's pill is playing, beside the map. Transport controls live in * The player, docked beside the map: art, title, progress, transport, then like, shuffle, repeat
* the pill itself now, not here - this column has exactly one job. * and lyrics. While it's here the nav bar's pill steps aside on Home, so the controls never show
* twice. The X sends it back to the nav bar.
*/ */
@Composable @Composable
private fun LyricsColumn(now: NowPlaying, modifier: Modifier) { private fun DockedPlayer(now: NowPlaying, modifier: Modifier) {
val navigator = LocalCarNavigator.current val navigator = LocalCarNavigator.current
Surface( val media = CarServices.media
modifier = modifier, val context = LocalContext.current
shape = RoundedCornerShape(EMBEDDED_RADIUS), val scheme = MaterialTheme.colorScheme
color = MaterialTheme.colorScheme.surfaceVariant, val art by produceState(now.art, now.art, now.artUri) { value = now.art ?: MediaArt.load(context, now.artUri) }
Surface(modifier = modifier, color = rememberArtColor(art, MaterialTheme.colorScheme.surfaceVariant)) {
BoxWithConstraints(Modifier.fillMaxSize().padding(12.dp)) {
// Whatever height the controls below leave over, so the art grows on a taller
// screen and shrinks rather than pushing the buttons off a short one.
val artSize = minOf(maxWidth - 44.dp, maxHeight - 250.dp).coerceIn(56.dp, 200.dp)
Column(Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally) {
Box(Modifier.fillMaxWidth()) {
// Art opens the full media screen, same as on the pill.
Box(
Modifier
.align(Alignment.TopCenter)
.size(artSize)
.clip(RoundedCornerShape(12.dp))
.background(scheme.surface)
.clickable { navigator.media() },
) { ) {
Column(Modifier.fillMaxSize().padding(16.dp)) { art?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) }
Row(verticalAlignment = Alignment.CenterVertically) { }
Text( GsIconButton(
now.title.ifEmpty { "Lyrics" }, Icons.Filled.Close, "Back to the nav bar", navigator::closeSidePanel,
style = MaterialTheme.typography.titleMedium, Modifier.align(Alignment.TopEnd), size = 36.dp, iconSize = 20.dp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
) )
IconButton(onClick = navigator::toggleLyrics, modifier = Modifier.size(36.dp)) {
Icon(Icons.Filled.Close, "Hide lyrics", Modifier.size(20.dp))
} }
Spacer(Modifier.height(10.dp))
Text(
now.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold,
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
Text(
now.artist, style = MaterialTheme.typography.bodyMedium, color = scheme.onSurfaceVariant,
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
if (now.durationMs > 0) {
PlayerProgress(now, media, Modifier.fillMaxWidth().padding(vertical = 2.dp), showTimes = false)
} else {
Spacer(Modifier.height(10.dp))
} }
Spacer(Modifier.height(4.dp)) TransportRow(now, media, skip = 48.dp, modifier = Modifier.fillMaxWidth())
LyricsPanel(now, Modifier.weight(1f).fillMaxWidth()) Spacer(Modifier.height(10.dp))
PlayModeRow(now, media, size = 42.dp, modifier = Modifier.fillMaxWidth()) {
GsIconButton(
ImageVector.vectorResource(R.drawable.lyrics_24), "Lyrics", navigator::showSideLyrics,
size = 42.dp, iconSize = 22.dp,
)
}
}
}
}
}
/**
* Lyrics alone beside the map, big enough to read at a glance. No art: that space goes to a
* larger title and artist instead. The player goes back to the nav bar meanwhile, whose dock
* button swaps these back out for the controls.
*/
@Composable
private fun SideLyrics(now: NowPlaying, modifier: Modifier) {
val navigator = LocalCarNavigator.current
val context = LocalContext.current
val art by produceState(now.art, now.art, now.artUri) { value = now.art ?: MediaArt.load(context, now.artUri) }
Surface(modifier = modifier, color = rememberArtColor(art, MaterialTheme.colorScheme.surfaceVariant)) {
Column(Modifier.fillMaxSize().padding(16.dp)) {
Row(verticalAlignment = Alignment.Top) {
Column(Modifier.weight(1f)) {
Text(
now.title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold,
maxLines = 2, overflow = TextOverflow.Ellipsis,
)
Text(
now.artist, style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
}
GsIconButton(Icons.Filled.Close, "Hide lyrics", navigator::closeSidePanel, size = 36.dp, iconSize = 20.dp)
}
Spacer(Modifier.height(12.dp))
LyricsPanel(now, Modifier.weight(1f).fillMaxWidth(), lineStyle = MaterialTheme.typography.headlineSmall)
} }
} }
} }
@@ -1,5 +1,6 @@
package app.seb3thehacker.gearslip.car package app.seb3thehacker.gearslip.car
import app.seb3thehacker.gearslip.car.theme.*
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -8,12 +9,14 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Search 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.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -23,9 +26,17 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector 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.clickable
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
/**
* Whether a [CarKeyboard] is on screen right now, so [CarUi] can give it the row of height the
* bottom nav bar normally holds instead of squeezing it in above - a driver typing has no use
* for Home/Apps/Settings shortcuts anyway, and the screen is small enough that the space matters.
*/
object CarKeyboardVisibility {
val state = mutableStateOf(false)
}
/** /**
* A keyboard the host draws itself. * A keyboard the host draws itself.
* *
@@ -35,7 +46,13 @@ import androidx.compose.foundation.shape.RoundedCornerShape
* the same reasons, so this does too - large keys, no long-press, no prediction. * the same reasons, so this does too - large keys, no long-press, no prediction.
* *
* It is a pure function of [text]: every key reports the new string and holds no state beyond * It is a pure function of [text]: every key reports the new string and holds no state beyond
* which layer is showing. * which layer is showing. Laid out like a PC's QWERTY keyboard rather than a phone's: shift and
* backspace flank the letter block (top-right and bottom-left of it, same as a physical board),
* not the space bar, so a fumbled reach for space never lands on delete instead.
*
* [onDismiss] is null wherever there's no keyboard-shown state independent of the screen itself
* (a search template, sign-in) - there, the header's own back action is the only way out and no
* dismiss button is drawn.
*/ */
@Composable @Composable
fun CarKeyboard( fun CarKeyboard(
@@ -45,10 +62,16 @@ fun CarKeyboard(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
submitIcon: Boolean = true, submitIcon: Boolean = true,
submitImage: ImageVector? = null, submitImage: ImageVector? = null,
onDismiss: (() -> Unit)? = null,
) { ) {
var symbols by remember { mutableStateOf(false) } var symbols by remember { mutableStateOf(false) }
var shifted by remember { mutableStateOf(false) } var shifted by remember { mutableStateOf(false) }
DisposableEffect(Unit) {
CarKeyboardVisibility.state.value = true
onDispose { CarKeyboardVisibility.state.value = false }
}
val rows = if (symbols) SYMBOL_ROWS else LETTER_ROWS val rows = if (symbols) SYMBOL_ROWS else LETTER_ROWS
Surface( Surface(
@@ -59,7 +82,7 @@ fun CarKeyboard(
Modifier.padding(horizontal = 6.dp, vertical = 4.dp), Modifier.padding(horizontal = 6.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp),
) { ) {
rows.forEach { row -> rows.dropLast(1).forEach { row ->
Row( Row(
Modifier.fillMaxWidth(), Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp),
@@ -74,19 +97,40 @@ fun CarKeyboard(
} }
} }
// The bottom letter row: shift and backspace bookend it, exactly where a physical
// keyboard puts them relative to the letter block, not down beside the space bar.
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
if (!symbols) {
Key(if (shifted) "SHIFT" else "shift", Modifier.weight(1.6f)) { shifted = !shifted }
}
rows.last().forEach { key ->
val label = if (shifted && !symbols) key.uppercase() else key
Key(label, Modifier.weight(1f)) {
onTextChange(text + label)
shifted = false
}
}
Key("⌫", Modifier.weight(1.6f)) {
if (text.isNotEmpty()) onTextChange(text.dropLast(1))
}
}
// Control row: no backspace here - it stays with the letters above, not next to the
// key most likely to be hit right after it by mistake. Dismiss (when offered) is the
// last key, bottom-right corner of the keyboard.
Row( Row(
Modifier.fillMaxWidth(), Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp),
) { ) {
Key(if (symbols) "abc" else "?123", Modifier.weight(1.4f)) { symbols = !symbols } Key(if (symbols) "abc" else "?123", Modifier.weight(1.4f)) { symbols = !symbols }
if (!symbols) { Key("space", Modifier.weight(if (onDismiss != null) 3.2f else 4f)) { onTextChange("$text ") }
Key(if (shifted) "SHIFT" else "shift", Modifier.weight(1.4f)) { shifted = !shifted }
}
Key("space", Modifier.weight(3f)) { onTextChange("$text ") }
Key("⌫", Modifier.weight(1.4f)) {
if (text.isNotEmpty()) onTextChange(text.dropLast(1))
}
SubmitKey(Modifier.weight(1.6f), submitIcon, submitImage, onSubmit) SubmitKey(Modifier.weight(1.6f), submitIcon, submitImage, onSubmit)
if (onDismiss != null) {
DismissKey(onDismiss, Modifier.weight(1.4f))
}
} }
} }
} }
@@ -94,11 +138,11 @@ fun CarKeyboard(
@Composable @Composable
private fun Key(label: String, modifier: Modifier = Modifier, onClick: () -> Unit) { private fun Key(label: String, modifier: Modifier = Modifier, onClick: () -> Unit) {
Surface( GsIconBox(
color = MaterialTheme.colorScheme.surfaceContainerHighest, onClick = onClick,
contentColor = MaterialTheme.colorScheme.onSurface, modifier = modifier.height(KEY_HEIGHT),
colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.colorScheme.onSurface),
shape = RoundedCornerShape(8.dp), shape = RoundedCornerShape(8.dp),
modifier = modifier.height(KEY_HEIGHT).clickable(onClick = onClick),
) { ) {
Row( Row(
Modifier.fillMaxWidth(), Modifier.fillMaxWidth(),
@@ -116,12 +160,30 @@ private fun Key(label: String, modifier: Modifier = Modifier, onClick: () -> Uni
} }
@Composable @Composable
private fun DismissKey(onClick: () -> Unit, modifier: Modifier = Modifier) {
GsIconBox(
onClick = onClick,
modifier = modifier.height(KEY_HEIGHT),
colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.colorScheme.onSurfaceVariant),
shape = RoundedCornerShape(8.dp),
) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
Icon(Icons.Filled.Close, contentDescription = "Hide keyboard")
}
}
}
@Composable
private fun SubmitKey(modifier: Modifier, search: Boolean, image: ImageVector?, onClick: () -> Unit) { private fun SubmitKey(modifier: Modifier, search: Boolean, image: ImageVector?, onClick: () -> Unit) {
Surface( GsIconBox(
color = MaterialTheme.colorScheme.primary, onClick = onClick,
contentColor = MaterialTheme.colorScheme.onPrimary, modifier = modifier.height(KEY_HEIGHT),
colors = GsColors(MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.onPrimary),
shape = RoundedCornerShape(8.dp), shape = RoundedCornerShape(8.dp),
modifier = modifier.height(KEY_HEIGHT).clickable(onClick = onClick),
) { ) {
Row( Row(
Modifier.fillMaxWidth(), Modifier.fillMaxWidth(),
@@ -0,0 +1,137 @@
package app.seb3thehacker.gearslip.car
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.view.KeyEvent
import app.seb3thehacker.gearslip.GearslipLog
import app.seb3thehacker.gearslip.call.CallUiState
import app.seb3thehacker.gearslip.call.CarCalls
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
/**
* The car's own buttons: steering wheel, rotary knob, D-pad and the hard keys around the screen.
*
* The head unit sends Android keycodes as they are, so the numbers here are android.view.KeyEvent's.
* Media, voice and call keys act straight away. Keys that move around the car UI (back, home, the
* knob) need a [CarNavigator], which only [CarUi] has, so they go out on [screenKeys]. Focus keys
* (D-pad, knob turns, OK) become real key events on the car's display through [focusSink], where
* Compose moves focus between buttons the same way it would with a keyboard.
*/
object CarKeys {
/** A key that changes which screen is showing. */
enum class ScreenKey { BACK, HOME, MAP, MEDIA, PHONE }
private val _screenKeys = MutableSharedFlow<ScreenKey>(extraBufferCapacity = 8)
val screenKeys: SharedFlow<ScreenKey> = _screenKeys
/** Delivers an Android key event to the car display; set while the car UI is showing. */
@Volatile var focusSink: ((keyCode: Int, down: Boolean) -> Unit)? = null
private var appContext: Context? = null
private val main = Handler(Looper.getMainLooper())
fun init(context: Context) { appContext = context.applicationContext }
/**
* Every key worth asking the head unit for. The binding request lists what the head unit
* offered; when it offers nothing (some units leave the list empty and send keys anyway),
* this is what's asked for instead.
*/
val WANTED = listOf(
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, KeyEvent.KEYCODE_MEDIA_PLAY, KeyEvent.KEYCODE_MEDIA_PAUSE,
KeyEvent.KEYCODE_MEDIA_NEXT, KeyEvent.KEYCODE_MEDIA_PREVIOUS, KeyEvent.KEYCODE_MEDIA_STOP,
KeyEvent.KEYCODE_SEARCH, KeyEvent.KEYCODE_VOICE_ASSIST,
KeyEvent.KEYCODE_CALL, KeyEvent.KEYCODE_ENDCALL,
KeyEvent.KEYCODE_BACK, KeyEvent.KEYCODE_HOME,
KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_DOWN, KeyEvent.KEYCODE_DPAD_LEFT,
KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER,
KEYCODE_ROTARY_CONTROLLER, KEYCODE_MEDIA, KEYCODE_NAVIGATION, KEYCODE_TEL,
)
/**
* One key edge from the head unit. Most keys act on the press; focus keys pass both edges on,
* so a held D-pad key repeats the way it would anywhere else. Returns false for a key nothing
* here knows, for the caller to log.
*/
fun onKey(keyCode: Int, down: Boolean, longPress: Boolean): Boolean {
if (keyCode in FOCUS_KEYS) {
focusSink?.invoke(keyCode, down)
return true
}
if (!down) return keyCode in WANTED
val media = CarServices.media
when (keyCode) {
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> media.togglePlay()
KeyEvent.KEYCODE_MEDIA_PLAY -> if (!media.now.value.playing) media.togglePlay()
KeyEvent.KEYCODE_MEDIA_PAUSE, KeyEvent.KEYCODE_MEDIA_STOP -> if (media.now.value.playing) media.togglePlay()
KeyEvent.KEYCODE_MEDIA_NEXT -> media.next()
KeyEvent.KEYCODE_MEDIA_PREVIOUS -> media.previous()
KeyEvent.KEYCODE_SEARCH, KeyEvent.KEYCODE_VOICE_ASSIST -> voice()
KeyEvent.KEYCODE_CALL -> call()
KeyEvent.KEYCODE_ENDCALL -> endCall()
KeyEvent.KEYCODE_BACK -> screen(ScreenKey.BACK)
KeyEvent.KEYCODE_HOME -> screen(ScreenKey.HOME)
KEYCODE_NAVIGATION -> screen(ScreenKey.MAP)
KEYCODE_MEDIA -> screen(ScreenKey.MEDIA)
KEYCODE_TEL -> screen(ScreenKey.PHONE)
else -> return false
}
if (longPress) GearslipLog.i("keys: long press on $keyCode")
return true
}
/** A turn of the rotary knob: each click moves focus one step, like Tab or Shift+Tab. */
fun onRotate(delta: Int) {
val sink = focusSink ?: return
val code = if (delta > 0) KeyEvent.KEYCODE_TAB else KEYCODE_SHIFT_TAB
repeat(kotlin.math.abs(delta).coerceAtMost(MAX_ROTARY_STEPS)) {
sink(code, true)
sink(code, false)
}
}
/**
* The voice key: answers the message on screen if there is one, otherwise starts the
* assistant when it's turned on.
*/
private fun voice() = main.post {
if (VoiceReply.onVoiceKey()) return@post
if (CarSettings.experimentalFeaturesEnabled.value && CarSettings.voiceAssistantEnabled.value) {
CarAssistant.start()
} else {
GearslipLog.i("keys: voice pressed, but the assistant is off in settings")
}
}
/** Answers a ringing call; with nothing ringing, opens the phone screen. */
private fun call() = main.post {
val context = appContext ?: return@post
if (CarCalls.state.value is CallUiState.Ringing) CarCalls.answer(context) else screen(ScreenKey.PHONE)
}
private fun endCall() = main.post {
if (CarCalls.state.value is CallUiState.Ringing) CarCalls.decline()
else GearslipLog.i("keys: end call pressed with no ringing call; hanging up needs the dialer")
}
private fun screen(key: ScreenKey) { _screenKeys.tryEmit(key) }
// aap_protobuf's KeyCode.proto: car-only keys past the end of Android's own range.
const val KEYCODE_ROTARY_CONTROLLER = 65536
const val KEYCODE_MEDIA = 65537
const val KEYCODE_NAVIGATION = 65538
const val KEYCODE_TEL = 65540
/** Stands in for Shift+Tab: the sink sends Tab with the shift meta state. */
const val KEYCODE_SHIFT_TAB = -KeyEvent.KEYCODE_TAB
private const val MAX_ROTARY_STEPS = 5
private val FOCUS_KEYS = setOf(
KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_DOWN, KeyEvent.KEYCODE_DPAD_LEFT,
KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER,
)
}
@@ -1,9 +1,8 @@
package app.seb3thehacker.gearslip.car package app.seb3thehacker.gearslip.car
import app.seb3thehacker.gearslip.car.theme.*
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.border
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
@@ -16,18 +15,20 @@ import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Call import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Share
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.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.ImageBitmap
@@ -50,11 +51,14 @@ 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
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Warning
import app.seb3thehacker.gearslip.host.KnownApps import app.seb3thehacker.gearslip.host.KnownApps
import app.seb3thehacker.gearslip.host.CarAppCatalog import app.seb3thehacker.gearslip.host.CarAppCatalog
import app.seb3thehacker.gearslip.host.TemplateApp import app.seb3thehacker.gearslip.host.TemplateApp
import app.seb3thehacker.gearslip.media.MediaApp import app.seb3thehacker.gearslip.media.MediaApp
import app.seb3thehacker.gearslip.media.MediaCatalog import app.seb3thehacker.gearslip.media.MediaCatalog
import app.seb3thehacker.gearslip.notify.MessagingApp
import app.seb3thehacker.gearslip.notify.MessagingCatalog
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -67,8 +71,8 @@ private class Tile(
val verified: Boolean = false, val verified: Boolean = false,
/** Known not to work; drawn with a red X instead of being left off the launcher. */ /** Known not to work; drawn with a red X instead of being left off the launcher. */
val broken: Boolean = false, val broken: Boolean = false,
val pinned: Boolean = false, /** Starts and can be used, but rough enough not to call working; drawn with a yellow mark. */
/** Null for the built-in tiles (Web, Screen sharing, Settings) - nothing to pin them as. */ val partial: Boolean = false,
val onLongClick: (() -> Unit)? = null, val onLongClick: (() -> Unit)? = null,
val onClick: () -> Unit, val onClick: () -> Unit,
) )
@@ -79,18 +83,34 @@ internal class Entry(
val icon: ImageBitmap?, val icon: ImageBitmap?,
val template: TemplateApp? = null, val template: TemplateApp? = null,
val media: MediaApp? = null, val media: MediaApp? = null,
val builtIn: BuiltInApp? = null,
val messaging: MessagingApp? = null,
) { ) {
/** What a pin or a nav-bar shortcut remembers this entry as; null for nothing pinnable. */ /** What a pin or a nav-bar shortcut remembers this entry as; null for nothing pinnable. */
val componentId: String? get() = (template?.component ?: media?.component)?.flattenToString() val componentId: String?
get() = builtIn?.let { BUILT_IN_PREFIX + it.id }
?: messaging?.let { MESSAGING_PREFIX + it.packageName }
?: (template?.component ?: media?.component)?.flattenToString()
val packageName: String?
get() = template?.component?.packageName ?: media?.component?.packageName ?: messaging?.packageName
} }
/** A messaging app has no component of its own to open, so its pin carries the package instead. */
internal const val MESSAGING_PREFIX = "messages:"
/** /**
* The installed car and media apps with their icons. Scanning the package manager and decoding * The installed car and media apps with their icons. Scanning the package manager and decoding
* icons is slow, so [Prefetch] does it ahead of time and again at the start of each car session, * icons is slow, so [Prefetch] does it ahead of time and again at the start of each car session,
* which is when new installs are picked up. * which is when new installs are picked up.
*/ */
internal object LauncherCache { internal object LauncherCache {
@Volatile var entries: List<Entry>? = null /** Observed, not read once: the nav bar is drawn before the car session's rescan finishes,
* and an app installed since the last scan has to show up there when it does. */
private val flow = MutableStateFlow<List<Entry>?>(null)
val state: StateFlow<List<Entry>?> = flow.asStateFlow()
val entries: List<Entry>? get() = flow.value
/** Returns the scan if there is one; [force] rescans. Synchronized so two callers share one scan. */ /** Returns the scan if there is one; [force] rescans. Synchronized so two callers share one scan. */
@Synchronized @Synchronized
@@ -100,12 +120,97 @@ internal object LauncherCache {
context.packageManager.getApplicationIcon(pkg).toBitmap(128, 128).asImageBitmap() context.packageManager.getApplicationIcon(pkg).toBitmap(128, 128).asImageBitmap()
}.getOrNull() }.getOrNull()
val nav = CarAppCatalog.installed(context).map { Entry(it.label, iconOf(it.component.packageName), template = it) } val navApps = CarAppCatalog.installed(context)
val media = MediaCatalog.installed(context).map { Entry(it.label, iconOf(it.component.packageName), media = it) } val mediaApps = MediaCatalog.installed(context)
return (nav + media).sortedBy { it.label.lowercase() }.also { entries = it } // Some apps (Spotify among them) ship both a Car App Library template and a legacy
// MediaBrowserService, which otherwise land as two identically-labeled tiles with no
// way to tell them apart. They're genuinely different screens - browsing versus Now
// Playing - so only the label needs disambiguating, not the tile itself.
val mediaPackages = mediaApps.map { it.component.packageName }.toSet()
val messagingApps = MessagingCatalog.installed(context)
// Google Messages ships its own car app, which won't start under Gearslip. Gearslip's
// own Messages screen does the same job, so a messenger gets only that tile.
val messagingPackages = messagingApps.map { it.packageName }.toSet()
val nav = navApps
.filter { it.component.packageName !in messagingPackages }
// An app whose template screen is rejected but whose player works through Gearslip
// (Spotify) shows only its player tile - a "· Browse" tile beside it would just report
// that the car screen isn't supported.
.filter { !(KnownApps.screenBroken(it.component.packageName) && KnownApps.playerWorks(it.component.packageName)) }
.map {
val label = if (it.component.packageName in mediaPackages) "${it.label} · Browse" else it.label
Entry(label, iconOf(it.component.packageName), template = it)
}
val media = mediaApps.map { Entry(it.label, iconOf(it.component.packageName), media = it) }
// A messenger that is also a car media app keeps only its player tile.
val messaging = messagingApps
.filter { it.packageName !in mediaPackages }
.map { Entry(it.label, iconOf(it.packageName), messaging = it) }
return (nav + media + messaging).sortedBy { it.label.lowercase() }.also { flow.value = it }
} }
} }
/** Every app the launcher knows: Gearslip's own and the installed ones, rescans included. */
@Composable
internal fun rememberAllEntries(): List<Entry> {
val context = LocalContext.current
val installed by LauncherCache.state.collectAsState()
LaunchedEffect(context) {
if (LauncherCache.entries == null) withContext(Dispatchers.IO) { LauncherCache.load(context) }
}
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 }
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
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. */
internal fun findEntry(id: String): Entry? =
BuiltInApps.entries.find { it.componentId == id } ?: LauncherCache.entries?.find { it.componentId == id }
/** Rescans the car and media apps now, replacing the cached list; the launcher keeps showing the old one meanwhile. */ /** Rescans the car and media apps now, replacing the cached list; the launcher keeps showing the old one meanwhile. */
fun warmLauncherApps(context: Context) { LauncherCache.load(context, force = true) } fun warmLauncherApps(context: Context) { LauncherCache.load(context, force = true) }
@@ -115,41 +220,48 @@ fun warmLauncherApps(context: Context) { LauncherCache.load(context, force = tru
* kind of app. * kind of app.
*/ */
internal fun launchEntry(entry: Entry, navigator: CarNavigator, frame: CarEnvironment.Frame) { internal fun launchEntry(entry: Entry, navigator: CarNavigator, frame: CarEnvironment.Frame) {
entry.template?.let { CarServices.connectNav(it, frame); navigator.home() } entry.builtIn?.let { it.open(navigator); return }
entry.template?.let {
if (it.isNavigation) {
CarServices.connectNav(it, frame)
navigator.home()
} else {
CarServices.connectBrowse(it, frame)
navigator.browse(entry.label)
}
}
entry.media?.let { CarServices.openMedia(it); navigator.media() } entry.media?.let { CarServices.openMedia(it); navigator.media() }
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 context = LocalContext.current
val navigator = LocalCarNavigator.current val navigator = LocalCarNavigator.current
val frame by CarEnvironment.frame.collectAsState() val frame by CarEnvironment.frame.collectAsState()
// Painted from the cache at once; scanned only if [Prefetch] has not got there first. // Painted from the cache at once; scanned only if [Prefetch] has not got there first.
val installed by produceState(initialValue = LauncherCache.entries ?: emptyList(), context) { val all = rememberAllEntries()
value = LauncherCache.entries ?: withContext(Dispatchers.IO) { LauncherCache.load(context) } val running = rememberRunningApps()
val showVehicleData = rememberVehicleDataShown()
var showBadgeKey by remember { mutableStateOf(false) }
fun tileOf(entry: Entry): Tile {
// The badges rate an app's own car screens; a messenger shown on Gearslip's own Messages
// screen has none, so it gets no badge.
val pkg = entry.packageName?.takeIf { entry.messaging == null }
val id = entry.componentId
return Tile(
entry.label, entry.icon, entry.builtIn?.glyph,
verified = pkg != null && (KnownApps.works(pkg) || (entry.media != null && KnownApps.playerWorks(pkg))),
broken = pkg != null && (KnownApps.isBroken(pkg) || (entry.template != null && KnownApps.screenBroken(pkg))),
partial = pkg != null && KnownApps.isPartial(pkg),
onLongClick = id?.let { { navigator.showAppMenu(it) } },
) { openApp(entry, id?.let { running[it] }, navigator, frame) }
} }
val pinned by CarSettings.pinnedApps.collectAsState() val appOrder by CarSettings.appOrder.collectAsState()
val tiles = launcherOrder(all, showVehicleData, appOrder).map(::tileOf)
val tiles = listOf(
Tile("Web", glyph = Icons.Filled.Search) { navigator.open("web") },
Tile("Screen sharing", glyph = Icons.Filled.Share) { navigator.open("phone") },
Tile("Phone", glyph = Icons.Filled.Call) { navigator.open("dialer") },
) + installed.map { entry ->
val pkg = entry.template?.component?.packageName ?: entry.media?.component?.packageName
val id = entry.componentId
Tile(
entry.label, entry.icon,
verified = pkg != null && KnownApps.works(pkg),
broken = pkg != null && KnownApps.isBroken(pkg),
pinned = id != null && id in pinned,
onLongClick = id?.let { { CarSettings.togglePin(it) } },
) { launchEntry(entry, navigator, frame) }
} + listOf(
Tile("Settings", glyph = Icons.Filled.Settings) { navigator.settings() },
)
LazyVerticalGrid( LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 108.dp), columns = GridCells.Adaptive(minSize = 108.dp),
@@ -159,27 +271,49 @@ fun CarLauncher() {
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
items(tiles) { AppTile(it) } items(tiles) { AppTile(it) }
if (tiles.any { it.verified }) { if (tiles.any { it.verified || it.broken || it.partial }) {
item(span = { GridItemSpan(maxLineSpan) }) { VerifiedLegend() } // Folded away behind one button: it's reference, read once, not something every
// visit to the launcher needs spelled out under the grid.
item(span = { GridItemSpan(maxLineSpan) }) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
GsButton(onClick = { showBadgeKey = !showBadgeKey }, tone = GsTone.Neutral) {
Icon(Icons.Filled.Info, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text(if (showBadgeKey) "Hide badge key" else "What the badges mean")
}
if (showBadgeKey) {
BadgeLegend(
verified = tiles.any { it.verified },
partial = tiles.any { it.partial },
broken = tiles.any { it.broken },
)
}
}
}
} }
} }
} }
/** Says what the check mark on a tile means, once, under the grid. */ /** Says what each badge on a tile means, once, under the grid - only the ones actually in use. */
@Composable @Composable
private fun VerifiedLegend() { private fun BadgeLegend(verified: Boolean, partial: Boolean, broken: Boolean) {
Row( Column(
Modifier.fillMaxWidth().padding(top = 4.dp), Modifier.fillMaxWidth().padding(top = 4.dp),
horizontalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally,
verticalAlignment = Alignment.CenterVertically, verticalArrangement = Arrangement.spacedBy(4.dp),
) { ) {
VerifiedBadge(16.dp) if (verified) LegendRow(size = 16.dp, badge = { VerifiedBadge(it) }, text = "Tested and working with Gearslip")
if (partial) LegendRow(size = 16.dp, badge = { PartialBadge(it) }, text = "Runs, but not cleanly, with Gearslip")
if (broken) LegendRow(size = 16.dp, badge = { BrokenBadge(it) }, text = "Doesn't work with Gearslip yet")
}
}
@Composable
private fun LegendRow(size: androidx.compose.ui.unit.Dp, badge: @Composable (androidx.compose.ui.unit.Dp) -> Unit, text: String) {
Row(horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) {
badge(size)
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
Text( Text(text, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
"Tested and working with Gearslip",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} }
} }
@@ -205,21 +339,29 @@ private fun BrokenBadge(size: androidx.compose.ui.unit.Dp, modifier: Modifier =
} }
} }
/** A yellow disc with a warning mark: neither the check nor the X, since this app runs but isn't clean about it. */
@Composable
private fun PartialBadge(size: androidx.compose.ui.unit.Dp, modifier: Modifier = Modifier) {
Box(
modifier.size(size).background(Color(0xFFF9A825), CircleShape),
contentAlignment = Alignment.Center,
) {
Icon(Icons.Filled.Warning, contentDescription = "Works, but not cleanly, with Gearslip", tint = Color.White, modifier = Modifier.size(size * 0.6f))
}
}
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
private fun AppTile(tile: Tile) { private fun AppTile(tile: Tile) {
val shape = MaterialTheme.shapes.extraLarge val shape = MaterialTheme.shapes.extraLarge
Surface( // A long press opens the app's menu (pin, close) - the same gesture everywhere apps are
modifier = Modifier // shown, here and on the nav bar.
.height(120.dp) GsIconBox(
.let { if (tile.pinned) it.border(2.dp, MaterialTheme.colorScheme.primary, shape) else it } onClick = tile.onClick,
// combinedClickable, not Surface's own onClick, so a long press can mean something onLongClick = tile.onLongClick,
// different from a tap - pinning is deliberately the same gesture everywhere apps modifier = Modifier.height(120.dp),
// are shown (here and, once pinned, the shortcut itself on the nav bar). colors = GsColors(MaterialTheme.colorScheme.surfaceVariant, MaterialTheme.colorScheme.onSurfaceVariant),
.combinedClickable(onClick = tile.onClick, onLongClick = tile.onLongClick),
shape = shape, shape = shape,
color = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
) { ) {
Box(Modifier.fillMaxSize()) { Box(Modifier.fillMaxSize()) {
Column( Column(
@@ -247,6 +389,9 @@ private fun AppTile(tile: Tile) {
if (tile.broken) { if (tile.broken) {
BrokenBadge(24.dp, Modifier.align(Alignment.TopEnd).padding(8.dp)) BrokenBadge(24.dp, Modifier.align(Alignment.TopEnd).padding(8.dp))
} }
if (tile.partial) {
PartialBadge(24.dp, Modifier.align(Alignment.TopEnd).padding(8.dp))
}
} }
} }
} }
@@ -17,14 +17,29 @@ sealed interface CarScreen {
/** Opened from the weather chip or the dashboard: now, the next hours and the week. */ /** Opened from the weather chip or the dashboard: now, the next hours and the week. */
data object Weather : CarScreen data object Weather : CarScreen
/** Everything the head unit's sensor channel has advertised and sent, raw. */
data object VehicleData : CarScreen
/** The current media app, full size: browse, lyrics and controls. */ /** The current media app, full size: browse, lyrics and controls. */
data object Media : CarScreen data object Media : CarScreen
/**
* A templated app that isn't a navigation app - Spotify's own Car App Library service, among
* others - opened on its own screen rather than the Home map slot, which only a genuine
* navigation app can claim.
*/
data object Browse : CarScreen
/** A messaging app's recent conversations, read from the notifications it posts. */
data class Messages(val packageName: String) : CarScreen
/** [replyTo] is a notification key to open straight into its reply pane. */ /** [replyTo] is a notification key to open straight into its reply pane. */
data class Notifications(val replyTo: String? = null) : CarScreen data class Notifications(val replyTo: String? = null) : CarScreen
data class App(val id: String) : CarScreen data class App(val id: String) : CarScreen
} }
enum class SidePanel { NONE, CONTROLS, LYRICS }
/** One step on the breadcrumb trail: the screen it shows and the word for it in the trail. */ /** One step on the breadcrumb trail: the screen it shows and the word for it in the trail. */
data class Crumb(val screen: CarScreen, val label: String) data class Crumb(val screen: CarScreen, val label: String)
@@ -66,18 +81,43 @@ class CarNavigator(startId: String) {
} }
/** /**
* The nav bar's now-playing pill is the player everywhere; this is only whether the home * What the column beside the map on Home is showing, if anything. The player lives in one
* screen is additionally showing lyrics for it in the column beside the map. Toggled by the * place at a time: with [SidePanel.CONTROLS] it moves into the column and the nav bar's pill
* pill's own lyrics button, so it means the same thing wherever the pill is visible, even * steps aside; with [SidePanel.LYRICS] the column is all lyrics and the pill comes back.
* though only the home screen actually has the room to show it. * Every other screen has no room for the column, so there the pill stays in the nav bar.
*/ */
var lyricsOpen by mutableStateOf(false) var sidePanel by mutableStateOf(SidePanel.NONE)
private set private set
fun toggleLyrics() { lyricsOpen = !lyricsOpen } /**
* The pill's dock button: moves the player beside the map, going Home first if need be. With
* lyrics already there it swaps them for the controls; with the controls there it closes.
*/
fun togglePlayerDock() {
if (current != CarScreen.Home) {
sidePanel = SidePanel.CONTROLS
home()
return
}
sidePanel = if (sidePanel == SidePanel.CONTROLS) SidePanel.NONE else SidePanel.CONTROLS
}
fun showSideLyrics() { sidePanel = SidePanel.LYRICS }
fun closeSidePanel() { sidePanel = SidePanel.NONE }
/** Component id of the app whose long-press menu (pin, close) is open, or null. */
var appMenu by mutableStateOf<String?>(null)
private set
fun showAppMenu(componentId: String) { appMenu = componentId }
fun dismissAppMenu() { appMenu = null }
fun media() { push(CarScreen.Media, "Media") } fun media() { push(CarScreen.Media, "Media") }
fun browse(label: String) { push(CarScreen.Browse, label) }
fun apps() { push(CarScreen.Apps, "Apps") } fun apps() { push(CarScreen.Apps, "Apps") }
fun settings() { push(CarScreen.Settings, "Settings") } fun settings() { push(CarScreen.Settings, "Settings") }
@@ -86,7 +126,14 @@ class CarNavigator(startId: String) {
fun weather() { push(CarScreen.Weather, "Weather") } fun weather() { push(CarScreen.Weather, "Weather") }
fun notifications(replyTo: String? = null) { push(CarScreen.Notifications(replyTo), "Notifications") } fun vehicleData() { push(CarScreen.VehicleData, "Vehicle data") }
fun messages(packageName: String, label: String) { push(CarScreen.Messages(packageName), label) }
/** The list lives on the dashboard; only a reply gets a screen of its own. */
fun notifications(replyTo: String? = null) {
if (replyTo == null) dashboard() else push(CarScreen.Notifications(replyTo), "Reply")
}
fun open(id: String) { push(CarScreen.App(id), CarApps.find(id)?.label ?: id) } fun open(id: String) { push(CarScreen.App(id), CarApps.find(id)?.label ?: id) }
@@ -0,0 +1,95 @@
package app.seb3thehacker.gearslip.car
import app.seb3thehacker.gearslip.Protobuf
import app.seb3thehacker.gearslip.Wire
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* Everything the head unit's sensor channel has offered and sent, for the "Vehicle data" screen.
*
* [ServiceDiscovery.findSensorService] tells us which numeric sensor types this head unit is
* willing to stream; [SensorType] names the ones publicly documented by other AA reverse-engineering
* projects (aasdk, openauto). That mapping is carried over from other projects, not verified
* against this exact unit, so it's a label, not a guarantee - the raw field dump next to it is
* the ground truth. Once [onEvent] starts decoding real traffic from the 2018 Uconnect, correct
* any type that turns out to be mislabeled.
*/
object CarSensors {
data class Reading(val typeId: Int, val typeName: String, val raw: String, val updatedAtMs: Long)
private val readingsFlow = MutableStateFlow<List<Reading>>(emptyList())
val readings: StateFlow<List<Reading>> = readingsFlow
/** Seeds one row per sensor type the head unit advertised, so the screen shows what's on
* offer even before the first event for it arrives. */
fun onDiscovered(types: List<Int>) {
readingsFlow.value = types.map { id ->
Reading(id, SensorType.name(id), "(waiting for data)", 0L)
}
}
/**
* One SensorEventIndication. Every reverse-engineered version of this message multiplexes
* every sensor type into one shared set of message-level field numbers (field N = "the data
* for sensor type N", roughly) rather than tagging events with the type id from
* SensorType.type - so field number is read here as a best-effort type id, same as the
* other services in this file.
*/
fun onEvent(body: ByteArray) {
val now = System.currentTimeMillis()
val fields = Wire.fields(body)
val byType = fields.groupBy { it.number }
val updated = byType.mapValues { (number, group) ->
Reading(
number,
SensorType.name(number),
group.joinToString("; ") { field ->
field.bytes?.let { Protobuf.describe(it, indent = "").trim().replace("\n", ", ") }
?: "varint=${field.varint}"
},
now,
)
}
val existing = readingsFlow.value.associateBy { it.typeId }
readingsFlow.value = (existing + updated).values.sortedBy { it.typeId }
}
fun clear() {
readingsFlow.value = emptyList()
}
}
/**
* Sensor type ids as named across public Android Auto reverse-engineering references. Treated as
* a label, not a fact - see [CarSensors] doc.
*/
object SensorType {
private val NAMES = mapOf(
1 to "Location (GPS)",
2 to "Compass",
3 to "Speed",
4 to "RPM",
5 to "Odometer",
6 to "Fuel",
7 to "Parking brake",
8 to "Gear",
9 to "Diagnostics",
10 to "Night mode",
11 to "Environment",
12 to "HVAC",
13 to "Driving status",
14 to "Dead reckoned location",
15 to "Passenger",
16 to "Door",
17 to "Light",
18 to "Tire pressure",
19 to "Accelerometer",
20 to "Gyroscope",
21 to "Gravity",
22 to "Wheel tick / distance",
)
fun name(id: Int): String = NAMES[id]?.let { "$it (#$id)" } ?: "Sensor #$id"
}
@@ -2,13 +2,17 @@ package app.seb3thehacker.gearslip.car
import android.content.ComponentName import android.content.ComponentName
import android.content.Context import android.content.Context
import android.media.session.MediaSessionManager
import android.media.session.PlaybackState
import app.seb3thehacker.gearslip.audio.CarAudio import app.seb3thehacker.gearslip.audio.CarAudio
import app.seb3thehacker.gearslip.host.CarAppCatalog import app.seb3thehacker.gearslip.host.CarAppCatalog
import app.seb3thehacker.gearslip.host.CarAppConnection import app.seb3thehacker.gearslip.host.CarAppConnection
import app.seb3thehacker.gearslip.host.KnownApps
import app.seb3thehacker.gearslip.host.TemplateApp import app.seb3thehacker.gearslip.host.TemplateApp
import app.seb3thehacker.gearslip.media.CarMedia import app.seb3thehacker.gearslip.media.CarMedia
import app.seb3thehacker.gearslip.media.MediaApp import app.seb3thehacker.gearslip.media.MediaApp
import app.seb3thehacker.gearslip.media.MediaCatalog import app.seb3thehacker.gearslip.media.MediaCatalog
import app.seb3thehacker.gearslip.notify.GearslipNotificationListener
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -29,9 +33,23 @@ object CarServices {
val nav: CarAppConnection by lazy { CarAppConnection(requireNotNull(context)) } val nav: CarAppConnection by lazy { CarAppConnection(requireNotNull(context)) }
val media: CarMedia by lazy { CarMedia(requireNotNull(context)) } val media: CarMedia by lazy { CarMedia(requireNotNull(context)) }
/**
* A second, independent connection for templated apps that aren't navigation apps - Spotify's
* own Car App Library service is the first of these. Kept apart from [nav] so opening one
* can never bump the map off Home: this one gets its own screen ([CarScreen.Browse]) instead.
*/
val browse: CarAppConnection by lazy { CarAppConnection(requireNotNull(context)) }
private val _mediaApp = MutableStateFlow<MediaApp?>(null) private val _mediaApp = MutableStateFlow<MediaApp?>(null)
val mediaApp: StateFlow<MediaApp?> = _mediaApp.asStateFlow() val mediaApp: StateFlow<MediaApp?> = _mediaApp.asStateFlow()
/**
* Whether the phone has any navigation app installed at all. True until [autostart] has
* actually checked, so the home screen never flashes "none found" before it knows.
*/
private val _hasNavApps = MutableStateFlow(true)
val hasNavApps: StateFlow<Boolean> = _hasNavApps.asStateFlow()
/** Set when the driver stops the map by hand, so going Home doesn't immediately restart it. */ /** Set when the driver stops the map by hand, so going Home doesn't immediately restart it. */
private var navStopped = false private var navStopped = false
private var autoplayPending = false private var autoplayPending = false
@@ -40,14 +58,29 @@ object CarServices {
context = appContext.applicationContext context = appContext.applicationContext
app.seb3thehacker.gearslip.notify.CarMotion.start(appContext) app.seb3thehacker.gearslip.notify.CarMotion.start(appContext)
app.seb3thehacker.gearslip.call.CarCalls.start(appContext) app.seb3thehacker.gearslip.call.CarCalls.start(appContext)
CarAssistant.init(appContext)
CarKeys.init(appContext)
VoiceReply.init(appContext)
// Loaded now, while nothing's waiting on it, so the first reply isn't slower than the rest.
app.seb3thehacker.gearslip.speech.SpeechEngine.warm(appContext)
} }
// --- map ----------------------------------------------------------------------------- // --- map -----------------------------------------------------------------------------
fun connectNav(app: TemplateApp, frame: CarEnvironment.Frame) { /**
* [destination] is a search query to start navigating to right away - "navigate to X with Y"
* from the voice assistant - left null for a plain app launch. An app that's already the one
* running gets the destination sent to its live session instead of being torn down and
* rebuilt, same as [CarAppConnection.navigateTo] documents.
*/
fun connectNav(app: TemplateApp, frame: CarEnvironment.Frame, destination: String? = null) {
navStopped = false navStopped = false
CarSettings.setLastNav(app.component.flattenToString()) CarSettings.setLastNav(app.component.flattenToString())
nav.connect(app, frame.width, frame.height, frame.densityDpi) if (destination != null && nav.connectedComponent == app.component) {
nav.navigateTo(destination)
} else {
nav.connect(app, frame.width, frame.height, frame.densityDpi, destination)
}
} }
fun stopNav() { fun stopNav() {
@@ -61,6 +94,17 @@ object CarServices {
autostart(frame) autostart(frame)
} }
// --- browse (non-navigation templated apps) -------------------------------------------
/** Opens a templated app that isn't navigation, on its own screen - never on Home. */
fun connectBrowse(app: TemplateApp, frame: CarEnvironment.Frame) {
browse.connect(app, frame.width, frame.height, frame.densityDpi)
}
fun stopBrowse() {
browse.disconnect()
}
// --- media --------------------------------------------------------------------------- // --- media ---------------------------------------------------------------------------
fun openMedia(app: MediaApp, autoplay: Boolean = false) { fun openMedia(app: MediaApp, autoplay: Boolean = false) {
@@ -77,6 +121,14 @@ object CarServices {
if (CarSettings.pipeAudio.value) CarAudio.request() if (CarSettings.pipeAudio.value) CarAudio.request()
} }
/** Closes the media app from the car: stops what it's playing and drops the connection. */
fun closeMedia() {
if (media.now.value.playing) media.togglePlay()
media.disconnect()
_mediaApp.value = null
autoplayPending = false
}
/** Called once the media app is connected: starts playback if that was asked for. */ /** Called once the media app is connected: starts playback if that was asked for. */
fun autoplayIfDue() { fun autoplayIfDue() {
if (!autoplayPending) return if (!autoplayPending) return
@@ -91,23 +143,56 @@ object CarServices {
val ctx = context ?: return val ctx = context ?: return
if (!navStopped && nav.status.value.phase == CarAppConnection.Phase.IDLE) { if (!navStopped && nav.status.value.phase == CarAppConnection.Phase.IDLE) {
val navApps = withContext(Dispatchers.IO) { CarAppCatalog.installed(ctx) }
.filter { it.isNavigation }
_hasNavApps.value = navApps.isNotEmpty()
// Never auto-pick one already known to fail outright - a driver who plugs in should
// land on a map, not on the one tile marked with a red X.
val candidates = navApps.filterNot { KnownApps.isBroken(it.component.packageName) }
val last = CarSettings.lastNav.value val last = CarSettings.lastNav.value
val app = last?.let { id -> // The one used last, or - first run, nothing on record yet - whichever comes first:
withContext(Dispatchers.IO) { CarAppCatalog.installed(ctx) } // a driver who plugs in for the first time shouldn't have to go pick one by hand
.firstOrNull { it.component == ComponentName.unflattenFromString(id) } // before the map shows up at all.
} val lastComponent = last?.let(ComponentName::unflattenFromString)
val app = candidates.firstOrNull { it.component == lastComponent } ?: candidates.firstOrNull()
if (app != null) connectNav(app, frame) if (app != null) connectNav(app, frame)
} }
if (_mediaApp.value == null) { if (_mediaApp.value == null) {
val catalog = withContext(Dispatchers.IO) { MediaCatalog.installed(ctx) }
// Something already playing on the phone wins over whatever was last used in the car -
// a driver who started a song before plugging in shouldn't have to go open its tile by
// hand just to see what's already going.
val playing = withContext(Dispatchers.IO) { currentlyPlaying(ctx, catalog) }
if (playing != null) {
openMedia(playing)
} else {
val last = CarSettings.lastMedia.value val last = CarSettings.lastMedia.value
val app = last?.let { id -> val app = last?.let { id -> catalog.firstOrNull { it.component == ComponentName.unflattenFromString(id) } }
withContext(Dispatchers.IO) { MediaCatalog.installed(ctx) }
.firstOrNull { it.component == ComponentName.unflattenFromString(id) }
}
if (app != null) openMedia(app, autoplay = CarSettings.autoplay.value) if (app != null) openMedia(app, autoplay = CarSettings.autoplay.value)
} }
} }
}
/**
* Whichever known car media app has an actively playing session right now, system-wide - not
* just the one Gearslip itself last connected to. Reading another app's session needs
* notification access, the same permission [app.seb3thehacker.gearslip.media.CarMedia]
* already asks for to control apps that won't share their library.
*/
private fun currentlyPlaying(ctx: Context, catalog: List<MediaApp>): MediaApp? {
val manager = ctx.getSystemService(MediaSessionManager::class.java)
val listener = ComponentName(ctx, GearslipNotificationListener::class.java)
val sessions = try {
manager.getActiveSessions(listener)
} catch (e: SecurityException) {
return null
}
val playingPackages = sessions
.filter { it.playbackState?.state == PlaybackState.STATE_PLAYING }
.map { it.packageName }
return playingPackages.firstNotNullOfOrNull { pkg -> catalog.firstOrNull { it.component.packageName == pkg } }
}
/** The car went away: nothing should keep running for a screen that no longer exists. */ /** The car went away: nothing should keep running for a screen that no longer exists. */
fun shutdown() { fun shutdown() {
@@ -115,6 +200,8 @@ object CarServices {
app.seb3thehacker.gearslip.call.CarCalls.stop() app.seb3thehacker.gearslip.call.CarCalls.stop()
runCatching { nav.disconnect() } runCatching { nav.disconnect() }
runCatching { media.disconnect() } runCatching { media.disconnect() }
runCatching { browse.disconnect() }
CarAssistant.cancel()
CarAudio.release() CarAudio.release()
_mediaApp.value = null _mediaApp.value = null
navStopped = false navStopped = false
@@ -2,11 +2,18 @@ package app.seb3thehacker.gearslip.car
import android.content.Context import android.content.Context
import app.seb3thehacker.gearslip.AppSettings import app.seb3thehacker.gearslip.AppSettings
import app.seb3thehacker.gearslip.car.theme.GsTheme
import app.seb3thehacker.gearslip.car.theme.GsThemes
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
enum class NightMode { AUTO, DAY, NIGHT } enum class NightMode { AUTO, DAY, NIGHT }
/** Distances and temperatures. [AUTO] goes by the phone's region. */
enum class Units { AUTO, IMPERIAL, METRIC }
/** Where a new message shows: [NAV_BAR] turns the clock button into it, [CARD] floats a card top right. */
/** The car UI's own colours. Separate from [NightMode], which is about the light outside (the map). */ /** The car UI's own colours. Separate from [NightMode], which is about the light outside (the map). */
enum class AppTheme { PHONE, LIGHT, DARK } enum class AppTheme { PHONE, LIGHT, DARK }
@@ -23,8 +30,18 @@ object CarSettings {
private const val KEY_LAST_NAV = "car_last_nav" private const val KEY_LAST_NAV = "car_last_nav"
private const val KEY_LAST_MEDIA = "car_last_media" private const val KEY_LAST_MEDIA = "car_last_media"
private const val KEY_AUTOPLAY = "car_autoplay" private const val KEY_AUTOPLAY = "car_autoplay"
private const val KEY_PLAYER_TITLE = "car_player_title"
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_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_VOICE_ASSISTANT = "car_voice_assistant"
private const val KEY_CAR_SENSORS = "car_sensors_enabled"
private const val KEY_SEEN_SAFETY_WARNING = "car_seen_safety_warning"
private const val KEY_UNITS = "car_units"
private const val KEY_WHATS_NEW = "car_whats_new_after_updates"
private const val KEY_WHATS_NEW_SEEN = "car_whats_new_seen_code"
/** How many shortcuts the nav bar makes room for; a driver reaching for one needs it to /** How many shortcuts the nav bar makes room for; a driver reaching for one needs it to
* still be on the bar, not off the edge of a long list. */ * still be on the bar, not off the edge of a long list. */
@@ -42,13 +59,72 @@ object CarSettings {
private val lastNavFlow = MutableStateFlow<String?>(null) private val lastNavFlow = MutableStateFlow<String?>(null)
private val lastMediaFlow = MutableStateFlow<String?>(null) private val lastMediaFlow = MutableStateFlow<String?>(null)
private val autoplayFlow = MutableStateFlow(false) private val autoplayFlow = MutableStateFlow(false)
private val playerTitleFlow = MutableStateFlow(false)
private val themeFlow = MutableStateFlow(AppTheme.PHONE) private val themeFlow = MutableStateFlow(AppTheme.PHONE)
val appTheme: StateFlow<AppTheme> = themeFlow val appTheme: StateFlow<AppTheme> = themeFlow
private val uiThemeFlow = MutableStateFlow(GsThemes.default.id)
/** Id of the [GsTheme] controls are drawn in; an unknown id falls back to the default. */
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 voiceAssistantFlow = MutableStateFlow(false)
private val carSensorsFlow = MutableStateFlow(false)
private val seenSafetyWarningFlow = MutableStateFlow(false)
private val unitsFlow = MutableStateFlow(Units.AUTO)
val units: StateFlow<Units> = unitsFlow
private val whatsNewFlow = MutableStateFlow(true)
private val whatsNewSeenFlow = MutableStateFlow(0)
private val whatsNewOpenFlow = MutableStateFlow(false)
/** Whether the car screen shows what's new once after each update. */
val whatsNewAfterUpdates: StateFlow<Boolean> = whatsNewFlow
/** Version code whose notes the car screen last showed. */
val whatsNewSeen: StateFlow<Int> = whatsNewSeenFlow
/** Opened by hand from Settings, whatever the two above say. Not saved. */
val whatsNewOpen: StateFlow<Boolean> = whatsNewOpenFlow
fun setWhatsNewAfterUpdates(on: Boolean) {
whatsNewFlow.value = on
AppSettings.putString(app, KEY_WHATS_NEW, on.toString())
}
fun openWhatsNew() { whatsNewOpenFlow.value = true }
/** Closes the notes and marks [versionCode]'s as read. */
fun closeWhatsNew(versionCode: Int) {
whatsNewOpenFlow.value = false
whatsNewSeenFlow.value = versionCode
AppSettings.putString(app, KEY_WHATS_NEW_SEEN, versionCode.toString())
}
/** Whether to show miles, feet and °F; [Units.AUTO] follows the countries that still use them. */
fun imperial(): Boolean = when (unitsFlow.value) {
Units.IMPERIAL -> true
Units.METRIC -> false
Units.AUTO -> java.util.Locale.getDefault().country in setOf("US", "LR", "MM")
}
fun setUnits(units: Units) {
unitsFlow.value = units
AppSettings.putString(app, KEY_UNITS, units.name)
Weather.refresh(app, force = true)
}
/** 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
@@ -56,6 +132,12 @@ object CarSettings {
/** Whether the media app starts playing by itself when the car connects. */ /** Whether the media app starts playing by itself when the car connects. */
val autoplay: StateFlow<Boolean> = autoplayFlow val autoplay: StateFlow<Boolean> = autoplayFlow
/**
* The nav bar's player keeps the song title up, with play/pause and the dock button, instead of
* settling to the skip buttons once a new song has announced itself.
*/
val playerTitle: StateFlow<Boolean> = playerTitleFlow
val scale: StateFlow<Float> = scaleFlow val scale: StateFlow<Float> = scaleFlow
val openOnConnect: StateFlow<String> = openFlow val openOnConnect: StateFlow<String> = openFlow
val nightMode: StateFlow<NightMode> = nightFlow val nightMode: StateFlow<NightMode> = nightFlow
@@ -68,6 +150,40 @@ object CarSettings {
*/ */
val pipeAudio: StateFlow<Boolean> = pipeAudioFlow val pipeAudio: StateFlow<Boolean> = pipeAudioFlow
/**
* Off by default: a driver has to opt into experimental features as a whole before any of
* them - the voice assistant included - show up to turn on individually.
*/
val experimentalFeaturesEnabled: StateFlow<Boolean> = experimentalFeaturesFlow
/**
* The voice assistant is experimental and off by default; a driver has to turn it on
* knowingly, from the settings screen, after reading the warning shown there. This flag
* alone does not run the assistant - [experimentalFeaturesEnabled] must also be on.
*/
val voiceAssistantEnabled: StateFlow<Boolean> = voiceAssistantFlow
/**
* Vehicle data is experimental and off by default: the launcher's tile for it, and the
* voice assistant's "vehicle data" command, only show up once this and
* [experimentalFeaturesEnabled] are both on. See [app.seb3thehacker.gearslip.car.CarSensors]
* for why the raw sensor feed it shows is worth gating - the field labels are unverified.
*/
val carSensorsEnabled: StateFlow<Boolean> = carSensorsFlow
/**
* 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 -
* a driver who only ever plugs in and never opens the phone app should still see this once,
* on the screen they are actually about to use while driving.
*/
val hasSeenSafetyWarning: StateFlow<Boolean> = seenSafetyWarningFlow
fun markSafetyWarningSeen() {
seenSafetyWarningFlow.value = true
AppSettings.putString(app, KEY_SEEN_SAFETY_WARNING, "true")
}
fun init(context: Context) { fun init(context: Context) {
app = context.applicationContext app = context.applicationContext
scaleFlow.value = AppSettings.getString(app, KEY_SCALE, "1.0").toFloatOrNull() ?: 1f scaleFlow.value = AppSettings.getString(app, KEY_SCALE, "1.0").toFloatOrNull() ?: 1f
@@ -77,13 +193,26 @@ object CarSettings {
themeFlow.value = runCatching { themeFlow.value = runCatching {
AppTheme.valueOf(AppSettings.getString(app, KEY_THEME, AppTheme.PHONE.name)) AppTheme.valueOf(AppSettings.getString(app, KEY_THEME, AppTheme.PHONE.name))
}.getOrDefault(AppTheme.PHONE) }.getOrDefault(AppTheme.PHONE)
uiThemeFlow.value = AppSettings.getString(app, KEY_UI_THEME, GsThemes.default.id)
autoplayFlow.value = AppSettings.getString(app, KEY_AUTOPLAY, "false") == "true" autoplayFlow.value = AppSettings.getString(app, KEY_AUTOPLAY, "false") == "true"
playerTitleFlow.value = AppSettings.getString(app, KEY_PLAYER_TITLE, "false") == "true"
pipeAudioFlow.value = AppSettings.getString(app, KEY_PIPE_AUDIO, "true") == "true" pipeAudioFlow.value = AppSettings.getString(app, KEY_PIPE_AUDIO, "true") == "true"
nightFlow.value = runCatching { nightFlow.value = runCatching {
NightMode.valueOf(AppSettings.getString(app, KEY_NIGHT, NightMode.AUTO.name)) NightMode.valueOf(AppSettings.getString(app, KEY_NIGHT, NightMode.AUTO.name))
}.getOrDefault(NightMode.AUTO) }.getOrDefault(NightMode.AUTO)
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"
voiceAssistantFlow.value = AppSettings.getString(app, KEY_VOICE_ASSISTANT, "false") == "true"
carSensorsFlow.value = AppSettings.getString(app, KEY_CAR_SENSORS, "false") == "true"
seenSafetyWarningFlow.value = AppSettings.getString(app, KEY_SEEN_SAFETY_WARNING, "false") == "true"
unitsFlow.value = runCatching {
Units.valueOf(AppSettings.getString(app, KEY_UNITS, Units.AUTO.name))
}.getOrDefault(Units.AUTO)
whatsNewFlow.value = AppSettings.getString(app, KEY_WHATS_NEW, "true") == "true"
whatsNewSeenFlow.value = AppSettings.getString(app, KEY_WHATS_NEW_SEEN, "0").toIntOrNull() ?: 0
} }
fun setScale(value: Float) { fun setScale(value: Float) {
@@ -111,6 +240,16 @@ object CarSettings {
AppSettings.putString(app, KEY_THEME, theme.name) AppSettings.putString(app, KEY_THEME, theme.name)
} }
fun setUiTheme(id: String) {
uiThemeFlow.value = id
AppSettings.putString(app, KEY_UI_THEME, id)
}
fun setPlayerTitle(on: Boolean) {
playerTitleFlow.value = on
AppSettings.putString(app, KEY_PLAYER_TITLE, on.toString())
}
fun setAutoplay(on: Boolean) { fun setAutoplay(on: Boolean) {
autoplayFlow.value = on autoplayFlow.value = on
AppSettings.putString(app, KEY_AUTOPLAY, on.toString()) AppSettings.putString(app, KEY_AUTOPLAY, on.toString())
@@ -121,6 +260,21 @@ object CarSettings {
AppSettings.putString(app, KEY_PIPE_AUDIO, on.toString()) AppSettings.putString(app, KEY_PIPE_AUDIO, on.toString())
} }
fun setExperimentalFeaturesEnabled(on: Boolean) {
experimentalFeaturesFlow.value = on
AppSettings.putString(app, KEY_EXPERIMENTAL_FEATURES, on.toString())
}
fun setVoiceAssistantEnabled(on: Boolean) {
voiceAssistantFlow.value = on
AppSettings.putString(app, KEY_VOICE_ASSISTANT, on.toString())
}
fun setCarSensorsEnabled(on: Boolean) {
carSensorsFlow.value = on
AppSettings.putString(app, KEY_CAR_SENSORS, 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)
@@ -139,4 +293,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(","))
}
} }
@@ -1,5 +1,7 @@
package app.seb3thehacker.gearslip.car package app.seb3thehacker.gearslip.car
import app.seb3thehacker.gearslip.car.theme.*
import android.os.Build import android.os.Build
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -9,11 +11,13 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@@ -28,6 +32,9 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import app.seb3thehacker.gearslip.car.theme.GsColors
import app.seb3thehacker.gearslip.car.theme.GsIconBox
import app.seb3thehacker.gearslip.car.theme.GsThemes
import app.seb3thehacker.gearslip.AppSettings import app.seb3thehacker.gearslip.AppSettings
import app.seb3thehacker.gearslip.ui.CertSummary import app.seb3thehacker.gearslip.ui.CertSummary
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -43,9 +50,16 @@ fun CarSettingsScreen() {
val scale by CarSettings.scale.collectAsState() val scale by CarSettings.scale.collectAsState()
val open by CarSettings.openOnConnect.collectAsState() val open by CarSettings.openOnConnect.collectAsState()
val night by CarSettings.nightMode.collectAsState() val night by CarSettings.nightMode.collectAsState()
val units by CarSettings.units.collectAsState()
val whatsNew by CarSettings.whatsNewAfterUpdates.collectAsState()
val pipeAudio by CarSettings.pipeAudio.collectAsState() val pipeAudio by CarSettings.pipeAudio.collectAsState()
val autoplay by CarSettings.autoplay.collectAsState() val autoplay by CarSettings.autoplay.collectAsState()
val playerTitle by CarSettings.playerTitle.collectAsState()
val theme by CarSettings.appTheme.collectAsState() val theme by CarSettings.appTheme.collectAsState()
val uiTheme by CarSettings.uiTheme.collectAsState()
val experimentalFeatures by CarSettings.experimentalFeaturesEnabled.collectAsState()
val voiceAssistant by CarSettings.voiceAssistantEnabled.collectAsState()
val carSensors by CarSettings.carSensorsEnabled.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
@@ -80,12 +94,30 @@ fun CarSettingsScreen() {
theme, theme,
) { CarSettings.setAppTheme(it) } ) { CarSettings.setAppTheme(it) }
ChoiceRow(
"Theme",
GsThemes.builtIn.map { it.id to it.name },
uiTheme,
) { CarSettings.setUiTheme(it) }
ChoiceRow( ChoiceRow(
"Map day/night", "Map day/night",
listOf(NightMode.AUTO to "Auto", NightMode.DAY to "Day", NightMode.NIGHT to "Night"), listOf(NightMode.AUTO to "Auto", NightMode.DAY to "Day", NightMode.NIGHT to "Night"),
night, night,
) { CarSettings.setNightMode(it) } ) { CarSettings.setNightMode(it) }
ChoiceRow(
"Units",
listOf(Units.AUTO to "Auto", Units.IMPERIAL to "Imperial (mi, ft, °F)", Units.METRIC to "Metric (km, m, °C)"),
units,
) { CarSettings.setUnits(it) }
ChoiceRow(
"Player in the nav bar",
listOf(false to "Skip buttons", true to "Song title"),
playerTitle,
) { CarSettings.setPlayerTitle(it) }
ChoiceRow( ChoiceRow(
"Autoplay on connect", "Autoplay on connect",
listOf(false to "Off", true to "On"), listOf(false to "Off", true to "On"),
@@ -110,6 +142,15 @@ fun CarSettingsScreen() {
ScreenFit(vehicle) ScreenFit(vehicle)
ChoiceRow(
"Experimental features",
listOf(false to "Hide", true to "Show"),
experimentalFeatures,
) { CarSettings.setExperimentalFeaturesEnabled(it) }
if (experimentalFeatures) {
ExperimentalFeaturesList(voiceAssistant, carSensors)
}
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Connection", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) Text("Connection", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
Info("Phone", "${Build.MANUFACTURER} ${Build.MODEL}") Info("Phone", "${Build.MANUFACTURER} ${Build.MODEL}")
@@ -117,6 +158,20 @@ fun CarSettingsScreen() {
Info("Video", display.ifEmpty { "-" }) Info("Video", display.ifEmpty { "-" })
Info("Certificate", cert?.headline ?: "Checking…") Info("Certificate", cert?.headline ?: "Checking…")
} }
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("About", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
Info("Version", app.seb3thehacker.gearslip.BuildConfig.VERSION_NAME)
Info("Build", app.seb3thehacker.gearslip.BuildConfig.VERSION_CODE.toString())
ExperimentalFeatureRow(
"What's new after updates",
"Show the list of changes once, the first time the car connects after an update.",
whatsNew,
) { CarSettings.setWhatsNewAfterUpdates(it) }
GsButton(onClick = CarSettings::openWhatsNew, tone = GsTone.Tonal, modifier = Modifier.height(56.dp)) {
Text("What's new", style = MaterialTheme.typography.titleMedium)
}
}
} }
} }
@@ -127,22 +182,87 @@ private fun <T> ChoiceRow(title: String, options: List<Pair<T, String>>, selecte
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
options.forEach { (value, label) -> options.forEach { (value, label) ->
val on = value == selected val on = value == selected
Surface( val scheme = MaterialTheme.colorScheme
GsIconBox(
onClick = { onSelect(value) }, onClick = { onSelect(value) },
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.height(64.dp), .height(64.dp),
shape = MaterialTheme.shapes.large, colors = if (on) GsColors(scheme.primaryContainer, scheme.onPrimaryContainer)
color = if (on) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHigh, else GsColors(scheme.surfaceContainerHigh, scheme.onSurface),
contentColor = if (on) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface, latched = on,
) { ) {
Row(Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center) {
Text(label, style = MaterialTheme.typography.titleMedium, fontWeight = if (on) FontWeight.Bold else FontWeight.Normal) Text(label, style = MaterialTheme.typography.titleMedium, fontWeight = if (on) FontWeight.Bold else FontWeight.Normal)
} }
} }
} }
} }
} }
/**
* Individually-switched experimental features, shown once the driver has opted into
* experimental features as a whole. Each is its own row - a title, what it does, and a switch -
* so the list reads fine whether there's one entry or several.
*/
@Composable
private fun ExperimentalFeaturesList(voiceAssistant: Boolean, carSensors: Boolean) {
Column(
Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
ExperimentalFeatureRow(
title = "Voice assistant",
description = "Push-to-talk and the car's voice button start listening: navigate, " +
"play media, call, or open an app by saying so. Can mishear you or act on the " +
"wrong command - keep your eyes on the road, and confirm anything it does " +
"before you rely on it.",
warning = true,
checked = voiceAssistant,
) { CarSettings.setVoiceAssistantEnabled(it) }
ExperimentalFeatureRow(
title = "Vehicle data",
description = "Adds a Vehicle data tile: a live, raw dump of the head unit's sensor " +
"channel. Field labels (speed, RPM, fuel...) are carried over from other " +
"reverse-engineering projects and unverified against this car - treat them as a " +
"guess, not a fact, until you've watched one change for yourself.",
checked = carSensors,
) { CarSettings.setCarSensorsEnabled(it) }
}
}
@Composable
private fun ExperimentalFeatureRow(
title: String,
description: String,
checked: Boolean,
warning: Boolean = false,
onCheckedChange: (Boolean) -> Unit,
) {
Surface(
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surfaceContainerHigh,
modifier = Modifier.fillMaxWidth(),
) {
Row(
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text(title, style = MaterialTheme.typography.titleMedium)
Text(
description,
style = MaterialTheme.typography.bodySmall,
color = if (warning) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 2.dp),
)
}
Spacer(Modifier.width(12.dp))
GsSwitch(checked, onCheckedChange)
}
}
} }
/** The URL (or raw HTML) the Web app shows. Edited with [CarKeyboard] - no phone in hand needed. */ /** The URL (or raw HTML) the Web app shows. Edited with [CarKeyboard] - no phone in hand needed. */
@@ -182,6 +302,7 @@ private fun WebPageSetting() {
AppSettings.setStartupUrl(context, it) AppSettings.setStartupUrl(context, it)
}, },
onSubmit = { editing = false }, onSubmit = { editing = false },
onDismiss = { editing = false },
) )
} }
} }
@@ -220,7 +341,7 @@ private fun ScreenFit(vehicle: String) {
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
Button( GsButton(
onClick = { onClick = {
val ok = app.seb3thehacker.gearslip.VehicleProfiles.saveInsets(context, vehicle, insets) val ok = app.seb3thehacker.gearslip.VehicleProfiles.saveInsets(context, vehicle, insets)
saved = if (ok) "Saved to $vehicle" else "Could not save" saved = if (ok) "Saved to $vehicle" else "Could not save"
@@ -251,14 +372,13 @@ private fun Stepper(label: String, value: Int, onStep: (Int) -> Unit) {
@Composable @Composable
private fun StepButton(text: String, modifier: Modifier, onClick: () -> Unit) { private fun StepButton(text: String, modifier: Modifier, onClick: () -> Unit) {
Surface( GsIconBox(
onClick = onClick, onClick = onClick,
modifier = modifier.height(64.dp), modifier = modifier.height(64.dp),
colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHigh, MaterialTheme.colorScheme.onSurface),
shape = MaterialTheme.shapes.large, shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surfaceContainerHigh,
) { ) {
Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) {
Text(text, style = MaterialTheme.typography.titleLarge) Text(text, style = MaterialTheme.typography.titleLarge)
} }
} }
}
@@ -13,20 +13,29 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.graphics.Brush
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.border
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Home import androidx.compose.foundation.layout.BoxScope
import androidx.compose.material.icons.filled.Notifications import androidx.car.app.model.CarIcon
import androidx.car.app.navigation.model.NavigationTemplate
import androidx.car.app.navigation.model.RoutingInfo
import app.seb3thehacker.gearslip.host.CarAppConnection
import app.seb3thehacker.gearslip.host.display
import app.seb3thehacker.gearslip.host.text
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
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
@@ -40,6 +49,10 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState import androidx.compose.runtime.produceState
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.animation.animateContentSize
import android.media.session.PlaybackState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -50,7 +63,17 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import app.seb3thehacker.gearslip.R
import app.seb3thehacker.gearslip.car.theme.GsColors
import app.seb3thehacker.gearslip.car.theme.GsIconBox
import app.seb3thehacker.gearslip.car.theme.GsIconButton
import app.seb3thehacker.gearslip.car.theme.GsButton
import app.seb3thehacker.gearslip.car.theme.GsTone
import app.seb3thehacker.gearslip.car.theme.gsColors
import app.seb3thehacker.gearslip.car.theme.GsThemes
import app.seb3thehacker.gearslip.car.theme.LocalGsTheme
import app.seb3thehacker.gearslip.media.CarMedia import app.seb3thehacker.gearslip.media.CarMedia
import app.seb3thehacker.gearslip.media.MediaArt import app.seb3thehacker.gearslip.media.MediaArt
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@@ -58,7 +81,6 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import app.seb3thehacker.gearslip.host.CarAppConnection
import app.seb3thehacker.gearslip.notify.CarNotifications import app.seb3thehacker.gearslip.notify.CarNotifications
import androidx.core.graphics.drawable.toBitmap import androidx.core.graphics.drawable.toBitmap
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -78,6 +100,7 @@ fun CarUi() {
val scale by CarSettings.scale.collectAsState() val scale by CarSettings.scale.collectAsState()
val phoneDark by CarEnvironment.phoneDark.collectAsState() val phoneDark by CarEnvironment.phoneDark.collectAsState()
val appTheme by CarSettings.appTheme.collectAsState() val appTheme by CarSettings.appTheme.collectAsState()
val uiThemeId by CarSettings.uiTheme.collectAsState()
val mediaApp by CarServices.mediaApp.collectAsState() val mediaApp by CarServices.mediaApp.collectAsState()
val darkOutside by CarEnvironment.darkOutside.collectAsState() val darkOutside by CarEnvironment.darkOutside.collectAsState()
val appContext = LocalContext.current.applicationContext val appContext = LocalContext.current.applicationContext
@@ -102,11 +125,40 @@ fun CarUi() {
// 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() }
// The one place with a navigator and a frame to act a voice command on - CarAssistant itself
// only knows how to listen and speak, not what "open Spotify" means.
val frameForAssistant by CarEnvironment.frame.collectAsState()
LaunchedEffect(navigator) {
CarAssistant.pendingCommand.collect { heard ->
if (heard != null) {
val reply = AssistantCommands.handle(appContext, navigator, frameForAssistant, heard)
CarAssistant.reply(reply)
}
}
}
// The car's own back, home, map, media and phone keys.
LaunchedEffect(navigator) {
CarKeys.screenKeys.collect { key ->
when (key) {
CarKeys.ScreenKey.BACK -> when {
navigator.appMenu != null -> navigator.dismissAppMenu()
VoiceReply.state.value.phase != VoiceReply.Phase.IDLE -> VoiceReply.cancel()
else -> navigator.back()
}
CarKeys.ScreenKey.HOME, CarKeys.ScreenKey.MAP -> navigator.home()
CarKeys.ScreenKey.MEDIA -> if (CarServices.mediaApp.value != null) navigator.media() else navigator.apps()
CarKeys.ScreenKey.PHONE -> BuiltInApps.Phone.open(navigator)
}
}
}
CompositionLocalProvider( CompositionLocalProvider(
LocalDensity provides Density(base.density * scale, base.fontScale), LocalDensity provides Density(base.density * scale, base.fontScale),
LocalCarNavigator provides navigator, LocalCarNavigator provides navigator,
LocalDarkOutside provides darkOutside, LocalDarkOutside provides darkOutside,
LocalMapControlsWake provides controlsWake, LocalMapControlsWake provides controlsWake,
LocalGsTheme provides GsThemes.byId(uiThemeId),
) { ) {
MaterialTheme(colorScheme = colors) { MaterialTheme(colorScheme = colors) {
Surface( Surface(
@@ -131,34 +183,187 @@ fun CarUi() {
) )
} }
val screen = navigator.current val screen = navigator.current
Column(Modifier.fillMaxSize().padding(pad)) { val safetyWarningSeen by CarSettings.hasSeenSafetyWarning.collectAsState()
Box(Modifier.fillMaxSize().padding(pad)) {
Column(Modifier.fillMaxSize()) {
if (!safetyWarningSeen) {
CarSafetyWizard(onDone = CarSettings::markSafetyWarningSeen)
return@Column
}
// Header navigation (breadcrumb trail) - off for now, kept to tweak later. // Header navigation (breadcrumb trail) - off for now, kept to tweak later.
// BreadcrumbBar(navigator) // BreadcrumbBar(navigator)
Box(Modifier.weight(1f).fillMaxWidth()) { Box(Modifier.weight(1f).fillMaxWidth()) {
// Home never leaves the composition; other screens cover it. Dropping it
// would tear down the map's surface, and coming back would then show black
// while the nav app redrew on a new one.
CarHome()
if (screen != CarScreen.Home) {
Box(
Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
// Keeps touches that miss this screen's controls off the map.
.pointerInput(Unit) { detectTapGestures { } },
) {
when (screen) { when (screen) {
CarScreen.Home -> CarHome() CarScreen.Home -> Unit
CarScreen.Apps -> CarLauncher() CarScreen.Apps -> CarLauncher()
CarScreen.Media -> mediaApp?.let { MediaScreen(it) { navigator.back() } } CarScreen.Media -> mediaApp?.let { MediaScreen(it) { navigator.back() } }
CarScreen.Browse -> BrowseAppScreen(navigator)
CarScreen.Settings -> CarSettingsScreen() CarScreen.Settings -> CarSettingsScreen()
CarScreen.Dashboard -> CarDashboardScreen() CarScreen.Dashboard -> CarDashboardScreen()
CarScreen.Weather -> WeatherScreen() CarScreen.Weather -> WeatherScreen()
CarScreen.VehicleData -> VehicleDataScreen()
is CarScreen.Notifications -> NotificationsScreen(screen.replyTo) is CarScreen.Notifications -> NotificationsScreen(screen.replyTo)
is CarScreen.Messages -> MessagesScreen(screen.packageName)
is CarScreen.App -> CarApps.find(screen.id)?.content?.invoke() is CarScreen.App -> CarApps.find(screen.id)?.content?.invoke()
} }
// The history screen is already showing them, so it needs no popup.
if (screen !is CarScreen.Notifications) {
NotificationPopup(Modifier.align(Alignment.TopCenter))
} }
}
PopupTimer()
// The dashboard is already listing them, so it needs no popup.
NotificationPopup(
showNew = screen !is CarScreen.Notifications && screen != CarScreen.Dashboard,
modifier = Modifier.align(Alignment.TopEnd),
)
// Above the popup: a call is more urgent than any notification. // Above the popup: a call is more urgent than any notification.
CallOverlay(Modifier.align(Alignment.TopCenter)) CallOverlay(Modifier.align(Alignment.TopCenter))
AssistantOverlay(Modifier.align(Alignment.TopCenter))
if (app.seb3thehacker.gearslip.BuildConfig.DEBUG) {
// Bottom corner, not top: a screen's own header almost always puts
// something in the top-right (settings, search, a Liked Songs
// shortcut) and this badge used to sit right on top of it, hiding
// whatever the screen drew there.
DevBuildBadge(Modifier.align(Alignment.BottomEnd))
} }
CarNavBar(navigator) }
// The keyboard takes the nav bar's row instead of squeezing in above it -
// nothing on the nav bar is useful mid-type, and the screen is too small to
// show both.
val keyboardVisible by CarKeyboardVisibility.state
if (!keyboardVisible) CarNavBar(navigator)
}
// Over everything, nav bar included: a long press on an app, anywhere.
AppMenu(navigator, rememberRunningApps())
if (safetyWarningSeen) CarWhatsNew()
} }
} }
} }
} }
} }
/**
* A single blocking screen, shown once per install before anything else the car screen can show -
* Home included. The phone's own setup guide carries the same warning, but a driver who plugs in
* without ever opening the phone app would otherwise never see it, on the one screen they are
* actually about to use while driving. [onDone] fires once, from its own button; there is no way
* to skip past this without tapping it.
*/
@Composable
private fun CarSafetyWizard(onDone: () -> Unit) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(
Modifier.fillMaxWidth().padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Surface(
color = MaterialTheme.colorScheme.surfaceContainerLow,
shape = MaterialTheme.shapes.extraLarge,
) {
Column(Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("Drive safely", style = ChromeType.headline, fontWeight = FontWeight.Bold)
Text(
"Gearslip is unofficial software: it is not made or reviewed by any " +
"carmaker, and it can fail without warning.",
style = ChromeType.body,
)
Text(
"Never interact with this screen while driving. Set the route or the " +
"song before you go, then keep your attention on the road.",
style = ChromeType.body,
)
}
}
Spacer(Modifier.height(24.dp))
GsButton(onClick = onDone, modifier = Modifier.fillMaxWidth().height(56.dp)) {
Text("I understand", style = ChromeType.body)
}
}
}
}
/**
* A templated app that isn't navigation, full size with its own title bar and Stop button - the
* same [CarAppStage] chrome the map uses on Home, just not embedded there. [CarServices.browse]
* is a connection of its own, so opening one of these never bumps the map off Home.
*/
@Composable
private fun BrowseAppScreen(navigator: CarNavigator) {
val status by CarServices.browse.status.collectAsState()
val frame by CarEnvironment.frame.collectAsState()
val template by CarServices.browse.template.collectAsState()
val leave = {
CarServices.stopBrowse()
navigator.back()
}
// An app that never answers is as stuck as one that says no; don't leave the driver on "Loading".
var timedOut by remember { mutableStateOf(false) }
LaunchedEffect(template == null) {
timedOut = false
if (template == null) {
delay(APP_START_TIMEOUT_MS)
timedOut = true
}
}
val failed = status.phase == CarAppConnection.Phase.REJECTED || status.phase == CarAppConnection.Phase.FAILED
if (failed || timedOut) {
AppWontStart(status.app ?: "This app", leave)
return
}
CarAppStage(CarServices.browse, status, frame, onDisconnect = leave)
}
private const val APP_START_TIMEOUT_MS = 15_000L
/** What a car app that won't run under Gearslip gets instead of a loading screen that never ends. */
@Composable
private fun AppWontStart(app: String, onBack: () -> Unit) {
Column(
Modifier.fillMaxSize().padding(24.dp),
verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterVertically),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text("$app doesn't work with Gearslip yet.", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
Text(
"It didn't start. Some apps only run on Google's Android Auto.",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
GsButton(onClick = onBack, tone = GsTone.Tonal) { Text("Back") }
}
}
/**
* Marks a debug build on the car screen itself, not just the phone's Home screen - the one
* that matters while driving, since a debug build is slower and less tested than what ships.
*/
@Composable
private fun DevBuildBadge(modifier: Modifier = Modifier) {
Surface(
modifier = modifier.padding(6.dp),
color = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError,
shape = MaterialTheme.shapes.small,
) {
Text(
"DEV BUILD",
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp),
)
}
}
/** /**
* "Home > Apps > Settings", like a file manager's address bar: hidden at Home itself (nothing to * "Home > Apps > Settings", like a file manager's address bar: hidden at Home itself (nothing to
* show yet), and grows by one crumb for every screen pushed from there. The back arrow undoes one * show yet), and grows by one crumb for every screen pushed from there. The back arrow undoes one
@@ -173,9 +378,7 @@ private fun BreadcrumbBar(navigator: CarNavigator) {
Modifier.fillMaxWidth().height(40.dp).padding(start = 2.dp, end = 10.dp), Modifier.fillMaxWidth().height(40.dp).padding(start = 2.dp, end = 10.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
IconButton(onClick = navigator::back, modifier = Modifier.size(36.dp)) { GsIconButton(Icons.AutoMirrored.Filled.ArrowBack, "Back", navigator::back, size = 32.dp, iconSize = 20.dp)
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back", Modifier.size(20.dp))
}
Spacer(Modifier.width(2.dp)) Spacer(Modifier.width(2.dp))
trail.forEachIndexed { index, crumb -> trail.forEachIndexed { index, crumb ->
val isLast = index == trail.lastIndex val isLast = index == trail.lastIndex
@@ -216,21 +419,13 @@ private fun CarNavBar(navigator: CarNavigator) {
val weather by Weather.state.collectAsState() val weather by Weather.state.collectAsState()
val weatherData = (weather as? WeatherState.Ready)?.data val weatherData = (weather as? WeatherState.Ready)?.data
// The one app that's actually open and not otherwise reachable from this bar: media has its
// own pill already, so the only "open app" icon worth showing is whichever map is connected.
val navStatus by CarServices.nav.status.collectAsState()
val lastNav by CarSettings.lastNav.collectAsState()
val openMapComponent = lastNav?.takeIf { navStatus.phase == CarAppConnection.Phase.RUNNING }
val context = LocalContext.current
val pinnedIds by CarSettings.pinnedApps.collectAsState() val pinnedIds by CarSettings.pinnedApps.collectAsState()
val entries by produceState(LauncherCache.entries ?: emptyList(), context) { val entries = rememberAllEntries()
value = LauncherCache.entries ?: withContext(Dispatchers.IO) { LauncherCache.load(context) }
}
val pinnedEntries = remember(entries, pinnedIds) { val pinnedEntries = remember(entries, pinnedIds) {
pinnedIds.mapNotNull { id -> entries.find { it.componentId == id } } pinnedIds.mapNotNull { id -> entries.find { it.componentId == id } }
} }
val frame by CarEnvironment.frame.collectAsState() val frame by CarEnvironment.frame.collectAsState()
val running = rememberRunningApps()
Surface(color = MaterialTheme.colorScheme.surfaceContainer) { Surface(color = MaterialTheme.colorScheme.surfaceContainer) {
BoxWithConstraints(Modifier.fillMaxWidth().height(56.dp).padding(horizontal = 10.dp)) { BoxWithConstraints(Modifier.fillMaxWidth().height(56.dp).padding(horizontal = 10.dp)) {
@@ -239,29 +434,61 @@ private fun CarNavBar(navigator: CarNavigator) {
// breadcrumb trail is how you retrace your steps, but reaching for either of // breadcrumb trail is how you retrace your steps, but reaching for either of
// these two from three screens deep in a media app shouldn't mean reading it // these two from three screens deep in a media app shouldn't mean reading it
// first. // first.
NavItem(Icons.Filled.Home, navigator.current == CarScreen.Home) { navigator.home() } NavHomeButton(navigator, entries, running)
Spacer(Modifier.width(6.dp)) Spacer(Modifier.width(6.dp))
NavItem(MediaIcons.Apps, navigator.current == CarScreen.Apps) { navigator.apps() } NavItem(MediaIcons.Apps, navigator.current == CarScreen.Apps) { navigator.apps() }
val experimentalFeatures by CarSettings.experimentalFeaturesEnabled.collectAsState()
openMapComponent?.let { component -> val voiceEnabled by CarSettings.voiceAssistantEnabled.collectAsState()
if (experimentalFeatures && voiceEnabled) {
Spacer(Modifier.width(6.dp)) Spacer(Modifier.width(6.dp))
NavAppIcon(component, contentDescription = "Map", onClick = navigator::home) NavAssistantIcon()
}
pinnedEntries.forEach { entry ->
Spacer(Modifier.width(6.dp))
entry.componentId?.let { id ->
NavAppIcon(id, contentDescription = entry.label) { launchEntry(entry, navigator, frame) }
}
} }
Box(Modifier.weight(1f).padding(horizontal = 10.dp), contentAlignment = Alignment.CenterStart) { // Pinned shortcuts, then whatever else is open right now. A pinned app that's also
NavNowPlaying(navigator, selected = navigator.current == CarScreen.Media) // open stays in its pinned spot, marked with a dot, instead of showing up twice.
// The map app lives on the Home button, so it never gets a second icon here.
val runningEntries = running.keys
.filter { it !in pinnedIds && running[it] != RunningSlot.NAV }
.mapNotNull { id -> entries.find { it.componentId == id } }
val shownPins = pinnedEntries.filter { running[it.componentId] != RunningSlot.NAV }
if (shownPins.isNotEmpty() || runningEntries.isNotEmpty()) NavDivider()
shownPins.forEach { entry -> NavEntryIcon(entry, running, navigator, frame) }
if (shownPins.isNotEmpty() && runningEntries.isNotEmpty()) NavDivider()
runningEntries.forEach { entry -> NavEntryIcon(entry, running, navigator, frame) }
// Next turn first, off the map only; the player shares the space when it fits,
// and gives way to the turn when it doesn't.
val turn = rememberNextTurn().takeIf { navigator.current != CarScreen.Home }
BoxWithConstraints(Modifier.weight(1f).padding(horizontal = 16.dp), contentAlignment = Alignment.Center) {
val roomForBoth = maxWidth >= TURN_AND_PLAYER_MIN_WIDTH
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
turn?.let { NavTurnPill(it) { navigator.home() } }
if (turn == null || roomForBoth) {
NavNowPlaying(navigator, selected = navigator.current == CarScreen.Media, modifier = Modifier.weight(1f, fill = false))
}
}
} }
// Weather no longer has its own chip - the temperature alone (no icon, no room // Weather no longer has its own chip - the temperature alone (no icon, no room
// for one) sits where "Night"/"Day" used to, freeing a whole chip's width for the // for one) sits where "Night"/"Day" used to, freeing a whole chip's width for the
// Home button above. // Home button above.
NavChip(navigator.current == CarScreen.Dashboard, { navigator.dashboard() }) { // The clock is also the notifications button: the dashboard lists them under the
// calendar and weather, and a dot here marks anything unread.
val unread by CarNotifications.unread.collectAsState()
NavChip(
navigator.current == CarScreen.Dashboard || navigator.current is CarScreen.Notifications,
{ navigator.dashboard() },
) {
if (unread > 0) {
Box(
Modifier
.align(Alignment.TopEnd)
.offset(x = 8.dp, y = (-2).dp)
.size(8.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary),
)
}
Column(horizontalAlignment = Alignment.End) { Column(horizontalAlignment = Alignment.End) {
Text( Text(
DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(now)), DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(now)),
@@ -289,63 +516,146 @@ private fun CarNavBar(navigator: CarNavigator) {
} }
} }
} }
Spacer(Modifier.width(10.dp))
NavItem(
Icons.Filled.Notifications, navigator.current is CarScreen.Notifications, padding = 8.dp,
) { navigator.notifications() }
} }
} }
} }
} }
/**
* Home: the map, with the player or lyrics beside it. While a map app runs, the button wears that
* app's icon and a long press offers to close it, so the app needs no icon of its own on the bar.
*/
@Composable
private fun NavHomeButton(navigator: CarNavigator, entries: List<Entry>, running: Map<String, RunningSlot>) {
val selected = navigator.current == CarScreen.Home
val navId = running.entries.firstOrNull { it.value == RunningSlot.NAV }?.key
val navEntry = navId?.let { id -> entries.find { it.componentId == id } }
GsIconBox(
{ navigator.home() },
colors = navColors(selected),
latched = selected,
onLongClick = navId?.let { id -> { navigator.showAppMenu(id) } },
) {
val icon = navEntry?.icon
if (icon != null) {
Box(Modifier.padding(NAV_ITEM_PADDING).size(28.dp), contentAlignment = Alignment.Center) {
Image(icon, navEntry.label, Modifier.size(28.dp).clip(RoundedCornerShape(7.dp)))
}
} else {
Icon(
ImageVector.vectorResource(R.drawable.navigation_24), contentDescription = "Home",
modifier = Modifier.padding(NAV_ITEM_PADDING).size(28.dp),
)
}
}
}
/** Splits the nav bar's sections: Home and Apps, pinned apps, open apps. */
@Composable
private fun NavDivider() {
Box(
Modifier
.padding(horizontal = 10.dp)
.width(1.dp)
.height(28.dp)
.background(MaterialTheme.colorScheme.outlineVariant),
)
}
/**
* A pinned or open app on the nav bar. Tapping brings it back without reconnecting if it's
* already open; a long press opens its menu (pin, unpin, close).
*/
@Composable
private fun NavEntryIcon(
entry: Entry,
running: Map<String, RunningSlot>,
navigator: CarNavigator,
frame: CarEnvironment.Frame,
) {
val id = entry.componentId ?: return
val slot = running[id]
Box(Modifier.padding(horizontal = 3.dp)) {
NavAppIcon(
entry,
open = slot != null,
showing = slot != null && slot.isShowing(navigator.current),
onLongClick = { navigator.showAppMenu(id) },
) { openApp(entry, slot, navigator, frame) }
}
}
/** One open or pinned app's icon on the nav bar - the same fixed size and shape either way. */ /** One open or pinned app's icon on the nav bar - the same fixed size and shape either way. */
@Composable @Composable
private fun NavAppIcon(component: String, contentDescription: String?, onClick: () -> Unit) { private fun NavAppIcon(
val context = LocalContext.current entry: Entry,
val pkg = remember(component) { android.content.ComponentName.unflattenFromString(component)?.packageName } open: Boolean,
val icon by produceState<androidx.compose.ui.graphics.ImageBitmap?>(null, pkg) { showing: Boolean,
value = pkg?.let { onLongClick: () -> Unit,
runCatching { context.packageManager.getApplicationIcon(it).toBitmap(96, 96).asImageBitmap() }.getOrNull() onClick: () -> Unit,
} ) {
} val scheme = MaterialTheme.colorScheme
GsIconBox(
onClick,
Modifier.size(44.dp),
colors = if (showing) GsColors(scheme.secondaryContainer, scheme.onSecondaryContainer)
else GsColors(scheme.surfaceVariant, scheme.onSurfaceVariant),
latched = showing,
onLongClick = onLongClick,
) {
entry.icon?.let { Image(it, entry.label, Modifier.size(30.dp).clip(RoundedCornerShape(8.dp))) }
entry.builtIn?.let { Icon(it.glyph, entry.label, Modifier.size(26.dp)) }
// Only running apps get the line along the bottom edge, the usual taskbar mark for it.
if (open) {
Box( Box(
Modifier Modifier
.size(44.dp) .align(Alignment.BottomCenter)
.clip(RoundedCornerShape(14.dp)) .padding(bottom = 2.dp)
.background(MaterialTheme.colorScheme.surfaceVariant) .size(width = 12.dp, height = 3.dp)
.clickable(onClick = onClick), .clip(RoundedCornerShape(50))
contentAlignment = Alignment.Center, .background(scheme.primary),
) { )
icon?.let { Image(it, contentDescription, Modifier.size(30.dp).clip(RoundedCornerShape(8.dp))) } }
} }
} }
/** A tappable group in the bar that stays highlighted while its screen is open, like [NavItem]. */ /** A tappable group in the bar that stays highlighted while its screen is open, like [NavItem]. */
@Composable @Composable
private fun NavChip(selected: Boolean, onClick: () -> Unit, content: @Composable () -> Unit) { private fun NavChip(selected: Boolean, onClick: () -> Unit, content: @Composable BoxScope.() -> Unit) {
val scheme = MaterialTheme.colorScheme GsIconBox(onClick, colors = navColors(selected), latched = selected) {
Box( Box(Modifier.padding(horizontal = 10.dp, vertical = 2.dp)) { content() }
Modifier }
.clip(RoundedCornerShape(18.dp))
.background(if (selected) scheme.secondaryContainer else scheme.surfaceContainer)
.clickable(onClick = onClick)
.padding(horizontal = 10.dp, vertical = 6.dp),
contentAlignment = Alignment.Center,
) { content() }
} }
/** Every nav bar item is raised, so it reads as pressable; the current screen's takes the accent colour. */
@Composable @Composable
private fun NavItem(icon: ImageVector, selected: Boolean, padding: androidx.compose.ui.unit.Dp = 12.dp, onClick: () -> Unit) { private fun navColors(selected: Boolean): GsColors {
val scheme = MaterialTheme.colorScheme val s = MaterialTheme.colorScheme
Box( return if (selected) GsColors(s.secondaryContainer, s.onSecondaryContainer)
Modifier else GsColors(s.surfaceContainerHighest, s.onSurface)
.clip(RoundedCornerShape(18.dp)) }
.background(if (selected) scheme.secondaryContainer else scheme.surfaceContainer)
.clickable(onClick = onClick) /**
.padding(padding), * Between Apps and whatever's actually open, not with the clock/battery group on the right - a
contentAlignment = Alignment.Center, * driver reaches for it right after deciding "not a tap for this", same gesture family as the
) { * two buttons beside it.
Icon(icon, contentDescription = null, modifier = Modifier.size(28.dp)) */
@Composable
private fun NavAssistantIcon() {
val state by CarAssistant.state.collectAsState()
val active = state.phase != CarAssistant.Phase.IDLE
GsIconBox({ CarAssistant.start() }, colors = navColors(active), latched = active) {
MicGlyph(Modifier.padding(NAV_ITEM_PADDING).size(28.dp))
}
}
/** Inner padding of every icon on the nav bar, leaving each button room inside the 56dp bar. */
private val NAV_ITEM_PADDING = 8.dp
@Composable
private fun NavItem(icon: ImageVector, selected: Boolean, onClick: () -> Unit) {
GsIconBox(onClick, colors = navColors(selected), latched = selected) {
Icon(icon, contentDescription = null, modifier = Modifier.padding(NAV_ITEM_PADDING).size(28.dp))
} }
} }
@@ -358,10 +668,78 @@ internal fun rememberNow(): State<Long> = produceState(System.currentTimeMillis(
} }
} }
/** How long the pill shows a new song's title before settling to just the controls. */
private const val TITLE_SHOWN_MS = 3_000L
/** Below this, the nav bar's middle only has room for the turn, not the player beside it. */
private val TURN_AND_PLAYER_MIN_WIDTH = 520.dp
/** The next manoeuvre, as the running nav app last described it. */
private class NextTurn(val icon: CarIcon?, val distance: String, val road: String)
/** /**
* The one persistent player, everywhere - not just a Home-screen extra. Art and title open the * Read straight off the nav app's own template: a navigation app keeps sending its routing card
* full media screen; skip and play/pause work right here; the last button toggles the lyrics * while it's running, whether or not the map is the screen being shown.
* panel beside the map on Home (see [CarHome]), the pill's own lyrics affordance wherever it sits. */
@Composable
private fun rememberNextTurn(): NextTurn? {
val status by CarServices.nav.status.collectAsState()
val template by CarServices.nav.template.collectAsState()
// Read so a change of units redraws the distance straight away, not at the next update.
CarSettings.units.collectAsState().value
if (status.phase != CarAppConnection.Phase.RUNNING) return null
val routing = (template as? NavigationTemplate)?.navigationInfo as? RoutingInfo ?: return null
if (routing.isLoading) return null
val step = routing.currentStep ?: return null
return NextTurn(
icon = step.maneuver?.icon,
distance = routing.currentDistance.display(),
road = step.road.text().ifEmpty { step.cue.text() },
)
}
/** The next turn in the nav bar: arrow, how far, onto what. Tapping goes back to the map. */
@Composable
private fun NavTurnPill(turn: NextTurn, onClick: () -> Unit) {
// Same finish as every other button on the bar, in the accent colour; the text runs a step
// bigger than anything else there, since it's the one thing worth a glance mid-drive.
GsIconBox(
onClick,
Modifier.widthIn(max = 340.dp).height(44.dp),
colors = gsColors(GsTone.Primary),
) {
Row(
Modifier.padding(NAV_ITEM_PADDING),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
CarGlyph(turn.icon, Modifier.size(28.dp))
if (turn.distance.isNotEmpty()) {
Text(turn.distance, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, maxLines = 1)
}
if (turn.road.isNotEmpty()) {
Text(
turn.road,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
/** "Title - Artist" on one line. The text itself cuts it off, and only where the pill runs out of room. */
private fun nowPlayingLabel(title: String, artist: String): String =
listOf(title.trim(), artist.trim()).filter { it.isNotEmpty() }.joinToString(" - ")
.ifEmpty { "Nothing playing" }
/**
* The player in the nav bar, on every screen. Art and title open the full media screen;
* play/pause and skip work right here. The dock button moves the player into the column beside
* the map on Home (see [CarHome]), and the pill steps aside there while it's docked - unless the
* column is showing lyrics instead, when the pill stays and its dock button swaps them back.
*/ */
@Composable @Composable
private fun NavNowPlaying(navigator: CarNavigator, selected: Boolean, modifier: Modifier = Modifier) { private fun NavNowPlaying(navigator: CarNavigator, selected: Boolean, modifier: Modifier = Modifier) {
@@ -369,83 +747,106 @@ private fun NavNowPlaying(navigator: CarNavigator, selected: Boolean, modifier:
val phase by media.phase.collectAsState() val phase by media.phase.collectAsState()
val now by media.now.collectAsState() val now by media.now.collectAsState()
if (phase != CarMedia.Phase.READY || !now.isActive) return if (phase != CarMedia.Phase.READY || !now.isActive) return
// Docked beside the map, the player is already on screen - the pill steps aside for it.
if (navigator.sidePanel == SidePanel.CONTROLS && navigator.current == CarScreen.Home) return
// The media screen is the full player already; the pill would only repeat its controls.
if (navigator.current == CarScreen.Media) return
val context = LocalContext.current val context = LocalContext.current
val art by produceState(now.art, now.art, now.artUri) { value = now.art ?: MediaArt.load(context, now.artUri) } val art by produceState(now.art, now.art, now.artUri) { value = now.art ?: MediaArt.load(context, now.artUri) }
val position by produceState(now.currentPosition(), now) {
while (true) {
value = now.currentPosition()
if (!now.playing) break
delay(1000)
}
}
// A new song announces itself: title, play/pause and dock for a few seconds, then the title
// goes and the pill settles to just the controls - unless the driver keeps the title up.
val keepTitle by CarSettings.playerTitle.collectAsState()
var announcing by remember { mutableStateOf(true) }
LaunchedEffect(now.title, now.artist) {
announcing = true
delay(TITLE_SHOWN_MS)
announcing = false
}
val showTitle = keepTitle || announcing
val scheme = MaterialTheme.colorScheme
val onPill = if (selected) scheme.onPrimaryContainer else scheme.onSecondaryContainer
Surface( Surface(
modifier = modifier, modifier = modifier
shape = RoundedCornerShape(50), .animateContentSize()
color = if (selected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.secondaryContainer, // With the title up, all the room the bar has, so as much of it shows as can.
contentColor = if (selected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSecondaryContainer, .then(if (showTitle) Modifier.fillMaxWidth() else Modifier)
.height(44.dp),
shape = RoundedCornerShape(14.dp),
color = if (selected) scheme.primaryContainer else scheme.secondaryContainer,
contentColor = onPill,
) { ) {
Box {
Row( Row(
Modifier.padding(start = 6.dp, end = 4.dp, top = 4.dp, bottom = 4.dp), Modifier.fillMaxHeight().padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp), horizontalArrangement = Arrangement.spacedBy(6.dp),
) { ) {
// Art (and the title while it shows) opens the full media screen.
Row( Row(
Modifier Modifier
.weight(1f, fill = false) .then(if (showTitle) Modifier.weight(1f) else Modifier)
.clip(RoundedCornerShape(50)) .fillMaxHeight()
.clip(RoundedCornerShape(10.dp))
.clickable { navigator.media() }, .clickable { navigator.media() },
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
Box(Modifier.size(40.dp).clip(RoundedCornerShape(50)).background(MaterialTheme.colorScheme.surfaceVariant)) { Box(Modifier.size(34.dp).clip(RoundedCornerShape(8.dp)).background(scheme.surfaceVariant)) {
art?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) } art?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) }
} }
if (showTitle) {
Text( Text(
now.title.ifEmpty { "Nothing playing" }, nowPlayingLabel(now.title, now.artist),
modifier = Modifier.weight(1f, fill = false),
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
} }
FilledIconButton(onClick = media::togglePlay, modifier = Modifier.size(44.dp)) { }
Icon( if (!showTitle) {
GsIconButton(
MediaIcons.Previous, "Previous", media::previous,
enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_PREVIOUS), size = 36.dp, iconSize = 22.dp,
)
}
GsIconButton(
if (now.playing) MediaIcons.Pause else Icons.Filled.PlayArrow, if (now.playing) MediaIcons.Pause else Icons.Filled.PlayArrow,
if (now.playing) "Pause" else "Play", if (now.playing) "Pause" else "Play",
Modifier.size(26.dp), media::togglePlay,
tone = GsTone.Primary, size = 36.dp, iconSize = 22.dp,
)
if (!showTitle) {
GsIconButton(
MediaIcons.Next, "Next", media::next,
enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_NEXT), size = 36.dp, iconSize = 22.dp,
) )
} }
FilledIconButton( GsIconButton(
onClick = navigator::toggleLyrics, MediaIcons.DockRight, "Move the player beside the map", navigator::togglePlayerDock,
modifier = Modifier.size(44.dp), size = 36.dp, iconSize = 22.dp,
colors = androidx.compose.material3.IconButtonDefaults.filledIconButtonColors(
containerColor = if (navigator.lyricsOpen) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.surfaceContainerHighest,
contentColor = if (navigator.lyricsOpen) MaterialTheme.colorScheme.onPrimary
else MaterialTheme.colorScheme.onSurfaceVariant,
),
) {
LyricsGlyph(Modifier.size(22.dp))
}
}
}
}
/**
* A card with two lines of text on it - read as "lyrics"/"captions" at a glance, which no icon in
* the core Material set does (the closest, a bullet list, reads as a menu, not song text). Drawn
* rather than a vector asset since the whole glyph is two shapes.
*/
@Composable
private fun LyricsGlyph(modifier: Modifier = Modifier) {
val tint = androidx.compose.material3.LocalContentColor.current
androidx.compose.foundation.Canvas(modifier) {
val stroke = size.minDimension * 0.11f
drawRoundRect(
color = tint,
cornerRadius = androidx.compose.ui.geometry.CornerRadius(size.minDimension * 0.22f),
style = androidx.compose.ui.graphics.drawscope.Stroke(width = stroke),
) )
val lineStyle = androidx.compose.ui.graphics.drawscope.Stroke(width = stroke * 0.85f, cap = androidx.compose.ui.graphics.StrokeCap.Round) }
val startX = size.width * 0.24f if (now.durationMs > 0) {
listOf(0.40f, 0.64f).forEach { fy -> // matchParentSize, so the line follows the pill's width rather than widening it.
val endX = size.width * (if (fy < 0.5f) 0.76f else 0.62f) Box(Modifier.matchParentSize(), contentAlignment = Alignment.BottomStart) {
drawLine(tint, androidx.compose.ui.geometry.Offset(startX, size.height * fy), androidx.compose.ui.geometry.Offset(endX, size.height * fy), strokeWidth = lineStyle.width, cap = lineStyle.cap) Box(
Modifier
.fillMaxWidth((position.toFloat() / now.durationMs).coerceIn(0f, 1f))
.height(2.dp)
.background(onPill.copy(alpha = 0.7f)),
)
}
}
} }
} }
} }
@@ -453,7 +854,7 @@ private fun LyricsGlyph(modifier: Modifier = Modifier) {
/** /**
* A filled lightning bolt, next to the battery percentage instead of the word "charging" - the * A filled lightning bolt, next to the battery percentage instead of the word "charging" - the
* core Material icon set this project ships (no extended pack, to keep the app small) has no * core Material icon set this project ships (no extended pack, to keep the app small) has no
* bolt, so this is drawn the same way [LyricsGlyph] is. * bolt, so this is drawn rather than a vector asset.
*/ */
@Composable @Composable
private fun ChargingGlyph(modifier: Modifier = Modifier, tint: Color = MaterialTheme.colorScheme.onSurfaceVariant) { private fun ChargingGlyph(modifier: Modifier = Modifier, tint: Color = MaterialTheme.colorScheme.onSurfaceVariant) {
@@ -0,0 +1,113 @@
package app.seb3thehacker.gearslip.car
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import kotlinx.coroutines.delay
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import app.seb3thehacker.gearslip.Changelog
import app.seb3thehacker.gearslip.car.theme.GsButton
import app.seb3thehacker.gearslip.car.theme.GsSwitch
import app.seb3thehacker.gearslip.notify.CarMotion
/**
* What's new in this build, once, the first time the car screen shows after an update - or any
* time from Settings > About. Held back while the car is moving: a list to read is for when
* parked. The switch turns the automatic showing off (or back on); it doesn't close the card.
*/
@Composable
fun CarWhatsNew() {
val release = Changelog.current ?: return
val afterUpdates by CarSettings.whatsNewAfterUpdates.collectAsState()
val seen by CarSettings.whatsNewSeen.collectAsState()
val open by CarSettings.whatsNewOpen.collectAsState()
// Polled: CarMotion is a plain value, and the card should appear once the car stops.
val moving by produceState(CarMotion.moving) {
while (true) {
value = CarMotion.moving
delay(5_000)
}
}
val due = afterUpdates && seen < release.versionCode && !moving
if (!open && !due) return
val close = { CarSettings.closeWhatsNew(release.versionCode) }
Box(
Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.5f))
.clickable(remember { MutableInteractionSource() }, indication = null, onClick = close),
contentAlignment = Alignment.Center,
) {
Surface(
shape = RoundedCornerShape(24.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
// Swallows taps on the card itself, so only the scrim around it closes.
modifier = Modifier
.padding(16.dp)
.widthIn(max = 640.dp)
.clickable(remember { MutableInteractionSource() }, indication = null) {},
) {
Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
Text(
"What's new in ${release.versionName}",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
)
Column(
Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
release.changes.forEach { change ->
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Box(
Modifier
.padding(top = 9.dp)
.size(7.dp)
.background(MaterialTheme.colorScheme.primary, CircleShape),
)
Text(change, style = MaterialTheme.typography.bodyLarge)
}
}
}
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp)) {
GsSwitch(afterUpdates, CarSettings::setWhatsNewAfterUpdates)
Text(
"Show after updates",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f),
)
GsButton(onClick = close, modifier = Modifier.height(56.dp)) {
Text("Got it", style = MaterialTheme.typography.titleMedium)
}
}
}
}
}
}
@@ -1,5 +1,8 @@
package app.seb3thehacker.gearslip.car package app.seb3thehacker.gearslip.car
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import app.seb3thehacker.gearslip.car.theme.*
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
@@ -27,10 +30,7 @@ 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.material3.Button
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
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
@@ -47,7 +47,9 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.PathFillType
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
import androidx.compose.foundation.layout.widthIn
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.addPathNodes import androidx.compose.ui.graphics.vector.addPathNodes
@@ -87,14 +89,13 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) {
// The connection outlives this screen: the home screen's player is the same one. // The connection outlives this screen: the home screen's player is the same one.
LaunchedEffect(app) { CarServices.openMedia(app) } LaunchedEffect(app) { CarServices.openMedia(app) }
Column(Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 10.dp)) { val art = rememberArt(now)
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = onExit) { Icon(Icons.Filled.ArrowBack, contentDescription = "Back to apps") }
Text(app.label, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
Spacer(Modifier.weight(1f))
AudioBadge(capture)
}
// The art's colour behind everything, header included; the buttons keep their own.
Surface(Modifier.fillMaxSize(), color = rememberArtColor(art, MaterialTheme.colorScheme.background, darkLightness = 0.15f, lightLightness = 0.9f)) {
if (phase != CarMedia.Phase.READY) {
Column(Modifier.fillMaxSize()) {
AppIdentity(app, onExit, Modifier.padding(horizontal = 16.dp, vertical = 10.dp))
when (phase) { when (phase) {
CarMedia.Phase.REJECTED -> when (rejection) { CarMedia.Phase.REJECTED -> when (rejection) {
CarMedia.Rejection.NEEDS_NOTIFICATION_ACCESS -> Notice( CarMedia.Rejection.NEEDS_NOTIFICATION_ACCESS -> Notice(
@@ -110,33 +111,51 @@ 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.",
) )
} }
CarMedia.Phase.CONNECTING, CarMedia.Phase.IDLE -> Notice("Connecting to ${app.label}…", null) else -> Notice("Connecting to ${app.label}…", null)
CarMedia.Phase.READY -> Row( }
Modifier.fillMaxSize().padding(top = 6.dp), }
horizontalArrangement = Arrangement.spacedBy(16.dp), return@Surface
}
// Two columns, split the same way on every tab so nothing jumps when switching: the app
// and the art (or, beside a list, the compact player) on the left; the tabs across the
// top of the right column, over whatever the tab shows.
Row(
Modifier.fillMaxSize().padding(start = 16.dp, end = 16.dp, top = 10.dp, bottom = 8.dp),
horizontalArrangement = Arrangement.spacedBy(24.dp),
) { ) {
// The art rail sits at the top, level with the tab row beside it, on every tab - Column(Modifier.weight(0.8f).fillMaxHeight()) {
// not under a full-width tab bar the way it used to be. Transport and the AppIdentity(app, onExit)
// progress bar stay on it always; only the app's extra buttons (shuffle, repeat, Spacer(Modifier.height(12.dp))
// like) are exclusive to the "Now playing" tab's own content area, since those are if (tab == Tab.NOW_PLAYING) {
// the one thing that isn't already always visible here. CoverArt(art, Modifier.weight(1f).fillMaxWidth())
NowPlayingRail(now, media, Modifier.width(220.dp).fillMaxHeight()) } else {
Column(Modifier.weight(1f).fillMaxHeight()) { CompactPlayer(now, media, Modifier.weight(1f).fillMaxWidth())
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { }
AudioBadge(capture)
}
Column(Modifier.weight(1.2f).fillMaxHeight()) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
MediaTab("Now playing", tab == Tab.NOW_PLAYING, Modifier.weight(1f)) { tab = Tab.NOW_PLAYING } MediaTab("Now playing", tab == Tab.NOW_PLAYING, Modifier.weight(1f)) { tab = Tab.NOW_PLAYING }
MediaTab("Browse", tab == Tab.BROWSE, Modifier.weight(1f)) { tab = Tab.BROWSE } MediaTab("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 }
} }
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(12.dp))
Box(Modifier.weight(1f).fillMaxWidth()) { Box(Modifier.weight(1f).fillMaxWidth()) {
when (tab) { when (tab) {
Tab.NOW_PLAYING -> NowPlayingExtras(now, media, Modifier.fillMaxSize()) Tab.NOW_PLAYING -> NowPlayingControls(now, media, Modifier.fillMaxSize())
Tab.BROWSE -> BrowsePanel(browse, media, app.label, Modifier.fillMaxSize()) Tab.BROWSE -> BrowsePanel(browse, media, app.label, Modifier.fillMaxSize())
Tab.QUEUE -> QueuePanel(queue, now, media, Modifier.fillMaxSize()) Tab.QUEUE -> QueuePanel(queue, now, media, Modifier.fillMaxSize())
Tab.LYRICS -> LyricsPanel(now, Modifier.fillMaxSize()) Tab.LYRICS -> LyricsPanel(now, Modifier.fillMaxSize())
@@ -146,6 +165,29 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) {
} }
} }
} }
/**
* Back, then which app this is: its own icon and its name, quieter than the song title - the
* name alone in big bold type read as one more heading fighting the track for attention.
*/
@Composable
private fun AppIdentity(app: MediaApp, onExit: () -> Unit, modifier: Modifier = Modifier) {
val icon = remember(app) { findEntry(app.component.flattenToString())?.icon }
Row(modifier, verticalAlignment = Alignment.CenterVertically) {
GsIconButton(Icons.Filled.ArrowBack, "Back", onExit, size = 44.dp)
Spacer(Modifier.width(14.dp))
icon?.let {
Image(it, null, Modifier.size(26.dp).clip(RoundedCornerShape(7.dp)))
Spacer(Modifier.width(10.dp))
}
Text(
app.label.lowercase().replaceFirstChar { it.titlecase() },
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
} }
private enum class Tab { NOW_PLAYING, BROWSE, QUEUE, LYRICS } private enum class Tab { NOW_PLAYING, BROWSE, QUEUE, LYRICS }
@@ -153,16 +195,21 @@ private enum class Tab { NOW_PLAYING, BROWSE, QUEUE, LYRICS }
/** 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) {
Surface( GsIconBox(
onClick = onClick, onClick = onClick,
modifier = modifier.height(48.dp), modifier = modifier.height(44.dp),
colors = if (selected) GsColors(MaterialTheme.colorScheme.primaryContainer, MaterialTheme.colorScheme.onPrimaryContainer)
else GsColors(MaterialTheme.colorScheme.surfaceContainerHigh, MaterialTheme.colorScheme.onSurface),
shape = MaterialTheme.shapes.large, shape = MaterialTheme.shapes.large,
color = if (selected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHigh, latched = selected,
contentColor = if (selected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface,
) { ) {
Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) { Text(
Text(label, style = MaterialTheme.typography.titleMedium, fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal) label,
} style = MaterialTheme.typography.titleSmall,
fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
} }
} }
@@ -171,10 +218,15 @@ private fun MediaTab(label: String, selected: Boolean, modifier: Modifier, onCli
* the current line in the accent colour; unsynced ones are plain scrolling text. * the current line in the accent colour; unsynced ones are plain scrolling text.
*/ */
@Composable @Composable
internal fun LyricsPanel(now: NowPlaying, modifier: Modifier) { internal fun LyricsPanel(
now: NowPlaying,
modifier: Modifier,
lineStyle: androidx.compose.ui.text.TextStyle = MaterialTheme.typography.titleLarge,
) {
val context = LocalContext.current
val lyrics by produceState<LyricsState>(LyricsState.Loading, now.title, now.artist, now.album, now.durationMs) { val lyrics by produceState<LyricsState>(LyricsState.Loading, now.title, now.artist, now.album, now.durationMs) {
value = LyricsState.Loading value = LyricsState.Loading
value = Lyrics.find(now.title, now.artist, now.album, now.durationMs) value = Lyrics.find(context, now.title, now.artist, now.album, now.durationMs)
?.let { LyricsState.Found(it) } ?: LyricsState.Missing ?.let { LyricsState.Found(it) } ?: LyricsState.Missing
} }
val list = rememberLazyListState() val list = rememberLazyListState()
@@ -204,7 +256,7 @@ internal fun LyricsPanel(now: NowPlaying, modifier: Modifier) {
itemsIndexed(lines) { index, line -> itemsIndexed(lines) { index, line ->
Text( Text(
line.text.ifEmpty { " " }, line.text.ifEmpty { " " },
style = MaterialTheme.typography.titleLarge, style = lineStyle,
fontWeight = if (index == current) FontWeight.Bold else FontWeight.Normal, fontWeight = if (index == current) FontWeight.Bold else FontWeight.Normal,
color = when { color = when {
!state.result.synced -> MaterialTheme.colorScheme.onSurface !state.result.synced -> MaterialTheme.colorScheme.onSurface
@@ -251,115 +303,97 @@ private fun Notice(title: String, detail: String?, action: Pair<String, () -> Un
} }
action?.let { (label, run) -> action?.let { (label, run) ->
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
androidx.compose.material3.FilledTonalButton(onClick = run) { Text(label) } GsButton(onClick = run, tone = GsTone.Tonal) { Text(label) }
} }
} }
} }
/** "1:05" for under a minute past the hour, "1:01:05" once an hour is involved. */
private fun formatDuration(ms: Long): String {
val totalSeconds = (ms / 1000).coerceAtLeast(0)
val hours = totalSeconds / 3600
val minutes = (totalSeconds % 3600) / 60
val seconds = totalSeconds % 60
return if (hours > 0) "%d:%02d:%02d".format(hours, minutes, seconds) else "%d:%02d".format(minutes, seconds)
}
// --- now playing ----------------------------------------------------------------------------- // --- now playing -----------------------------------------------------------------------------
/** /**
* The art rail beside every tab: art, title, transport and the seek bar - always visible no * The now-playing view on its own, for places with no tabs around it (an app's own
* matter which tab is open. Only the app's extra buttons (shuffle, repeat, like) are held back * now-playing template): the cover on the left, [NowPlayingControls] on the right.
* for [NowPlayingExtras], since those are the one thing that isn't already always shown here.
*/ */
@Composable @Composable
private fun NowPlayingRail(now: NowPlaying, media: CarMedia, modifier: Modifier) { internal fun NowPlayingPage(now: NowPlaying, media: CarMedia, modifier: Modifier) {
val context = LocalContext.current Row(
val art by produceState(now.art, now.art, now.artUri) { modifier.padding(horizontal = 24.dp, vertical = 8.dp),
value = now.art ?: MediaArt.load(context, now.artUri) horizontalArrangement = Arrangement.spacedBy(24.dp),
} ) {
var position by remember { mutableLongStateOf(0L) } CoverArt(rememberArt(now), Modifier.weight(0.8f).fillMaxHeight())
LaunchedEffect(now) { NowPlayingControls(now, media, Modifier.weight(1.2f).fillMaxHeight())
while (true) {
position = now.currentPosition()
kotlinx.coroutines.delay(500)
} }
} }
// Whether the progress bar (and its duration text) is showing depends on whether the app has /** The cover, as big a square as its space allows, centred in it. */
// reported a duration yet, which can arrive a beat after everything else - so this rail's @Composable
// content height isn't fixed. A plain fillMaxHeight Column just clips whatever doesn't fit private fun CoverArt(art: android.graphics.Bitmap?, modifier: Modifier) {
// once that row appears; scrolling means it's always reachable instead. BoxWithConstraints(modifier, contentAlignment = Alignment.Center) {
BoxWithConstraints(modifier) {
// Off the space actually available rather than a fixed dp value, so it doesn't overrun a
// short car frame the way a flat size did on anything shorter than a phone screen.
val artSize = minOf(maxWidth, maxHeight * 0.45f).coerceAtLeast(96.dp)
Column(Modifier.verticalScroll(rememberScrollState())) {
Surface( Surface(
shape = MaterialTheme.shapes.large, shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surfaceVariant, color = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.size(artSize), shadowElevation = 6.dp,
modifier = Modifier.size(minOf(maxWidth, maxHeight)),
) { ) {
art?.let { art?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) }
Image(it.asImageBitmap(), null, Modifier.fillMaxSize().clip(MaterialTheme.shapes.large), contentScale = ContentScale.Crop)
} }
} }
Spacer(Modifier.height(16.dp)) }
/**
* Everything about the track and every control for it, read top to bottom: title and artist,
* the seek bar, the transport, then like, shuffle and repeat as big icon-only buttons.
*/
@Composable
private fun NowPlayingControls(now: NowPlaying, media: CarMedia, modifier: Modifier) {
Column(
modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(14.dp, Alignment.CenterVertically),
) {
Column {
Text(
now.title.ifEmpty { "Nothing playing" },
style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold,
maxLines = 2, overflow = TextOverflow.Ellipsis,
)
Text(
listOf(now.artist, now.album).filter { it.isNotEmpty() }.joinToString(" - "),
style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
now.error?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error, maxLines = 2) }
}
PlayerProgress(now, media, Modifier.fillMaxWidth())
TransportRow(now, media, skip = 60.dp, modifier = Modifier.fillMaxWidth())
PlayModeRow(now, media, size = 56.dp, modifier = Modifier.fillMaxWidth(), maxOther = 2)
}
}
/**
* The player beside the browse list, the queue and the lyrics: no art, which the list needs the
* room for, just what's playing, the seek bar and the controls.
*/
@Composable
private fun CompactPlayer(now: NowPlaying, media: CarMedia, modifier: Modifier) {
Column(
modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
Column {
Text( Text(
now.title.ifEmpty { "Nothing playing" }, now.title.ifEmpty { "Nothing playing" },
style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold,
maxLines = 2, overflow = TextOverflow.Ellipsis, maxLines = 2, overflow = TextOverflow.Ellipsis,
) )
Text( Text(
listOf(now.artist, now.album).filter { it.isNotEmpty() }.joinToString(" - "), now.artist,
style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1, overflow = TextOverflow.Ellipsis, maxLines = 1, overflow = TextOverflow.Ellipsis,
) )
now.error?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error, maxLines = 2) }
Spacer(Modifier.height(16.dp))
if (now.durationMs > 0) {
WavyProgress(
fraction = position.toFloat() / now.durationMs,
playing = now.state == PlaybackState.STATE_PLAYING,
onSeek = { media.seek((it * now.durationMs).toLong()) },
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(formatDuration(position), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(formatDuration(now.durationMs), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Spacer(Modifier.height(12.dp))
}
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
IconButton(onClick = media::previous, enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_PREVIOUS), modifier = Modifier.size(56.dp)) {
Icon(MediaIcons.Previous, "Previous", Modifier.size(32.dp))
}
FilledIconButton(onClick = media::togglePlay, modifier = Modifier.size(68.dp)) {
Icon(if (now.playing) MediaIcons.Pause else Icons.Filled.PlayArrow, if (now.playing) "Pause" else "Play", Modifier.size(40.dp))
}
IconButton(onClick = media::next, enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_NEXT), modifier = Modifier.size(56.dp)) {
Icon(MediaIcons.Next, "Next", Modifier.size(32.dp))
}
}
}
}
}
/**
* The "Now playing" tab's own content: just the app's extra buttons (shuffle, repeat, like), by
* name - their icons are resources inside the app's own package, so there's nothing to draw but
* the label. Transport and progress already live on [NowPlayingRail], visible on every tab.
*/
@Composable
private fun NowPlayingExtras(now: NowPlaying, media: CarMedia, modifier: Modifier) {
Column(modifier, verticalArrangement = Arrangement.Center) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
now.custom.take(3).forEach { action ->
TextButton(onClick = { media.custom(action) }) { Text(action.name, maxLines = 1, overflow = TextOverflow.Ellipsis) }
}
} }
PlayerProgress(now, media, Modifier.fillMaxWidth())
TransportRow(now, media, skip = 52.dp, modifier = Modifier.fillMaxWidth())
PlayModeRow(now, media, size = 48.dp, modifier = Modifier.fillMaxWidth())
} }
} }
@@ -374,7 +408,7 @@ private fun BrowsePanel(browse: app.seb3thehacker.gearslip.media.BrowseState, me
if (media.canGoUp || browse.trail.isNotEmpty()) { if (media.canGoUp || browse.trail.isNotEmpty()) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
if (media.canGoUp) { if (media.canGoUp) {
IconButton(onClick = media::up) { Icon(Icons.Filled.ArrowBack, "Up") } GsIconButton(Icons.Filled.ArrowBack, "Up", media::up)
} }
browse.trail.lastOrNull()?.let { browse.trail.lastOrNull()?.let {
Text( Text(
@@ -404,7 +438,7 @@ private fun BrowsePanel(browse: app.seb3thehacker.gearslip.media.BrowseState, me
/** /**
* Whatever the app has queued up, starting right after the track that's playing now - not the * Whatever the app has queued up, starting right after the track that's playing now - not the
* whole queue from the top, which would just repeat what [NowPlayingRail] already shows. Apps * whole queue from the top, which would just repeat what the player beside it already shows. Apps
* that never publish a queue (many don't) show an empty notice rather than a permanently blank tab. * that never publish a queue (many don't) show an empty notice rather than a permanently blank tab.
*/ */
@Composable @Composable
@@ -441,7 +475,7 @@ private fun QueueRow(track: QueueTrack, media: CarMedia) {
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
Row(Modifier.padding(10.dp), verticalAlignment = Alignment.CenterVertically) { Row(Modifier.padding(10.dp), verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.size(52.dp).clip(MaterialTheme.shapes.medium).background(MaterialTheme.colorScheme.surface)) { Box(Modifier.size(62.dp).clip(MaterialTheme.shapes.medium).background(MaterialTheme.colorScheme.surface)) {
icon?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) } icon?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) }
} }
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) { Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
@@ -468,7 +502,7 @@ private fun EntryRow(entry: MediaEntry, media: CarMedia) {
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
Row(Modifier.padding(10.dp), verticalAlignment = Alignment.CenterVertically) { Row(Modifier.padding(10.dp), verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.size(52.dp).clip(MaterialTheme.shapes.medium).background(MaterialTheme.colorScheme.surface)) { Box(Modifier.size(62.dp).clip(MaterialTheme.shapes.medium).background(MaterialTheme.colorScheme.surface)) {
icon?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) } icon?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) }
} }
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) { Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
@@ -478,7 +512,7 @@ private fun EntryRow(entry: MediaEntry, media: CarMedia) {
} }
} }
if (entry.browsable && entry.playable) { if (entry.browsable && entry.playable) {
IconButton(onClick = { media.playAll(entry) }) { Icon(Icons.Filled.PlayArrow, "Play") } GsIconButton(Icons.Filled.PlayArrow, "Play", { media.playAll(entry) })
} }
if (entry.browsable) Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, null) if (entry.browsable) Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, null)
} }
@@ -492,8 +526,14 @@ internal object MediaIcons {
/** The 3x3 app grid; not in material-icons-core. */ /** The 3x3 app grid; not in material-icons-core. */
val Apps = icon("Apps", "M4,8h4L8,4L4,4v4zM10,20h4v-4h-4v4zM4,20h4v-4L4,16v4zM4,14h4v-4L4,10v4zM10,14h4v-4h-4v4zM16,4v4h4L20,4h-4zM10,8h4L14,4h-4v4zM16,14h4v-4h-4v4zM16,20h4v-4h-4v4z") val Apps = icon("Apps", "M4,8h4L8,4L4,4v4zM10,20h4v-4h-4v4zM4,20h4v-4L4,16v4zM4,14h4v-4L4,10v4zM10,14h4v-4h-4v4zM16,4v4h4L20,4h-4zM10,8h4L14,4h-4v4zM16,14h4v-4h-4v4zM16,20h4v-4h-4v4z")
val Previous = icon("SkipPrevious", "M6,6h2v12L6,18zM9.5,12l8.5,6V6z") val Previous = icon("SkipPrevious", "M6,6h2v12L6,18zM9.5,12l8.5,6V6z")
val Equalizer = icon("Equalizer", "M10,20h4L14,4h-4v16zM4,20h4v-8L4,12v8zM16,9v11h4L20,9h-4z")
val Shuffle = icon("Shuffle", "M10.59,9.17L5.41,4 4,5.41l5.17,5.17 1.42,-1.41zM14.5,4l2.04,2.04L4,18.59 5.41,20 17.96,7.46 20,9.5L20,4h-5.5zM14.83,13.41l-1.41,1.41 3.13,3.13L14.5,20L20,20v-5.5l-2.04,2.04 -3.13,-3.13z")
val Repeat = icon("Repeat", "M7,7h10v3l4,-4 -4,-4v3L5,5v6h2L7,7zM17,17L7,17v-3l-4,4 4,4v-3h12v-6h-2v4z")
val RepeatOne = icon("RepeatOne", "M7,7h10v3l4,-4 -4,-4v3L5,5v6h2L7,7zM17,17L7,17v-3l-4,4 4,4v-3h12v-6h-2v4zM13,15L13,9h-1l-2,1v1h1.5v4L13,15z")
/** A screen with a filled right-hand panel: "put the player beside the map". */
val DockRight = icon("DockRight", "M2,4h20v16H2zM4,6h10v12H4z", PathFillType.EvenOdd)
private fun icon(name: String, path: String) = ImageVector.Builder( private fun icon(name: String, path: String, fillType: PathFillType = PathFillType.NonZero) = ImageVector.Builder(
name, 24.dp, 24.dp, 24f, 24f, name, 24.dp, 24.dp, 24f, 24f,
).addPath(addPathNodes(path), fill = SolidColor(androidx.compose.ui.graphics.Color.Black)).build() ).addPath(addPathNodes(path), pathFillType = fillType, fill = SolidColor(androidx.compose.ui.graphics.Color.Black)).build()
} }
@@ -0,0 +1,274 @@
package app.seb3thehacker.gearslip.car
import android.os.SystemClock
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.withFrameMillis
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import app.seb3thehacker.gearslip.R
import app.seb3thehacker.gearslip.car.theme.GsButton
import app.seb3thehacker.gearslip.car.theme.GsIconButton
import app.seb3thehacker.gearslip.car.theme.GsProgressBar
import app.seb3thehacker.gearslip.car.theme.GsTone
import app.seb3thehacker.gearslip.notify.CarNotification
import app.seb3thehacker.gearslip.notify.CarNotifications
import kotlinx.coroutines.delay
/*
* A new message on the car screen: a card at the top right, over the player beside the map on
* Home, so the road stays in view. It offers two things - hear it, or answer it by voice - and
* shows [VoiceReply]'s progress once either starts. A bar along its bottom empties as the card
* runs out of time, whatever it's waiting to do: go away, or send.
*/
/** How long a new message stays up on its own. */
private const val POPUP_MS = 5_000L
/** When [this] popup goes away on its own. */
private fun CarNotification.until() = shownAt + POPUP_MS
/**
* Times the popup out. Each new message in the same chat starts the clock again. A voice reply
* covers the card while it runs; if the popup's time ran out meanwhile, it goes when the reply ends.
*/
@Composable
internal fun PopupTimer() {
val popup by CarNotifications.popup.collectAsState()
val voice by VoiceReply.state.collectAsState()
val n = popup ?: return
val busy = voice.phase != VoiceReply.Phase.IDLE
LaunchedEffect(n.id, n.shownAt, busy) {
if (busy) return@LaunchedEffect
delay((n.until() - SystemClock.uptimeMillis()).coerceAtLeast(0L))
CarNotifications.dismissPopup(n.id)
}
}
@Composable
internal fun ReadGlyph(modifier: Modifier = Modifier) =
Icon(ImageVector.vectorResource(R.drawable.volume_up_24), null, modifier)
@Composable
internal fun ReplyGlyph(modifier: Modifier = Modifier) =
Icon(ImageVector.vectorResource(R.drawable.voice_selection_24), null, modifier)
/** "Alex", or "Alex · 3 new" when a burst of messages from one chat has been merged. */
private fun CarNotification.heading(): String {
val who = title.ifBlank { appLabel }
return if (burst > 1) "$who · $burst new" else who
}
/** The card. It shows the voice reply too, wherever that was started from. */
@Composable
fun NotificationPopup(showNew: Boolean, modifier: Modifier = Modifier) {
val popup by CarNotifications.popup.collectAsState()
val voice by VoiceReply.state.collectAsState()
val target = voice.target
if (voice.phase != VoiceReply.Phase.IDLE && target != null) {
PopupCard(voice.until, voice.span, modifier, held = voice.held) { VoiceReplyBody(voice, target) }
return
}
val n = popup?.takeIf { showNew } ?: return
PopupCard(n.until(), POPUP_MS, modifier) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
NotificationIcon(n, size = 44.dp)
Column(Modifier.weight(1f)) {
Text(n.heading(), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(n.text, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
GsIconButton(Icons.Filled.Close, "Dismiss", { CarNotifications.dismissPopup(n.id) }, size = 40.dp, iconSize = 20.dp)
}
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
GsButton(onClick = { VoiceReply.read(n) }, tone = GsTone.Neutral, modifier = Modifier.weight(1f)) {
ReadGlyph(Modifier.size(20.dp))
Text("Read", Modifier.padding(start = 8.dp))
}
if (n.reply != null) {
GsButton(onClick = { VoiceReply.reply(n) }, modifier = Modifier.weight(1f)) {
ReplyGlyph(Modifier.size(20.dp))
Text("Reply", Modifier.padding(start = 8.dp))
}
}
}
}
}
/**
* The card's frame. When [until] is set, a bar along the bottom empties over [span] ms, so the
* driver sees at a glance how long is left before the card acts. A [held] countdown shows its bar
* standing still.
*/
@Composable
private fun PopupCard(until: Long, span: Long, modifier: Modifier, held: Float = 0f, content: @Composable () -> Unit) {
Surface(
shape = RoundedCornerShape(20.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
contentColor = MaterialTheme.colorScheme.onSurface,
shadowElevation = 8.dp,
modifier = modifier.padding(12.dp).widthIn(min = 300.dp, max = 400.dp).fillMaxWidth(0.34f),
) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
content()
if (until > 0L && span > 0L) {
val left by rememberTimeLeft(until, span)
GsProgressBar(left, Modifier.fillMaxWidth(), height = 14.dp)
} else if (held > 0f) {
GsProgressBar(held, Modifier.fillMaxWidth(), height = 14.dp)
}
}
}
}
/** The share of [span] still to go before [until], updated every frame so the bar moves smoothly. */
@Composable
private fun rememberTimeLeft(until: Long, span: Long) = produceState(fractionLeft(until, span), until, span) {
// The state outlives a change of deadline, so start from the new one rather than the old value.
value = fractionLeft(until, span)
while (value > 0f) {
withFrameMillis { value = fractionLeft(until, span) }
}
}
private fun fractionLeft(until: Long, span: Long): Float =
((until - SystemClock.uptimeMillis()).toFloat() / span).coerceIn(0f, 1f)
/** Each step of a voice reply, with the buttons that fit it. */
@Composable
private fun VoiceReplyBody(s: VoiceReply.State, n: CarNotification) {
val navigator = LocalCarNavigator.current
val who = n.title.ifBlank { n.appLabel }
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
when (s.phase) {
VoiceReply.Phase.OFFERING, VoiceReply.Phase.ASKING, VoiceReply.Phase.LISTENING -> ListeningDot()
VoiceReply.Phase.SENT -> Icon(Icons.Filled.Check, null, Modifier.size(32.dp), tint = MaterialTheme.colorScheme.primary)
else -> NotificationIcon(n, size = 44.dp)
}
Column(Modifier.weight(1f)) {
Text(voiceHeading(s, who), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis)
voiceDetail(s, n)?.let {
Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 3, overflow = TextOverflow.Ellipsis)
}
}
}
if (s.phase == VoiceReply.Phase.CONFIRMING) {
Text("Say “cancel” or “change it”", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
}
if ((s.phase == VoiceReply.Phase.READ || s.phase == VoiceReply.Phase.OFFERING) && n.reply != null) {
QuickReplies(n)
}
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
val wide = Modifier.weight(1f)
when (s.phase) {
VoiceReply.Phase.READING, VoiceReply.Phase.READ -> {
GsButton(onClick = VoiceReply::cancel, tone = GsTone.Neutral, modifier = wide) {
Text(if (s.phase == VoiceReply.Phase.READING) "Stop" else "Close")
}
if (n.reply != null) {
GsButton(onClick = { VoiceReply.reply(n, readFirst = false) }, modifier = wide) {
ReplyGlyph(Modifier.size(20.dp))
Text("Reply", Modifier.padding(start = 8.dp))
}
}
}
VoiceReply.Phase.OFFERING -> {
GsButton(onClick = VoiceReply::cancel, tone = GsTone.Neutral, modifier = wide) { Text("No") }
GsButton(onClick = { VoiceReply.reply(n, readFirst = false) }, modifier = wide) {
ReplyGlyph(Modifier.size(20.dp))
Text("Reply", Modifier.padding(start = 8.dp))
}
}
VoiceReply.Phase.ASKING, VoiceReply.Phase.LISTENING ->
GsButton(onClick = VoiceReply::cancel, tone = GsTone.Neutral, modifier = wide) { Text("Cancel") }
VoiceReply.Phase.CONFIRMING -> {
GsButton(onClick = VoiceReply::cancel, tone = GsTone.Neutral, modifier = wide) { Text("Cancel") }
GsButton(onClick = VoiceReply::sendNow, modifier = wide) { Text("Send now") }
}
VoiceReply.Phase.ERROR -> {
GsButton(onClick = VoiceReply::cancel, tone = GsTone.Neutral, modifier = wide) { Text("Close") }
if (n.reply != null) {
GsButton(onClick = { VoiceReply.reply(n, readFirst = false) }, tone = GsTone.Tonal, modifier = wide) { Text("Try again") }
GsButton(
onClick = { VoiceReply.cancel(); navigator.notifications(replyTo = n.key) },
tone = GsTone.Neutral, modifier = wide,
) { Text("Type") }
}
}
VoiceReply.Phase.SENT, VoiceReply.Phase.IDLE -> Unit
}
}
}
private fun voiceHeading(s: VoiceReply.State, who: String): String = when (s.phase) {
VoiceReply.Phase.READING, VoiceReply.Phase.READ -> who
VoiceReply.Phase.OFFERING, VoiceReply.Phase.ASKING -> "Reply to $who"
VoiceReply.Phase.LISTENING -> "Listening"
VoiceReply.Phase.CONFIRMING -> "Sending to $who"
VoiceReply.Phase.SENT -> "Sent to $who"
VoiceReply.Phase.ERROR -> "Couldn't reply"
VoiceReply.Phase.IDLE -> ""
}
private fun voiceDetail(s: VoiceReply.State, n: CarNotification): String? = when (s.phase) {
VoiceReply.Phase.READING, VoiceReply.Phase.READ -> n.text
VoiceReply.Phase.OFFERING -> s.heard.ifBlank { "Do you want to reply? Say yes or no." }
VoiceReply.Phase.ASKING -> "Get ready to say your reply."
VoiceReply.Phase.LISTENING -> s.heard.ifBlank { "Say your reply" }
VoiceReply.Phase.CONFIRMING, VoiceReply.Phase.SENT -> "“${s.heard}”"
VoiceReply.Phase.ERROR -> s.error
VoiceReply.Phase.IDLE -> null
}
/** Ready-made answers, one tap each: sent at once, with no voice. */
@Composable
internal fun QuickReplies(n: CarNotification, modifier: Modifier = Modifier) {
Row(
modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
VoiceReply.quickReplies().forEach { text ->
GsButton(onClick = { VoiceReply.quickReply(n, text) }, tone = GsTone.Neutral) {
Text(text, style = MaterialTheme.typography.labelLarge, maxLines = 1)
}
}
}
}
@Composable
private fun ListeningDot(modifier: Modifier = Modifier) {
Box(
modifier.size(44.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primary),
contentAlignment = Alignment.Center,
) {
MicGlyph(Modifier.size(24.dp))
}
}
@@ -0,0 +1,198 @@
package app.seb3thehacker.gearslip.car
import app.seb3thehacker.gearslip.car.theme.*
import android.content.Intent
import android.provider.Settings
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import app.seb3thehacker.gearslip.notify.CarNotification
import app.seb3thehacker.gearslip.notify.CarNotifications
import app.seb3thehacker.gearslip.notify.ChatLine
import java.text.DateFormat
import java.util.Date
/** Lines a collapsed conversation shows; tapping it shows the rest the app sent. */
private const val COLLAPSED_LINES = 2
/**
* A messaging app's recent conversations, the way Android Auto shows them: the app has no car
* screen of its own, so each thread comes from the notification it has on the phone, newest
* first, with reply and mark-as-read when the app offers them.
*/
@Composable
fun MessagesScreen(packageName: String) {
val history by CarNotifications.history.collectAsState()
val listening by CarNotifications.listening.collectAsState()
val navigator = LocalCarNavigator.current
val context = LocalContext.current.applicationContext
val entry = remember(packageName) { findEntry(MESSAGING_PREFIX + packageName) }
val label = entry?.label ?: history.firstOrNull { it.packageName == packageName }?.appLabel ?: "Messages"
val threads = history.filter { it.packageName == packageName }
LaunchedEffect(history) { CarNotifications.dismissPopupFrom(packageName) }
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
GsIconButton(Icons.AutoMirrored.Filled.ArrowBack, "Back", navigator::back)
entry?.icon?.let { Image(it, contentDescription = null, modifier = Modifier.size(36.dp).clip(RoundedCornerShape(8.dp))) }
Text(label, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
}
if (!listening) {
AccessBanner {
context.startActivity(
Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
)
}
}
if (threads.isEmpty()) {
Text(
"No recent messages. New $label messages appear here as they arrive on the phone.",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(10.dp),
contentPadding = PaddingValues(bottom = 16.dp),
) {
items(threads, key = { it.key }) { n ->
ConversationCard(
n,
onRead = { VoiceReply.read(n) },
onReply = { VoiceReply.reply(n) },
onMarkRead = { CarNotifications.markThreadRead(context, n) },
)
}
}
}
}
}
@Composable
private fun ConversationCard(n: CarNotification, onRead: () -> Unit, onReply: () -> Unit, onMarkRead: () -> Unit) {
var expanded by rememberSaveable(n.key) { mutableStateOf(false) }
// An app that posts plain notifications instead of a conversation still gets one line.
val lines = n.lines.ifEmpty { listOf(ChatLine(n.title.ifBlank { n.appLabel }, n.text.ifBlank { n.title }, n.postedAt)) }
val shown = if (expanded) lines else lines.takeLast(COLLAPSED_LINES)
val dim = n.read || n.replied
var quick by rememberSaveable(n.key) { mutableStateOf(false) }
Surface(
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surfaceContainer,
modifier = Modifier
.fillMaxWidth()
.animateContentSize()
.clickable(enabled = lines.size > COLLAPSED_LINES) { expanded = !expanded },
) {
Row(
Modifier.padding(14.dp),
verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
NotificationIcon(n, size = 52.dp)
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
n.title.ifBlank { n.appLabel },
style = MaterialTheme.typography.titleLarge,
fontWeight = if (dim) FontWeight.Medium else FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
Text(
" · ${DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(n.postedAt))}",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
)
}
if (!expanded && lines.size > COLLAPSED_LINES) {
Text(
"${lines.size - COLLAPSED_LINES} earlier",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
)
}
shown.forEach { MessageLine(it, showSender = n.isGroup) }
if (quick) QuickReplies(n, Modifier.padding(top = 6.dp))
}
Column(horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(8.dp)) {
if (n.replied) {
Icon(Icons.Filled.Check, contentDescription = "Replied", tint = MaterialTheme.colorScheme.primary)
}
GsButton(onClick = onRead, tone = GsTone.Neutral) {
ReadGlyph(Modifier.size(20.dp))
Text("Read", Modifier.padding(start = 8.dp))
}
if (n.reply != null) {
GsButton(onClick = onReply, tone = GsTone.Tonal) {
ReplyGlyph(Modifier.size(20.dp))
Text("Reply", Modifier.padding(start = 8.dp))
}
}
if (n.reply != null) {
GsButton(onClick = { quick = !quick }, tone = GsTone.Neutral) {
Text(if (quick) "Hide quick replies" else "Quick reply")
}
}
if (n.markRead != null) {
GsButton(onClick = onMarkRead, tone = GsTone.Neutral) { Text("Mark read") }
}
}
}
}
}
@Composable
private fun MessageLine(line: ChatLine, showSender: Boolean) {
val who = when {
line.sender == null -> "You: "
showSender -> "${line.sender}: "
else -> ""
}
Text(
who + line.text,
style = MaterialTheme.typography.bodyLarge,
color = if (line.sender == null) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
}
@@ -0,0 +1,278 @@
package app.seb3thehacker.gearslip.car
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Call
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import app.seb3thehacker.gearslip.car.theme.GsButton
import app.seb3thehacker.gearslip.car.theme.GsTone
import app.seb3thehacker.gearslip.host.CarAppConnection
import app.seb3thehacker.gearslip.media.CarMedia
/** 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:"
/** One of Gearslip's own apps: a screen, with nothing running behind it once you leave. */
internal class BuiltInApp(
val id: String,
val label: String,
val glyph: ImageVector,
val screen: CarScreen,
val open: (CarNavigator) -> Unit,
)
internal object BuiltInApps {
val Web = BuiltInApp("web", "Web", Icons.Filled.Search, CarScreen.App("web")) { it.open("web") }
val ScreenSharing = BuiltInApp("phone", "Screen sharing", Icons.Filled.Share, CarScreen.App("phone")) { it.open("phone") }
val Phone = BuiltInApp("dialer", "Phone", Icons.Filled.Call, CarScreen.App("dialer")) { it.open("dialer") }
val VehicleData = BuiltInApp("vehicle", "Vehicle data", Icons.Filled.Info, CarScreen.VehicleData) { it.vehicleData() }
val Settings = BuiltInApp("settings", "Settings", Icons.Filled.Settings, CarScreen.Settings) { it.settings() }
val all = listOf(Web, ScreenSharing, Phone, VehicleData, Settings)
val entries: List<Entry> = all.map { Entry(it.label, icon = null, builtIn = it) }
}
/** Vehicle data is experimental: raw, unverified sensor labels (see [CarSensors]). */
@Composable
internal fun rememberVehicleDataShown(): Boolean {
val experimental by CarSettings.experimentalFeaturesEnabled.collectAsState()
val sensors by CarSettings.carSensorsEnabled.collectAsState()
return experimental && sensors
}
/**
* Where an open app lives - decides where tapping it goes and how it closes. Gearslip's own
* apps have no connection behind them, so [SCREEN] is simply "on screen right now".
*/
internal enum class RunningSlot { NAV, BROWSE, MEDIA, SCREEN }
/**
* Apps open right now, as component id to where they live, in the order the nav bar shows them:
* the map, then a browsed templated app, then the media app, then whichever built-in app is on
* screen. The map and a browsed app count from the moment they start connecting until they're
* closed. The media app counts while it has something loaded to play - the same rule the nav
* bar's player follows, so the two always show up together - or while its own screen is up.
*/
@Composable
internal fun rememberRunningApps(): Map<String, RunningSlot> {
val navStatus by CarServices.nav.status.collectAsState()
val browseStatus by CarServices.browse.status.collectAsState()
val mediaApp by CarServices.mediaApp.collectAsState()
val mediaPhase by CarServices.media.phase.collectAsState()
val now by CarServices.media.now.collectAsState()
val current = LocalCarNavigator.current.current
fun CarAppConnection.liveId(phase: CarAppConnection.Phase) =
connectedComponent?.flattenToString()?.takeIf {
phase == CarAppConnection.Phase.BINDING ||
phase == CarAppConnection.Phase.HANDSHAKE ||
phase == CarAppConnection.Phase.RUNNING
}
return buildMap {
CarServices.nav.liveId(navStatus.phase)?.let { put(it, RunningSlot.NAV) }
CarServices.browse.liveId(browseStatus.phase)?.let { put(it, RunningSlot.BROWSE) }
val mediaLive = when (mediaPhase) {
CarMedia.Phase.CONNECTING -> true
CarMedia.Phase.READY -> now.isActive || current == CarScreen.Media
else -> false
}
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) }
(current as? CarScreen.Messages)?.let { put(MESSAGING_PREFIX + it.packageName, RunningSlot.SCREEN) }
}
}
/** The screen an open app is showing on, so its nav bar icon can stay pressed in while it's up. */
internal fun RunningSlot.isShowing(current: CarScreen): Boolean = when (this) {
RunningSlot.NAV -> current == CarScreen.Home
RunningSlot.BROWSE -> current == CarScreen.Browse
RunningSlot.MEDIA -> current == CarScreen.Media
RunningSlot.SCREEN -> true
}
/**
* Brings an app up: back to its screen if it's already open - never a reconnect, which would
* throw away the route or restart the session - otherwise a fresh launch.
*/
internal fun openApp(entry: Entry, slot: RunningSlot?, navigator: CarNavigator, frame: CarEnvironment.Frame) {
when (slot) {
RunningSlot.NAV -> navigator.home()
RunningSlot.BROWSE -> navigator.browse(entry.label)
RunningSlot.MEDIA -> navigator.media()
RunningSlot.SCREEN -> Unit
null -> launchEntry(entry, navigator, frame)
}
}
internal fun closeApp(slot: RunningSlot, navigator: CarNavigator) {
when (slot) {
RunningSlot.NAV -> CarServices.stopNav()
RunningSlot.BROWSE -> CarServices.stopBrowse()
RunningSlot.MEDIA -> CarServices.closeMedia()
RunningSlot.SCREEN -> Unit
}
if (slot != RunningSlot.NAV && slot.isShowing(navigator.current)) navigator.back()
}
/**
* What a long press on an app offers - pin it to or unpin it from the nav bar, move it along the
* 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
* car's virtual display has no reason to host well.
*/
@Composable
internal fun AppMenu(navigator: CarNavigator, running: Map<String, RunningSlot>) {
val id = navigator.appMenu ?: return
val entry = findEntry(id)
if (entry == null) {
navigator.dismissAppMenu()
return
}
val pinned by CarSettings.pinnedApps.collectAsState()
val isPinned = id in pinned
val barFull = !isPinned && pinned.size >= CarSettings.MAX_PINNED
val slot = running[id]
Box(
Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.5f))
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = navigator::dismissAppMenu,
),
contentAlignment = Alignment.Center,
) {
Surface(
shape = RoundedCornerShape(24.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
// Swallows taps on the card itself, so only the scrim around it dismisses.
modifier = Modifier
.widthIn(max = 420.dp)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = {},
),
) {
Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
entry.icon?.let { Image(it, null, Modifier.size(44.dp).clip(RoundedCornerShape(10.dp))) }
entry.builtIn?.let { Icon(it.glyph, null, Modifier.size(36.dp)) }
Text(
entry.label,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(start = 14.dp),
)
}
GsButton(
onClick = {
CarSettings.togglePin(id)
navigator.dismissAppMenu()
},
tone = GsTone.Tonal,
enabled = !barFull,
modifier = Modifier.fillMaxWidth().height(56.dp),
) {
Text(
when {
isPinned -> "Unpin from nav bar"
barFull -> "Nav bar is full (${CarSettings.MAX_PINNED} pinned)"
else -> "Pin to nav bar"
},
style = MaterialTheme.typography.titleMedium,
)
}
if (navigator.current == CarScreen.Apps) MoveButtons(id)
if (slot != null) {
GsButton(
onClick = {
closeApp(slot, navigator)
navigator.dismissAppMenu()
},
tone = GsTone.Danger,
modifier = Modifier.fillMaxWidth().height(56.dp),
) { Text("Close app", style = MaterialTheme.typography.titleMedium) }
}
GsButton(
onClick = navigator::dismissAppMenu,
tone = GsTone.Neutral,
modifier = Modifier.fillMaxWidth().height(56.dp),
) { Text("Cancel", style = MaterialTheme.typography.titleMedium) }
}
}
}
}
/**
* 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) }
}
}
@@ -1,9 +1,9 @@
package app.seb3thehacker.gearslip.car package app.seb3thehacker.gearslip.car
import app.seb3thehacker.gearslip.car.theme.*
import android.content.Intent import android.content.Intent
import android.provider.Settings import android.provider.Settings
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -13,8 +13,6 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
@@ -23,10 +21,7 @@ import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Warning import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Button
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
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
@@ -35,6 +30,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@@ -52,34 +48,40 @@ import kotlinx.coroutines.delay
import java.text.DateFormat import java.text.DateFormat
import java.util.Date import java.util.Date
/** The notification history, with a reply pane for anything the sending app lets us answer. */ /**
* Replying to one notification. The list itself lives on the dashboard (see
* [NotificationsSection]); with nothing to reply to, this is just the dashboard.
*/
@Composable @Composable
fun NotificationsScreen(replyTo: String?) { fun NotificationsScreen(replyTo: String?) {
val history by CarNotifications.history.collectAsState() val history by CarNotifications.history.collectAsState()
var replying by remember(replyTo) { mutableStateOf(replyTo) } val navigator = LocalCarNavigator.current
val target = history.firstOrNull { it.key == replyTo && it.reply != null }
if (target != null) {
ReplyPane(target, onDone = navigator::back)
} else {
CarDashboardScreen()
}
}
// Anything arriving while this is open is being looked at, so it never counts as unread. /**
* The notification history, under the calendar on the dashboard. Replying opens its own pane.
* Anything arriving while this is on screen is being looked at, so it never counts as unread.
*/
@Composable
fun NotificationsSection() {
val history by CarNotifications.history.collectAsState()
val listening by CarNotifications.listening.collectAsState()
val navigator = LocalCarNavigator.current
val context = LocalContext.current.applicationContext
LaunchedEffect(history) { CarNotifications.markRead() } LaunchedEffect(history) { CarNotifications.markRead() }
val target = history.firstOrNull { it.key == replying && it.reply != null } Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
if (target != null) {
ReplyPane(target, onDone = { replying = null })
} else {
NotificationList(history, onReply = { replying = it.key })
}
}
@Composable
private fun NotificationList(history: List<CarNotification>, onReply: (CarNotification) -> Unit) {
val listening by CarNotifications.listening.collectAsState()
val context = LocalContext.current.applicationContext
Column(Modifier.fillMaxSize().padding(horizontal = 20.dp, vertical = 12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Text( Text(
"Notifications", "Notifications",
style = MaterialTheme.typography.headlineMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
if (history.isNotEmpty()) TextButton(onClick = CarNotifications::clear) { Text("Clear all") } if (history.isNotEmpty()) TextButton(onClick = CarNotifications::clear) { Text("Clear all") }
@@ -94,22 +96,21 @@ private fun NotificationList(history: List<CarNotification>, onReply: (CarNotifi
} }
if (history.isEmpty()) { if (history.isEmpty()) {
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
Text( Text(
"Nothing yet. New notifications from the phone will show up here.", "Nothing yet. New notifications from the phone will show up here.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
}
} else { } else {
LazyColumn(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp)) { history.forEach { n ->
items(history, key = { it.key }) { NotificationRow(it, onReply) } key(n.key) { NotificationRow(n) { navigator.notifications(replyTo = it.key) } }
} }
} }
} }
} }
@Composable @Composable
private fun AccessBanner(onOpen: () -> Unit) { internal fun AccessBanner(onOpen: () -> Unit) {
Surface( Surface(
shape = MaterialTheme.shapes.large, shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.errorContainer, color = MaterialTheme.colorScheme.errorContainer,
@@ -127,7 +128,7 @@ private fun AccessBanner(onOpen: () -> Unit) {
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
Button(onClick = onOpen) { Text("Open") } GsButton(onClick = onOpen) { Text("Open") }
} }
} }
} }
@@ -160,14 +161,17 @@ private fun NotificationRow(n: CarNotification, onReply: (CarNotification) -> Un
} }
when { when {
n.replied -> Icon(Icons.Filled.Check, contentDescription = "Replied", tint = MaterialTheme.colorScheme.primary) n.replied -> Icon(Icons.Filled.Check, contentDescription = "Replied", tint = MaterialTheme.colorScheme.primary)
n.reply != null -> FilledTonalButton(onClick = { onReply(n) }) { Text("Reply") } n.reply != null -> GsButton(onClick = { VoiceReply.reply(n) }, tone = GsTone.Tonal) {
ReplyGlyph(Modifier.size(20.dp))
Text("Reply", Modifier.padding(start = 8.dp))
}
} }
} }
} }
} }
@Composable @Composable
private fun NotificationIcon(n: CarNotification, size: androidx.compose.ui.unit.Dp = 44.dp) { internal fun NotificationIcon(n: CarNotification, size: androidx.compose.ui.unit.Dp = 44.dp) {
val icon = n.icon val icon = n.icon
if (icon != null) { if (icon != null) {
Image(icon.asImageBitmap(), contentDescription = null, modifier = Modifier.size(size).clip(RoundedCornerShape(10.dp))) Image(icon.asImageBitmap(), contentDescription = null, modifier = Modifier.size(size).clip(RoundedCornerShape(10.dp)))
@@ -194,7 +198,7 @@ private fun ReplyPane(n: CarNotification, onDone: () -> Unit) {
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
IconButton(onClick = onDone) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") } GsIconButton(Icons.AutoMirrored.Filled.ArrowBack, "Back", onDone)
Surface( Surface(
shape = RoundedCornerShape(24.dp), shape = RoundedCornerShape(24.dp),
color = MaterialTheme.colorScheme.surfaceContainerHighest, color = MaterialTheme.colorScheme.surfaceContainerHighest,
@@ -220,58 +224,9 @@ private fun ReplyPane(n: CarNotification, onDone: () -> Unit) {
Text(n.text.ifBlank { n.title }, style = MaterialTheme.typography.bodyLarge, maxLines = 3, overflow = TextOverflow.Ellipsis) Text(n.text.ifBlank { n.title }, style = MaterialTheme.typography.bodyLarge, maxLines = 3, overflow = TextOverflow.Ellipsis)
} }
CarKeyboard(text, onTextChange = { text = it }, onSubmit = ::send, submitImage = Icons.AutoMirrored.Filled.Send) CarKeyboard(
text, onTextChange = { text = it }, onSubmit = ::send,
submitImage = Icons.AutoMirrored.Filled.Send, onDismiss = onDone,
)
} }
} }
/**
* The popup shown over whatever the car is displaying. It times itself out, and tapping it opens
* the history - or straight into a reply, when the app allows one.
*/
@Composable
fun NotificationPopup(modifier: Modifier = Modifier) {
val popup by CarNotifications.popup.collectAsState()
val navigator = LocalCarNavigator.current
val n = popup ?: return
LaunchedEffect(n.id) {
delay(POPUP_MS)
CarNotifications.dismissPopup(n.id)
}
Surface(
shape = RoundedCornerShape(20.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
contentColor = MaterialTheme.colorScheme.onSurface,
shadowElevation = 8.dp,
modifier = modifier
.padding(top = 8.dp)
.fillMaxWidth(0.7f)
.clickable { navigator.notifications() },
) {
Row(
Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
NotificationIcon(n)
Column(Modifier.weight(1f)) {
Text(n.appLabel, style = MaterialTheme.typography.labelMedium, maxLines = 1)
if (n.title.isNotBlank()) {
Text(n.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
if (n.text.isNotBlank()) {
Text(n.text, style = MaterialTheme.typography.bodyMedium, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
}
if (n.reply != null) {
FilledTonalButton(onClick = { navigator.notifications(replyTo = n.key) }) { Text("Reply") }
}
IconButton(onClick = { CarNotifications.dismissPopup(n.id) }) {
Icon(Icons.Filled.Close, contentDescription = "Dismiss")
}
}
}
}
private const val POPUP_MS = 8_000L
@@ -1,5 +1,6 @@
package app.seb3thehacker.gearslip.car package app.seb3thehacker.gearslip.car
import app.seb3thehacker.gearslip.car.theme.*
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -14,7 +15,6 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.Button
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
@@ -96,19 +96,18 @@ private fun TouchRelayWarning() {
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
Button(onClick = { PhoneApps.openAccessibilitySettings(context) }) { Text("Open") } GsButton(onClick = { PhoneApps.openAccessibilitySettings(context) }) { Text("Open") }
} }
} }
} }
@Composable @Composable
private fun PhoneAppTile(app: PhoneApp, onClick: () -> Unit) { private fun PhoneAppTile(app: PhoneApp, onClick: () -> Unit) {
Surface( GsIconBox(
onClick = onClick, onClick = onClick,
modifier = Modifier.height(120.dp), modifier = Modifier.height(120.dp),
colors = GsColors(MaterialTheme.colorScheme.surfaceVariant, MaterialTheme.colorScheme.onSurfaceVariant),
shape = MaterialTheme.shapes.extraLarge, shape = MaterialTheme.shapes.extraLarge,
color = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
) { ) {
Column( Column(
Modifier.fillMaxSize().padding(8.dp), Modifier.fillMaxSize().padding(8.dp),
@@ -1,5 +1,6 @@
package app.seb3thehacker.gearslip.car package app.seb3thehacker.gearslip.car
import app.seb3thehacker.gearslip.car.theme.*
import android.Manifest import android.Manifest
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
@@ -20,17 +21,14 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Call import androidx.compose.material.icons.filled.Call
import androidx.compose.material3.Button
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -50,14 +48,15 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
/** A phone contact with at least one number, as read off the device's contacts. */ /** A phone contact with at least one number, as read off the device's contacts. */
private data class Contact(val name: String, val number: String) internal data class Contact(val name: String, val number: String)
private val CALL_PERMISSIONS = arrayOf(Manifest.permission.CALL_PHONE, Manifest.permission.READ_CONTACTS) private val CALL_PERMISSIONS = arrayOf(Manifest.permission.CALL_PHONE, Manifest.permission.READ_CONTACTS)
private fun hasCallPermissions(context: Context): Boolean = /** Also used by [AssistantCommands] for "call <name>". */
internal fun hasCallPermissions(context: Context): Boolean =
CALL_PERMISSIONS.all { context.checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED } CALL_PERMISSIONS.all { context.checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED }
private fun loadContacts(context: Context): List<Contact> { internal fun loadContacts(context: Context): List<Contact> {
if (context.checkSelfPermission(Manifest.permission.READ_CONTACTS) != PackageManager.PERMISSION_GRANTED) return emptyList() if (context.checkSelfPermission(Manifest.permission.READ_CONTACTS) != PackageManager.PERMISSION_GRANTED) return emptyList()
val out = mutableListOf<Contact>() val out = mutableListOf<Contact>()
val projection = arrayOf( val projection = arrayOf(
@@ -80,7 +79,7 @@ private fun loadContacts(context: Context): List<Contact> {
return out.distinctBy { it.number } return out.distinctBy { it.number }
} }
private fun placeCall(context: Context, number: String) { internal fun placeCall(context: Context, number: String) {
if (number.isBlank()) return if (number.isBlank()) return
if (context.checkSelfPermission(Manifest.permission.CALL_PHONE) != PackageManager.PERMISSION_GRANTED) return if (context.checkSelfPermission(Manifest.permission.CALL_PHONE) != PackageManager.PERMISSION_GRANTED) return
runCatching { runCatching {
@@ -101,23 +100,27 @@ fun PhoneDialerScreen() {
var tab by remember { mutableStateOf(TAB_CONTACTS) } var tab by remember { mutableStateOf(TAB_CONTACTS) }
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
// The tabs share the title's row, so the list and keypad get the height back.
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text( Text(
"Phone", "Phone",
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
modifier = Modifier.padding(vertical = 4.dp), modifier = Modifier.weight(1f),
) )
if (hasPermissions) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
DialerTab("Contacts", tab == TAB_CONTACTS, Modifier.width(140.dp)) { tab = TAB_CONTACTS }
DialerTab("Keypad", tab == TAB_KEYPAD, Modifier.width(140.dp)) { tab = TAB_KEYPAD }
}
}
}
if (!hasPermissions) { if (!hasPermissions) {
MissingPermission(context) MissingPermission(context)
return@Column return@Column
} }
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
DialerTab("Contacts", tab == TAB_CONTACTS, Modifier.weight(1f)) { tab = TAB_CONTACTS }
DialerTab("Keypad", tab == TAB_KEYPAD, Modifier.weight(1f)) { tab = TAB_KEYPAD }
}
Box(Modifier.weight(1f).fillMaxWidth()) { Box(Modifier.weight(1f).fillMaxWidth()) {
if (tab == TAB_CONTACTS) ContactsList(context) else Keypad(context) if (tab == TAB_CONTACTS) ContactsList(context) else Keypad(context)
} }
@@ -133,7 +136,7 @@ private fun MissingPermission(context: Context) {
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
FilledTonalButton(onClick = { GsButton(tone = GsTone.Tonal, onClick = {
context.startActivity( context.startActivity(
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", context.packageName, null)), Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", context.packageName, null)),
) )
@@ -143,18 +146,17 @@ private fun MissingPermission(context: Context) {
@Composable @Composable
private fun DialerTab(label: String, selected: Boolean, modifier: Modifier, onClick: () -> Unit) { private fun DialerTab(label: String, selected: Boolean, modifier: Modifier, onClick: () -> Unit) {
Surface( GsIconBox(
onClick = onClick, onClick = onClick,
modifier = modifier.height(52.dp), modifier = modifier.height(44.dp),
colors = if (selected) GsColors(MaterialTheme.colorScheme.primaryContainer, MaterialTheme.colorScheme.onPrimaryContainer)
else GsColors(MaterialTheme.colorScheme.surfaceContainerHigh, MaterialTheme.colorScheme.onSurface),
shape = MaterialTheme.shapes.large, shape = MaterialTheme.shapes.large,
color = if (selected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHigh, latched = selected,
contentColor = if (selected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface,
) { ) {
Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) {
Text(label, style = MaterialTheme.typography.titleMedium, fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal) Text(label, style = MaterialTheme.typography.titleMedium, fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal)
} }
} }
}
/** /**
* A 3-column digit grid beside a fourth column holding backspace and call, stacked - four equal * A 3-column digit grid beside a fourth column holding backspace and call, stacked - four equal
@@ -192,19 +194,17 @@ private fun Keypad(context: Context) {
} }
} }
Column(Modifier.weight(1f).fillMaxHeight(), verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(Modifier.weight(1f).fillMaxHeight(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
FilledTonalButton( GsButton(
onClick = { if (number.isNotEmpty()) number = number.dropLast(1) }, onClick = { if (number.isNotEmpty()) number = number.dropLast(1) },
tone = GsTone.Tonal,
enabled = number.isNotEmpty(), enabled = number.isNotEmpty(),
modifier = Modifier.weight(1f).fillMaxWidth(), modifier = Modifier.weight(1f).fillMaxWidth(),
) { Text("⌫") } ) { Text("⌫", style = MaterialTheme.typography.titleLarge) }
FilledIconButton( GsIconBox(
onClick = { placeCall(context, number) }, onClick = { placeCall(context, number) },
colors = gsColors(GsTone.Primary),
enabled = number.isNotEmpty(), enabled = number.isNotEmpty(),
modifier = Modifier.weight(1f).fillMaxWidth(), modifier = Modifier.weight(1f).fillMaxWidth(),
colors = androidx.compose.material3.IconButtonDefaults.filledIconButtonColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary,
),
) { Icon(Icons.Filled.Call, "Call") } ) { Icon(Icons.Filled.Call, "Call") }
} }
} }
@@ -213,17 +213,15 @@ private fun Keypad(context: Context) {
@Composable @Composable
private fun KeypadButton(digit: String, modifier: Modifier, onClick: () -> Unit) { private fun KeypadButton(digit: String, modifier: Modifier, onClick: () -> Unit) {
Surface( GsIconBox(
onClick = onClick, onClick,
modifier = modifier, modifier,
colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHigh, MaterialTheme.colorScheme.onSurface),
shape = MaterialTheme.shapes.large, shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surfaceContainerHigh,
) { ) {
Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) {
Text(digit, style = MaterialTheme.typography.titleLarge) Text(digit, style = MaterialTheme.typography.titleLarge)
} }
} }
}
@Composable @Composable
private fun ContactsList(context: Context) { private fun ContactsList(context: Context) {
@@ -266,7 +264,12 @@ private fun ContactsList(context: Context) {
} }
} }
if (searching) { if (searching) {
CarKeyboard(query, onTextChange = { query = it }, onSubmit = { searching = false }) CarKeyboard(
query,
onTextChange = { query = it },
onSubmit = { searching = false },
onDismiss = { searching = false },
)
} }
} }
@@ -309,10 +312,10 @@ private fun ConfirmCallOverlay(contact: Contact, onConfirm: () -> Unit, onDismis
Text("Call ${contact.name}?", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) Text("Call ${contact.name}?", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
Text(contact.number, style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurfaceVariant) Text(contact.number, style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(16.dp)) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(16.dp)) {
OutlinedButton(onClick = onDismiss, modifier = Modifier.weight(1f).height(56.dp)) { GsButton(onClick = onDismiss, tone = GsTone.Outline, modifier = Modifier.weight(1f).height(56.dp)) {
Text("Cancel", style = MaterialTheme.typography.titleMedium) Text("Cancel", style = MaterialTheme.typography.titleMedium)
} }
Button(onClick = onConfirm, modifier = Modifier.weight(1f).height(56.dp)) { GsButton(onClick = onConfirm, modifier = Modifier.weight(1f).height(56.dp)) {
Text("Call", style = MaterialTheme.typography.titleMedium) Text("Call", style = MaterialTheme.typography.titleMedium)
} }
} }
@@ -0,0 +1,236 @@
package app.seb3thehacker.gearslip.car
import android.media.session.PlaybackState
import android.support.v4.media.session.PlaybackStateCompat
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.FavoriteBorder
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import app.seb3thehacker.gearslip.media.ArtColor
import app.seb3thehacker.gearslip.media.MediaArt
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.core.graphics.drawable.toBitmap
import app.seb3thehacker.gearslip.car.theme.GsIconBox
import app.seb3thehacker.gearslip.car.theme.GsIconButton
import app.seb3thehacker.gearslip.car.theme.GsSeekBar
import app.seb3thehacker.gearslip.car.theme.GsTone
import app.seb3thehacker.gearslip.car.theme.gsColors
import app.seb3thehacker.gearslip.media.CarMedia
import app.seb3thehacker.gearslip.media.CustomAction
import app.seb3thehacker.gearslip.media.NowPlaying
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
/*
* The pieces every player on the car screen is built from - the media screen, the column beside
* the map - so a button looks and behaves the same wherever it turns up.
*/
/** The seek bar, with elapsed and total time under it when [showTimes]. */
@Composable
internal fun PlayerProgress(now: NowPlaying, media: CarMedia, modifier: Modifier = Modifier, showTimes: Boolean = true) {
if (now.durationMs <= 0) return
var position by remember { mutableLongStateOf(now.currentPosition()) }
LaunchedEffect(now) {
while (true) {
position = now.currentPosition()
if (!now.playing) break
delay(500)
}
}
Column(modifier) {
GsSeekBar(
fraction = position.toFloat() / now.durationMs,
onSeek = { media.seek((it * now.durationMs).toLong()) },
enabled = now.canDo(PlaybackState.ACTION_SEEK_TO),
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
)
if (showTimes) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(formatDuration(position), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(formatDuration(now.durationMs), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
}
/** "1:05" for under a minute past the hour, "1:01:05" once an hour is involved. */
internal fun formatDuration(ms: Long): String {
val totalSeconds = (ms / 1000).coerceAtLeast(0)
val hours = totalSeconds / 3600
val minutes = (totalSeconds % 3600) / 60
val seconds = totalSeconds % 60
return if (hours > 0) "%d:%02d:%02d".format(hours, minutes, seconds) else "%d:%02d".format(minutes, seconds)
}
/** Previous, play/pause, next. [skip] sizes the outer two; play is a step bigger. */
@Composable
internal fun TransportRow(now: NowPlaying, media: CarMedia, skip: Dp, modifier: Modifier = Modifier) {
val play = skip * 1.25f
Row(modifier, horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.CenterVertically) {
GsIconButton(
MediaIcons.Previous, "Previous", media::previous,
enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_PREVIOUS), size = skip, iconSize = skip * 0.58f, shape = CircleShape,
)
GsIconButton(
if (now.playing) MediaIcons.Pause else Icons.Filled.PlayArrow,
if (now.playing) "Pause" else "Play",
media::togglePlay,
tone = GsTone.Primary, size = play, iconSize = play * 0.58f, shape = CircleShape,
)
GsIconButton(
MediaIcons.Next, "Next", media::next,
enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_NEXT), size = skip, iconSize = skip * 0.58f, shape = CircleShape,
)
}
}
/**
* Like, shuffle and repeat, then up to [maxOther] of the app's other buttons, all icon-only. One
* the app doesn't support stays on screen greyed out, so the row never shifts between apps.
* [trailing] adds buttons of the caller's own at the end (the side column's lyrics button).
*/
@Composable
internal fun PlayModeRow(
now: NowPlaying,
media: CarMedia,
size: Dp,
modifier: Modifier = Modifier,
maxOther: Int = 0,
trailing: @Composable RowScope.() -> Unit = {},
) {
val modes by media.modes.collectAsState()
val liked = now.liked
val shuffleKnown = modes.shuffle != PlaybackStateCompat.SHUFFLE_MODE_INVALID
val repeatKnown = modes.repeat != PlaybackStateCompat.REPEAT_MODE_INVALID
val shuffleOn = shuffleKnown && modes.shuffle != PlaybackStateCompat.SHUFFLE_MODE_NONE
val repeatOn = repeatKnown && modes.repeat != PlaybackStateCompat.REPEAT_MODE_NONE
val icon = size * 0.52f
Row(modifier, horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.CenterVertically) {
GsIconButton(
if (liked == true) Icons.Filled.Favorite else Icons.Filled.FavoriteBorder,
if (liked == true) "Unlike" else "Like",
media::toggleLike,
enabled = liked != null,
tone = if (liked == true) GsTone.Primary else GsTone.Neutral,
size = size, iconSize = icon,
)
GsIconButton(
MediaIcons.Shuffle, if (shuffleOn) "Shuffle off" else "Shuffle on",
media::toggleShuffle,
enabled = shuffleKnown || now.shuffleAction != null,
tone = if (shuffleOn) GsTone.Primary else GsTone.Neutral,
size = size, iconSize = icon,
)
GsIconButton(
if (modes.repeat == PlaybackStateCompat.REPEAT_MODE_ONE) MediaIcons.RepeatOne else MediaIcons.Repeat,
"Repeat",
media::cycleRepeat,
enabled = repeatKnown || now.repeatAction != null,
tone = if (repeatOn) GsTone.Primary else GsTone.Neutral,
size = size, iconSize = icon,
)
now.otherActions.take(maxOther).forEach { CustomActionButton(it, media, size) }
trailing()
}
}
/**
* One of the app's own buttons, drawn with the app's own icon - it lives in the app's package,
* not Gearslip's, so it's loaded from there. Its name stands in if the icon won't load.
*/
@Composable
private fun CustomActionButton(action: CustomAction, media: CarMedia, size: Dp) {
val icon = rememberActionIcon(action)
GsIconBox(
onClick = { media.custom(action) },
modifier = Modifier.size(size),
colors = gsColors(GsTone.Neutral),
) {
if (icon != null) {
Icon(icon, action.name, Modifier.size(size * 0.52f))
} else {
Text(action.name, style = MaterialTheme.typography.labelSmall, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
}
}
@Composable
private fun rememberActionIcon(action: CustomAction): ImageBitmap? {
val context = LocalContext.current
val app by CarServices.mediaApp.collectAsState()
val pkg = app?.component?.packageName
val icon by produceState<ImageBitmap?>(null, pkg, action.icon) {
value = if (pkg == null || action.icon == 0) null else withContext(Dispatchers.IO) {
runCatching {
context.packageManager.getResourcesForApplication(pkg)
.getDrawable(action.icon, null).toBitmap(96, 96).asImageBitmap()
}.getOrNull()
}
}
return icon
}
/** The track's art, from the session's bitmap or, failing that, its art URI. */
@Composable
internal fun rememberArt(now: NowPlaying): android.graphics.Bitmap? {
val context = LocalContext.current
val art by produceState(now.art, now.art, now.artUri) { value = now.art ?: MediaArt.load(context, now.artUri) }
return art
}
/**
* A background taken from the album art's main colour, in place of [fallback]. Only the hue and a
* capped amount of its saturation come from the art; the lightness stays close to the theme's
* own ([darkLightness] or [lightLightness]), so text drawn in the theme's colours reads the same
* on any cover. Art with no real colour in it keeps [fallback]. Fades between songs rather than
* jumping.
*/
@Composable
internal fun rememberArtColor(
art: android.graphics.Bitmap?,
fallback: Color,
darkLightness: Float = 0.2f,
lightLightness: Float = 0.86f,
): Color {
val dark = fallback.luminance() < 0.5f
val tint by produceState<ArtColor.Tint?>(null, art) {
value = art?.let { withContext(Dispatchers.Default) { runCatching { ArtColor.of(it) }.getOrNull() } }
}
val target = tint?.let {
if (dark) Color.hsl(it.hue, it.saturation.coerceIn(0.3f, 0.5f), darkLightness)
else Color.hsl(it.hue, it.saturation.coerceIn(0.35f, 0.55f), lightLightness)
} ?: fallback
return animateColorAsState(target, tween(600), label = "art colour").value
}
@@ -1,9 +1,9 @@
package app.seb3thehacker.gearslip.car package app.seb3thehacker.gearslip.car
import app.seb3thehacker.gearslip.car.theme.*
import android.graphics.Rect import android.graphics.Rect
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -23,7 +23,6 @@ import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.material.icons.filled.KeyboardArrowUp
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.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -274,26 +273,20 @@ private fun TravelEstimateBar(estimate: TravelEstimate, modifier: Modifier = Mod
Modifier.padding(start = 8.dp, end = 18.dp, top = 10.dp, bottom = 10.dp), Modifier.padding(start = 8.dp, end = 18.dp, top = 10.dp, bottom = 10.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Box( // Not a disconnect: the connection stays up and the app decides its own next screen.
Modifier // Ending navigation is its own call, separate from a back press - a back press only
.size(40.dp) // pops the on-screen card, leaving the app still believing it is navigating (so a
.clip(CircleShape) // freshly picked destination silently does nothing); this is the call that actually
.background(MaterialTheme.colorScheme.surfaceContainerHighest) // clears that flag on the app's side.
// Not a disconnect: the connection stays up and the app decides its own next GsIconButton(
// screen. Ending navigation is its own call, separate from a back press - a
// back press only pops the on-screen card, leaving the app still believing it
// is navigating (so a freshly picked destination silently does nothing); this
// is the call that actually clears that flag on the app's side.
.clickable { CarServices.nav.stopNavigating() },
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Filled.Close, Icons.Filled.Close,
contentDescription = "Stop navigating", "Stop navigating",
tint = MaterialTheme.colorScheme.onSurface, { CarServices.nav.stopNavigating() },
modifier = Modifier.size(20.dp), colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.colorScheme.onSurface),
size = 40.dp,
iconSize = 20.dp,
shape = CircleShape,
) )
}
Spacer(Modifier.width(10.dp)) Spacer(Modifier.width(10.dp))
Column { Column {
if (time.isNotEmpty()) { if (time.isNotEmpty()) {
@@ -1,9 +1,12 @@
package app.seb3thehacker.gearslip.car package app.seb3thehacker.gearslip.car
import androidx.compose.foundation.clickable 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.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@@ -12,6 +15,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
@@ -20,6 +24,7 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -30,6 +35,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.car.app.model.GridItem
import androidx.car.app.model.GridTemplate import androidx.car.app.model.GridTemplate
import androidx.car.app.model.Item import androidx.car.app.model.Item
import androidx.car.app.model.ItemList import androidx.car.app.model.ItemList
@@ -67,7 +73,13 @@ import app.seb3thehacker.gearslip.host.textChanged
* the same function either way - the only difference is how much room they get. * the same function either way - the only difference is how much room they get.
*/ */
@Composable @Composable
fun ContentTemplate(template: Template?, modifier: Modifier = Modifier, autoStartPane: Boolean = false) { fun ContentTemplate(
template: Template?,
modifier: Modifier = Modifier,
autoStartPane: Boolean = false,
ownActionsElsewhere: Boolean = false,
tabId: String = "",
) {
when (template) { when (template) {
null -> Unit null -> Unit
is ListTemplate -> ListContent(template, modifier) is ListTemplate -> ListContent(template, modifier)
@@ -78,7 +90,7 @@ fun ContentTemplate(template: Template?, modifier: Modifier = Modifier, autoStar
is SearchTemplate -> SearchContent(template, modifier) is SearchTemplate -> SearchContent(template, modifier)
is SignInTemplate -> SignInContent(template, modifier) is SignInTemplate -> SignInContent(template, modifier)
is TabTemplate -> TabContent(template, modifier) is TabTemplate -> TabContent(template, modifier)
is SectionedItemTemplate -> SectionedContent(template, modifier) is SectionedItemTemplate -> SectionedContent(template, modifier, showActionsInHeader = !ownActionsElsewhere, tabId = tabId)
is MediaPlaybackTemplate -> MediaPlaybackContent(template, modifier) is MediaPlaybackTemplate -> MediaPlaybackContent(template, modifier)
else -> UnsupportedChrome(template, modifier) else -> UnsupportedChrome(template, modifier)
} }
@@ -236,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())
} }
@@ -256,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) { private 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,
)
}
}
} }
} }
@@ -334,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(
@@ -374,7 +431,13 @@ private fun Callout(value: String, modifier: Modifier) {
@Composable @Composable
private fun TabContent(template: TabTemplate, modifier: Modifier) { private fun TabContent(template: TabTemplate, modifier: Modifier) {
val active = template.activeTabContentId val active = template.activeTabContentId
ContentSurface(modifier) { val innerTemplate = template.tabContents?.template
// A sectioned tab's own actions (search, a Liked Songs shortcut) belong on the same line as
// the tabs themselves - a real Android Auto host's header row, not a second near-empty row
// underneath it that SectionedContent would otherwise draw just to hold them.
val trailingActions = (innerTemplate as? SectionedItemTemplate)?.actions.orEmpty().filter { it.isDrawable() }
Box(modifier) {
ContentSurface(Modifier.fillMaxSize()) {
Row( Row(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -386,15 +449,12 @@ private fun TabContent(template: TabTemplate, modifier: Modifier) {
template.headerAction?.let { ActionButton(it) } template.headerAction?.let { ActionButton(it) }
template.tabs.forEach { tab -> template.tabs.forEach { tab ->
val selected = tab.contentId == active val selected = tab.contentId == active
Surface( GsIconBox(
color = if (selected) MaterialTheme.colorScheme.primaryContainer onClick = { template.tabCallbackDelegate.tabSelected(tab.contentId) },
else MaterialTheme.colorScheme.surfaceContainerHighest, colors = if (selected) GsColors(MaterialTheme.colorScheme.primaryContainer, MaterialTheme.colorScheme.onPrimaryContainer)
contentColor = if (selected) MaterialTheme.colorScheme.onPrimaryContainer else GsColors(MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.colorScheme.onSurface),
else MaterialTheme.colorScheme.onSurface,
shape = RoundedCornerShape(20.dp), shape = RoundedCornerShape(20.dp),
modifier = Modifier.clickable { latched = selected,
template.tabCallbackDelegate.tabSelected(tab.contentId)
},
) { ) {
Row( Row(
Modifier.padding(horizontal = 12.dp, vertical = 8.dp), Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
@@ -410,7 +470,23 @@ private fun TabContent(template: TabTemplate, modifier: Modifier) {
} }
} }
if (template.isLoading) Loading() if (template.isLoading) Loading()
else ContentTemplate(template.tabContents?.template, Modifier.weight(1f)) else ContentTemplate(
innerTemplate,
Modifier.weight(1f),
ownActionsElsewhere = trailingActions.isNotEmpty(),
tabId = active.orEmpty(),
)
}
// Bottom-left, not on the tab row: a driver's hand rests near the bottom of the screen,
// and the tab row is already busy with the tabs themselves.
if (trailingActions.isNotEmpty()) {
Row(
Modifier.align(Alignment.BottomStart).padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
trailingActions.forEach { ActionButton(it) }
}
}
} }
} }
@@ -419,46 +495,66 @@ private fun Modifier.verticalScrollNone(): Modifier = this
// --- sectioned items -------------------------------------------------------------------------- // --- sectioned items --------------------------------------------------------------------------
/**
* A section's items ([SectionRows]) are cached for the life of the process, keyed off the
* foreground app and, when nested under a tab, the tab - so flipping back to a shelf already
* seen this drive is instant instead of re-asking the app over the binder. Bounded and in
* memory only, so it costs nothing on disk and disappears with the process.
*/
private val sectionCache = android.util.LruCache<String, List<Item>>(64)
/** /**
* Sections fetch their items across the binder on demand rather than carrying them, so each one * Sections fetch their items across the binder on demand rather than carrying them, so each one
* is asked for its full range once and the answer is held while the section is on screen. * is asked for its full range once and the answer is held while the section is on screen (and,
* via [sectionCache], for the rest of the drive).
*/ */
@Composable @Composable
private fun SectionedContent(template: SectionedItemTemplate, modifier: Modifier) { private fun SectionedContent(template: SectionedItemTemplate, modifier: Modifier, showActionsInHeader: Boolean = true, tabId: String = "") {
val appPackage = CarServices.nav.appPackage.orEmpty()
ContentSurface(modifier) { ContentSurface(modifier) {
// The app's own actions (Spotify's search and Liked Songs shortcut, for two) come from
// template.actions, not the header - but a bottom-of-content row is wrong for a driver
// reaching for it. Normally the header's own end-actions slot is where a real Android
// Auto host puts these; nested under a TabTemplate, though, TabContent already drew them
// on the tab row itself, so showActionsInHeader is false and this header stays title-only
// (and usually collapses to nothing, since a tab's own content carries no separate title).
HeaderBar( HeaderBar(
headerTitle(template.header, ""), headerTitle(template.header, ""),
template.header?.startHeaderAction, template.header?.startHeaderAction,
template.header?.endHeaderActions.orEmpty(), template.header?.endHeaderActions.orEmpty() + if (showActionsInHeader) template.actions.orEmpty() else emptyList(),
) )
if (template.isLoading) { if (template.isLoading) {
Loading() Loading()
return@ContentSurface return@ContentSurface
} }
Column(Modifier.weight(1f).verticalScroll(rememberScrollState())) { Column(Modifier.weight(1f).verticalScroll(rememberScrollState())) {
template.sections.forEach { section -> template.sections.forEachIndexed { index, section ->
PaneTitle(section.title.text()) PaneTitle(section.title.text())
SectionRows(section) SectionRows(section, "$appPackage/$tabId/$index/${section.title.text()}")
} }
} }
ActionRow(template.actions.orEmpty(), Modifier.padding(12.dp))
} }
} }
@Composable @Composable
private fun SectionRows(section: Section<*>) { private fun SectionRows(section: Section<*>, cacheKey: String) {
var items by remember(section) { mutableStateOf<List<Item>>(emptyList()) } var items by remember(section) { mutableStateOf(sectionCache.get(cacheKey).orEmpty()) }
LaunchedEffect(section) { LaunchedEffect(section) {
if (sectionCache.get(cacheKey) != null) return@LaunchedEffect
val delegate = section.itemsDelegate val delegate = section.itemsDelegate
val size = runCatching { delegate.size }.getOrDefault(0) val sizeResult = runCatching { delegate.size }
val size = sizeResult.getOrDefault(0)
sizeResult.onFailure { GearslipLog.w("host: section.itemsDelegate.size threw: ${it.javaClass.simpleName}: ${it.message}") }
if (size <= 0) return@LaunchedEffect if (size <= 0) return@LaunchedEffect
runCatching { runCatching {
delegate.requestItemRange(0, size - 1, object : OnDoneCallback { delegate.requestItemRange(0, size - 1, object : OnDoneCallback {
override fun onSuccess(response: Bundleable?) { override fun onSuccess(response: Bundleable?) {
val value = response?.let { runCatching { it.get() }.getOrNull() } val value = response?.let { runCatching { it.get() }.getOrNull() }
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
items = (value as? List<Item>).orEmpty() val fetched = (value as? List<Item>).orEmpty()
items = fetched
sectionCache.put(cacheKey, fetched)
} }
override fun onFailure(response: Bundleable) = override fun onFailure(response: Bundleable) =
@@ -472,27 +568,54 @@ private fun SectionRows(section: Section<*>) {
if (message.isNotEmpty()) Text(message, Modifier.padding(14.dp), style = ChromeType.body) if (message.isNotEmpty()) Text(message, Modifier.padding(14.dp), style = ChromeType.body)
return return
} }
items.filterIsInstance<CarRow>().forEach { RowItem(it) } // A section's items are homogeneous in practice - one kind or the other - but nothing in
// the API guarantees that, so both are handled rather than assuming whichever the app
// happened to send first.
val rows = items.filterIsInstance<CarRow>()
val tiles = items.filterIsInstance<GridItem>()
rows.forEach { RowItem(it) }
if (tiles.isNotEmpty()) {
// A plain scrollable Row, not LazyRow: the car screen renders into a VirtualDisplay
// Presentation rather than a normal window, and LazyRow's SubcomposeLayout measures to
// zero height there even with an explicit size - a handful of tiles per shelf makes the
// lost virtualization no real loss.
Row(
Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.padding(horizontal = 8.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
tiles.forEach { tile -> GridTile(tile, Modifier.width(180.dp)) }
}
}
} }
// --- media ---------------------------------------------------------------------------------- // --- media ----------------------------------------------------------------------------------
/** /**
* Media playback is drawn from the app's MediaSession rather than from the template, which * The template itself carries only a header - real Android Auto hosts draw this screen from the
* carries only a header. Gearslip has no media channel yet, so this says so plainly instead of * app's MediaSession instead, which is exactly what [CarServices.media] already does for the
* showing an empty player. * media-app launcher and the bottom bar. Reusing [NowPlayingPage] here rather than a "not
* supported" placeholder means an app whose own now-playing template Gearslip lands on (like
* Spotify's "Liked Songs" screen) still gets working transport controls.
*/ */
@Composable @Composable
private fun MediaPlaybackContent(template: MediaPlaybackTemplate, modifier: Modifier) { private fun MediaPlaybackContent(template: MediaPlaybackTemplate, modifier: Modifier) {
val media = CarServices.media
val now by media.now.collectAsState()
ContentSurface(modifier) { ContentSurface(modifier) {
HeaderBar( HeaderBar(
headerTitle(template.header, ""), headerTitle(template.header, ""),
template.header?.startHeaderAction, template.header?.startHeaderAction,
template.header?.endHeaderActions.orEmpty(), template.header?.endHeaderActions.orEmpty(),
) )
if (now.title.isNotEmpty()) {
NowPlayingPage(now, media, Modifier.weight(1f).fillMaxWidth())
} else {
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) { Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
Text( Text(
"Playback controls come from the app's media session,\nwhich Gearslip doesn't carry yet.", "Nothing playing yet.",
style = ChromeType.body, style = ChromeType.body,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = androidx.compose.ui.text.style.TextAlign.Center, textAlign = androidx.compose.ui.text.style.TextAlign.Center,
@@ -500,6 +623,7 @@ private fun MediaPlaybackContent(template: MediaPlaybackTemplate, modifier: Modi
} }
} }
} }
}
// --- shared --------------------------------------------------------------------------------- // --- shared ---------------------------------------------------------------------------------
@@ -1,9 +1,14 @@
package app.seb3thehacker.gearslip.car package app.seb3thehacker.gearslip.car
import app.seb3thehacker.gearslip.car.theme.*
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalContentColor
import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -296,15 +301,17 @@ internal fun ActionButton(action: Action, large: Boolean = false, modifier: Modi
} }
val hasGlyph = action.icon != null || action.type != Action.TYPE_CUSTOM val hasGlyph = action.icon != null || action.type != Action.TYPE_CUSTOM
Surface( GsIconBox(
color = background, onClick = {
contentColor = content,
shape = if (title.isEmpty()) androidx.compose.foundation.shape.CircleShape else RoundedCornerShape(if (large) 28.dp else 24.dp),
modifier = modifier.clickable(enabled = action.isEnabled) {
if (action.type == Action.TYPE_BACK) CarServices.nav.backPressed() if (action.type == Action.TYPE_BACK) CarServices.nav.backPressed()
else if (action.type == Action.TYPE_PAN) CarServices.nav.setPanMode(!panOn) else if (action.type == Action.TYPE_PAN) CarServices.nav.setPanMode(!panOn)
else action.onClickDelegate?.click("action") else action.onClickDelegate?.click("action")
}, },
modifier = modifier,
colors = GsColors(background, content),
enabled = action.isEnabled,
shape = if (title.isEmpty()) androidx.compose.foundation.shape.CircleShape else RoundedCornerShape(if (large) 28.dp else 24.dp),
latched = active,
) { ) {
if (large && title.isNotEmpty()) { if (large && title.isNotEmpty()) {
// The label sits dead centre on the button; the icon hangs off the left edge, so the // The label sits dead centre on the button; the icon hangs off the left edge, so the
@@ -364,13 +371,14 @@ internal fun AutoStartButton(action: Action, modifier: Modifier = Modifier) {
val content = if (custom) Color.White else MaterialTheme.colorScheme.onPrimary val content = if (custom) Color.White else MaterialTheme.colorScheme.onPrimary
val hasGlyph = action.icon != null val hasGlyph = action.icon != null
Surface( GsIconBox(
onClick = { fire() },
modifier = modifier.fillMaxWidth(),
// An unfilled track, solid, not the button's own colour dimmed with alpha - so the map // An unfilled track, solid, not the button's own colour dimmed with alpha - so the map
// behind never shows through however much of the five seconds is left. // behind never shows through however much of the five seconds is left.
color = MaterialTheme.colorScheme.surfaceContainerHighest, colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHighest, content),
contentColor = content, enabled = action.isEnabled,
shape = RoundedCornerShape(28.dp), shape = RoundedCornerShape(28.dp),
modifier = modifier.fillMaxWidth().clickable(enabled = action.isEnabled) { fire() },
) { ) {
androidx.compose.foundation.layout.Box { androidx.compose.foundation.layout.Box {
// The countdown itself: a plain fill sweeping left to right underneath the label. // The countdown itself: a plain fill sweeping left to right underneath the label.
@@ -424,9 +432,16 @@ private fun standardFor(action: Action): Int? = when (action.type) {
Action.TYPE_BACK -> CarIcon.TYPE_BACK Action.TYPE_BACK -> CarIcon.TYPE_BACK
Action.TYPE_PAN -> CarIcon.TYPE_PAN Action.TYPE_PAN -> CarIcon.TYPE_PAN
Action.TYPE_APP_ICON -> CarIcon.TYPE_APP_ICON Action.TYPE_APP_ICON -> CarIcon.TYPE_APP_ICON
ACTION_TYPE_NOW_PLAYING -> ACTION_TYPE_NOW_PLAYING
else -> null else -> null
} }
/**
* The media "now playing" standard action, newer than the Car App Library we build against, so it
* has no named constant here. Spotify puts it in its header with no icon of its own.
*/
private const val ACTION_TYPE_NOW_PLAYING = 65542
// --- icons ---------------------------------------------------------------------------------- // --- icons ----------------------------------------------------------------------------------
/** /**
@@ -437,7 +452,14 @@ private fun standardFor(action: Action): Int? = when (action.type) {
@Composable @Composable
internal fun CarGlyph(icon: CarIcon?, modifier: Modifier = Modifier, standard: Int? = null) { internal fun CarGlyph(icon: CarIcon?, modifier: Modifier = Modifier, standard: Int? = null) {
val context = LocalContext.current val context = LocalContext.current
val bitmap = icon.image(context) // Off the main thread and cached per icon: loadDrawable() is a Binder round trip into the
// app's own process for a content:// icon, plus a bitmap decode - done synchronously on the
// composition thread for every tile on screen, that's what made a shelf of ten covers feel
// like it was hanging rather than loading.
val bitmapState by produceState<ImageBitmap?>(null, icon) {
value = withContext(Dispatchers.IO) { icon.image(context) }
}
val bitmap = bitmapState
if (bitmap != null) { if (bitmap != null) {
// An icon that names a tint (DEFAULT included) expects the host to colour it for the // An icon that names a tint (DEFAULT included) expects the host to colour it for the
// surface it sits on; without this a white glyph vanishes on a light theme. // surface it sits on; without this a white glyph vanishes on a light theme.
@@ -458,6 +480,7 @@ internal fun CarGlyph(icon: CarIcon?, modifier: Modifier = Modifier, standard: I
CarIcon.TYPE_ALERT, CarIcon.TYPE_ERROR -> Icons.Filled.Warning CarIcon.TYPE_ALERT, CarIcon.TYPE_ERROR -> Icons.Filled.Warning
// No four-way pan glyph in material-icons-core; this is the closest honest stand-in. // No four-way pan glyph in material-icons-core; this is the closest honest stand-in.
CarIcon.TYPE_PAN -> Icons.Filled.Menu CarIcon.TYPE_PAN -> Icons.Filled.Menu
ACTION_TYPE_NOW_PLAYING -> MediaIcons.Equalizer
else -> null else -> null
} ?: return } ?: return
Icon(vector, contentDescription = null, modifier = modifier) Icon(vector, contentDescription = null, modifier = modifier)
@@ -500,7 +523,9 @@ internal fun ItemListColumn(
} }
} }
/** A grid of large tappable tiles - what apps use for a home screen beside the map. */ /** A grid of tappable tiles - what apps use for a home screen beside the map. Icons here are as
* often a small menu glyph (Organic Maps' Search, say) as real cover art, so the tile stays
* modest; [GridTile]'s own default is for the shelf case, where every tile is a photo. */
@Composable @Composable
internal fun GridItems(list: ItemList?, modifier: Modifier = Modifier) { internal fun GridItems(list: ItemList?, modifier: Modifier = Modifier) {
val items = list?.items?.filterIsInstance<GridItem>().orEmpty() val items = list?.items?.filterIsInstance<GridItem>().orEmpty()
@@ -512,14 +537,25 @@ internal fun GridItems(list: ItemList?, modifier: Modifier = Modifier) {
horizontalArrangement = Arrangement.spacedBy(6.dp), horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp),
) { ) {
items(items) { item -> items(items) { item -> GridTile(item, iconSize = 36.dp) }
}
}
/**
* One cover-art tile: an app's own icon over a title and optional subtitle. Shared by
* [GridItems] (a whole screen of tiles, [iconSize] scaled down there to suit a plain menu glyph)
* and a section's horizontal shelf, which carries the same [GridItem] type but scrolls sideways
* instead of wrapping into a grid and wants its full-size cover art.
*/
@Composable
internal fun GridTile(item: GridItem, modifier: Modifier = Modifier, iconSize: androidx.compose.ui.unit.Dp = 144.dp) {
Column( Column(
Modifier modifier
.clickable { item.onClickDelegate.click("grid item") } .clickable { item.onClickDelegate.click("grid item") }
.padding(vertical = 8.dp), .padding(vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
CarGlyph(item.image, Modifier.size(36.dp)) CarGlyph(item.image, Modifier.size(iconSize))
Spacer(Modifier.height(6.dp)) Spacer(Modifier.height(6.dp))
Text( Text(
item.title.text(), item.title.text(),
@@ -539,8 +575,6 @@ internal fun GridItems(list: ItemList?, modifier: Modifier = Modifier) {
} }
} }
} }
}
}
/** /**
* [autoStart] is for a pane floating over a map - a place or a route the app is proposing - where * [autoStart] is for a pane floating over a map - a place or a route the app is proposing - where
@@ -631,6 +665,7 @@ internal fun Color.luminanceIsDark(): Boolean =
(red * 0.299f + green * 0.587f + blue * 0.114f) < 0.5f (red * 0.299f + green * 0.587f + blue * 0.114f) < 0.5f
/** True for a glyph drawn entirely in white (any transparency): a mask, not a picture. */ /** True for a glyph drawn entirely in white (any transparency): a mask, not a picture. */
private fun androidx.compose.ui.graphics.ImageBitmap.isPlainWhite(): Boolean { private fun androidx.compose.ui.graphics.ImageBitmap.isPlainWhite(): Boolean {
val pixels = IntArray(width * height) val pixels = IntArray(width * height)
@@ -645,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
@@ -0,0 +1,69 @@
package app.seb3thehacker.gearslip.car
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
/**
* Everything the head unit's sensor channel is willing to send, live and unfiltered - see
* [CarSensors] for why the labels are a best guess and the raw field dump next to each one is
* the part to actually trust while confirming what this unit really sends.
*/
@Composable
fun VehicleDataScreen() {
val readings by CarSensors.readings.collectAsState()
Column(Modifier.fillMaxSize().padding(horizontal = 24.dp, vertical = 16.dp)) {
Text("Vehicle data", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
Text(
"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.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp, bottom = 16.dp),
)
if (readings.isEmpty()) {
Text(
"No sensor service advertised by this head unit, or not connected.",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
return@Column
}
LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(readings, key = { it.typeId }) { reading -> ReadingRow(reading) }
}
}
}
@Composable
private fun ReadingRow(reading: CarSensors.Reading) {
Surface(color = MaterialTheme.colorScheme.surfaceContainerHigh, shape = MaterialTheme.shapes.large) {
Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(reading.typeName, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
}
Text(
reading.raw,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@@ -0,0 +1,383 @@
package app.seb3thehacker.gearslip.car
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.os.SystemClock
import androidx.car.app.navigation.model.NavigationTemplate
import app.seb3thehacker.gearslip.GearslipLog
import app.seb3thehacker.gearslip.call.CallUiState
import app.seb3thehacker.gearslip.call.CarCalls
import app.seb3thehacker.gearslip.host.clockTime
import app.seb3thehacker.gearslip.host.remainingTime
import app.seb3thehacker.gearslip.notify.CarNotification
import app.seb3thehacker.gearslip.notify.CarNotifications
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* Messages by voice, the way Android Auto does it: a message is read aloud, "Do you want to
* reply?", a yes gets "Go ahead", the reply is read back, and it sends after a few seconds unless
* cancelled. Reply does the same; only once the message has just been read does it skip straight
* to "What's your reply?". While the reply counts down, saying "cancel" or "change it" stops it or
* starts it again. A quick reply sends a ready-made answer with no voice at all. Listening and speaking
* go through [CarAssistant], so this hears through the same microphone and recognizer.
*
* Every step runs on the main thread. Each run gets a new [run] number, and callbacks from an
* older run are dropped, so a cancel can never be undone by a late recognizer result.
*/
object VoiceReply {
enum class Phase {
IDLE,
/** Reading the message aloud. */
READING,
/** Read; the Reply button stays up for a moment in case the driver wants it. */
READ,
/** Asking "Do you want to reply?" and listening for a yes. */
OFFERING,
/** Saying "What's your reply?". */
ASKING,
LISTENING,
/**
* 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.
*/
CONFIRMING,
SENT,
ERROR,
}
data class State(
val phase: Phase = Phase.IDLE,
val target: CarNotification? = null,
val heard: String = "",
val error: String = "",
/**
* Uptime at which this step ends on its own (the card goes, or the reply sends), and how
* long the step was given, for the card's bar. 0 when nothing is timed.
*/
val until: Long = 0L,
val span: Long = 0L,
/** While a countdown is held, how much of its bar was left, so the bar stays put. */
val held: Float = 0f,
)
const val SEND_DELAY_MS = 5_000L
private const val LINGER_MS = 5_000L
private const val SENT_MS = 2_000L
private val _state = MutableStateFlow(State())
val state: StateFlow<State> = _state
private val main = Handler(Looper.getMainLooper())
private var appContext: Context? = null
private var run = 0
fun init(context: Context) { appContext = context.applicationContext }
val active: Boolean get() = _state.value.phase != Phase.IDLE
/** Reads [n]'s newest messages aloud. */
fun read(n: CarNotification) {
val id = begin(State(Phase.READING, n))
CarAssistant.say(spoken(n)) {
if (id != run) return@say
when {
n.reply == null -> finish()
// The microphone is silent during a call, so there's no point asking.
CarCalls.state.value is CallUiState.Active -> idleAfterRead(id)
else -> offer(id)
}
}
}
/**
* "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) {
_state.value = _state.value.copy(phase = Phase.OFFERING, heard = "", until = 0L, span = 0L, held = 0f)
CarAssistant.say("Do you want to reply?") {
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) cancel() }, until + OFFER_GRACE_MS)
CarAssistant.dictate(
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 ->
if (id != run) return@dictate
if (isYes(text)) {
_state.value = _state.value.copy(phase = Phase.ASKING, heard = "", until = 0L, span = 0L, held = 0f)
CarAssistant.say("Go ahead.") { if (id == run) listen(id) }
} else {
finish()
}
},
// No answer: the card has already offered Reply and the quick replies all along.
onError = { if (id == run) finish() },
)
}
}
private fun idleAfterRead(id: Int) {
_state.value = _state.value.copy(phase = Phase.READ, heard = "")
lingerThenIdle(id)
}
private fun isYes(text: String): Boolean {
val words = words(text)
if (words.any { it in NO_WORDS }) return false
return words.any { it in YES_WORDS }
}
/**
* Reads [n] aloud and asks whether to reply, or, when the driver has just heard it, asks for
* the reply straight away. Then listens, reads the reply back and sends it.
*/
fun reply(n: CarNotification, readFirst: Boolean = true) {
if (n.reply == null) {
val id = begin(State(Phase.ERROR, n, error = "${n.appLabel} doesn't allow replies from the car."))
lingerThenIdle(id)
return
}
// Android hands every app but the dialer a silent microphone during a call.
if (CarCalls.state.value is CallUiState.Active) {
val id = begin(State(Phase.ERROR, n, error = "Voice reply works once the call ends."))
lingerThenIdle(id)
return
}
if (!readFirst) {
val id = begin(State(Phase.ASKING, n))
ask(id)
return
}
// Read it, then "Do you want to reply?", the same as Read.
read(n)
}
private fun ask(id: Int) {
CarAssistant.say("What's your reply?") {
if (id == run) listen(id)
}
}
private fun listen(id: Int) {
_state.value = _state.value.copy(phase = Phase.LISTENING, heard = "")
CarAssistant.dictate(
onPartial = { text -> if (id == run) _state.value = _state.value.copy(heard = text) },
onResult = { text -> if (id == run) confirm(id, text) },
onError = { message ->
if (id != run) return@dictate
_state.value = _state.value.copy(phase = Phase.ERROR, error = message)
lingerThenIdle(id)
},
)
}
private fun confirm(id: Int, text: String) {
_state.value = _state.value.copy(phase = Phase.CONFIRMING, heard = text, until = 0L, span = 0L, held = 0f)
// The countdown starts once the driver has heard the whole reply, not while it's read out.
CarAssistant.say("Sending: $text") {
if (id != run) return@say
startCountdown(id, SEND_DELAY_MS)
// A moment for the read-back to finish playing in the car before the microphone opens.
main.postDelayed({ if (id == run) listenForCommand(id) }, ECHO_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) {
val sendAt = SystemClock.uptimeMillis() + ms
val held = _state.value.held
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.postAtTime({ if (id == run) send() }, SEND_TOKEN, sendAt)
}
/**
* Listens through the countdown for "cancel", "change it" or "send". The countdown holds from
* the first word, so a reply can't go out while the driver is still saying "cancel".
*/
private fun listenForCommand(id: Int) {
CarAssistant.dictate(
onPartial = {},
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 ->
if (id != run) return@dictate
when (command(text)) {
Command.CANCEL -> {
finish()
CarAssistant.say("Not sent.") {}
}
Command.CHANGE -> {
main.removeCallbacksAndMessages(SEND_TOKEN)
_state.value = _state.value.copy(phase = Phase.ASKING, heard = "", until = 0L, span = 0L, held = 0f)
ask(id)
}
Command.SEND -> send()
// Talk that wasn't meant for us, a passenger or the radio: carry on counting.
null -> startCountdown(id, RESUME_MS)
}
},
onError = { if (id == run && _state.value.until == 0L) startCountdown(id, RESUME_MS) },
)
}
private enum class Command { CANCEL, CHANGE, SEND }
private fun command(text: String): Command? {
val words = words(text)
return when {
words.any { it in CANCEL_WORDS } -> Command.CANCEL
words.any { it in CHANGE_WORDS } -> Command.CHANGE
words.any { it in SEND_WORDS } -> Command.SEND
else -> null
}
}
private fun words(text: String) = text.lowercase().split(Regex("[^a-z']+")).filter { it.isNotEmpty() }
/**
* Ready-made replies for a tap instead of a voice reply. The first gives the arrival time when
* the map is following a route.
*/
fun quickReplies(): List<String> = listOfNotNull(etaReply(), "Driving, I'll reply soon.", "On my way.")
private fun etaReply(): String? {
val estimate = (CarServices.nav.template.value as? NavigationTemplate)?.destinationTravelEstimate ?: return null
val at = estimate.arrivalTimeAtDestination.clockTime()
val left = remainingTime(estimate.remainingTimeSeconds)
return when {
at.isNotEmpty() -> "Driving, I'll be there around $at."
left.isNotEmpty() -> "Driving, I'll be there in about $left."
else -> null
}
}
/** Sends [text] to [n] straight away, as if it had been said and confirmed. */
fun quickReply(n: CarNotification, text: String) {
if (n.reply == null) return
begin(State(Phase.CONFIRMING, n, heard = text))
send()
}
/** Sends a reply that's counting down, without waiting for the rest of it. */
fun sendNow() {
if (_state.value.phase == Phase.CONFIRMING) send()
}
private fun send() {
val s = _state.value
val n = s.target ?: return finish()
val context = appContext ?: return finish()
val id = ++run
CarAssistant.stopListening()
CarAssistant.stopSpeaking()
if (CarNotifications.reply(context, n, s.heard.trim())) {
GearslipLog.i("voice reply: sent to ${n.appLabel}")
CarAssistant.say("Message sent.") {}
CarNotifications.dismissPopup(n.id)
_state.value = s.copy(phase = Phase.SENT, until = SystemClock.uptimeMillis() + SENT_MS, span = SENT_MS, held = 0f)
main.postDelayed({ if (id == run) finish() }, SENT_MS)
} else {
_state.value = s.copy(phase = Phase.ERROR, error = "${n.appLabel} withdrew the reply. Open the app on the phone to answer.")
lingerThenIdle(id)
}
}
/** Stops wherever it is. Nothing is sent. */
fun cancel() {
if (!active) return
CarAssistant.stopListening()
CarAssistant.stopSpeaking()
finish()
}
/**
* The steering wheel's voice key. With a message on screen it answers that message, and
* during a countdown it sends straight away. Returns false when there's nothing to answer, so
* the key can start the assistant instead.
*/
fun onVoiceKey(): Boolean {
val s = _state.value
when (s.phase) {
// Already heard, so straight to the reply.
Phase.READ, Phase.READING, Phase.OFFERING, Phase.ERROR -> s.target?.let { reply(it, readFirst = false); return true }
Phase.CONFIRMING -> { sendNow(); return true }
Phase.ASKING, Phase.LISTENING, Phase.SENT -> return true
Phase.IDLE -> Unit
}
val popup = CarNotifications.popup.value ?: return false
if (popup.reply == null) return false
reply(popup)
return true
}
private fun begin(state: State): Int {
CarAssistant.stopListening()
CarAssistant.stopSpeaking()
main.removeCallbacksAndMessages(null)
_state.value = state
return ++run
}
private fun lingerThenIdle(id: Int) {
_state.value = _state.value.copy(until = SystemClock.uptimeMillis() + LINGER_MS, span = LINGER_MS, held = 0f)
main.postDelayed({ if (id == run) finish() }, LINGER_MS)
}
private fun finish() {
run++
main.removeCallbacksAndMessages(null)
_state.value = State()
}
/** "Alex says: ..." or, in a group, "Sam in Climbing says: ...", for the newest messages. */
private fun spoken(n: CarNotification): String {
val from = n.title.ifBlank { n.appLabel }
val lines = n.lines.filter { it.sender != null }.takeLast(n.burst.coerceIn(1, MAX_READ))
if (lines.isEmpty()) return "$from says: ${n.text}"
return lines.joinToString(". ") { line ->
val who = if (n.isGroup) "${line.sender} in $from" else from
"$who says: ${line.text}"
}
}
private const val MAX_READ = 3
private val YES_WORDS = setOf("yes", "yeah", "yep", "yup", "sure", "ok", "okay", "reply", "please")
private val NO_WORDS = setOf("no", "nope", "nah", "cancel", "don't", "stop")
private val CANCEL_WORDS = setOf("cancel", "stop", "don't", "no", "nope")
private val CHANGE_WORDS = setOf("change", "redo", "again", "edit", "wrong", "fix")
private val SEND_WORDS = setOf("send", "yes", "yeah", "ok", "okay", "go")
private val SEND_TOKEN = Any()
private const val ECHO_MS = 300L
/** How long "Do you want to reply?" waits for an answer: the start beep, then the engine's silence limit. */
private const val OFFER_MS = 6_500L
private const val OFFER_GRACE_MS = 1_500L
/** After talk that wasn't a command, the reply waits this long again before it goes. */
private const val RESUME_MS = 3_000L
}
@@ -1,92 +0,0 @@
package app.seb3thehacker.gearslip.car
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.dp
import kotlin.math.PI
import kotlin.math.sin
/**
* A progress bar whose played part is a wave, and which flattens out while paused.
*
* The wave runs only while [playing], so a paused player costs the encoder nothing. Tapping or
* dragging seeks; the position is committed on release rather than on every pixel of a drag,
* because each commit is a request to the media app.
*/
@Composable
fun WavyProgress(
fraction: Float,
playing: Boolean,
onSeek: (Float) -> Unit,
modifier: Modifier = Modifier,
) {
val played = MaterialTheme.colorScheme.primary
val remaining = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f)
val amplitude by animateFloatAsState(if (playing) 1f else 0f, tween(400), label = "wave")
val phase = if (playing) {
rememberInfiniteTransition(label = "phase").animateFloat(
0f, (2 * PI).toFloat(),
infiniteRepeatable(tween(1400, easing = LinearEasing), RepeatMode.Restart),
label = "phase",
).value
} else 0f
var dragging by remember { mutableStateOf<Float?>(null) }
val shown = (dragging ?: fraction).coerceIn(0f, 1f)
Canvas(
modifier
.fillMaxWidth()
.height(32.dp)
.pointerInput(Unit) { detectTapGestures { onSeek((it.x / size.width).coerceIn(0f, 1f)) } }
.pointerInput(Unit) {
detectHorizontalDragGestures(
onDragStart = { dragging = (it.x / size.width).coerceIn(0f, 1f) },
onDragEnd = { dragging?.let(onSeek); dragging = null },
onDragCancel = { dragging = null },
) { change, _ -> dragging = (change.position.x / size.width).coerceIn(0f, 1f) }
},
) {
val stroke = 4.dp.toPx()
val mid = size.height / 2
val end = size.width * shown
val wavelength = 22.dp.toPx()
val amp = 3.5.dp.toPx() * amplitude
// Unplayed part: a straight line, so the wave reads as "this much is done".
drawLine(remaining, Offset(end, mid), Offset(size.width, mid), stroke, StrokeCap.Round)
val wave = Path().apply {
moveTo(0f, mid + amp * sin(phase.toDouble()).toFloat())
var x = 0f
while (x < end) {
x = minOf(end, x + 2f)
lineTo(x, mid + amp * sin(2 * PI * x / wavelength + phase).toFloat())
}
}
drawPath(wave, played, style = Stroke(stroke, cap = StrokeCap.Round))
drawLine(played, Offset(end, mid - 10.dp.toPx()), Offset(end, mid + 10.dp.toPx()), stroke, StrokeCap.Round)
}
}
@@ -136,7 +136,7 @@ object Weather {
// --- network -------------------------------------------------------------------------- // --- network --------------------------------------------------------------------------
private fun fetch(context: Context, location: Location): WeatherData { private fun fetch(context: Context, location: Location): WeatherData {
val imperial = Locale.getDefault().country in setOf("US", "LR", "MM") val imperial = CarSettings.imperial()
val url = URL( val url = URL(
"https://api.open-meteo.com/v1/forecast?latitude=${location.latitude}&longitude=${location.longitude}" + "https://api.open-meteo.com/v1/forecast?latitude=${location.latitude}&longitude=${location.longitude}" +
"&current=temperature_2m,apparent_temperature,relative_humidity_2m,weather_code,wind_speed_10m,is_day" + "&current=temperature_2m,apparent_temperature,relative_humidity_2m,weather_code,wind_speed_10m,is_day" +
@@ -86,6 +86,10 @@ fun WebApp(content: String) {
web?.evaluateJavascript(setFieldValueJs(updated), null) web?.evaluateJavascript(setFieldValueJs(updated), null)
}, },
onSubmit = { web?.evaluateJavascript(SUBMIT_FIELD_JS, null) }, onSubmit = { web?.evaluateJavascript(SUBMIT_FIELD_JS, null) },
onDismiss = {
fieldText = null
web?.evaluateJavascript(BLUR_FIELD_JS, null)
},
) )
} }
} }
@@ -144,6 +148,10 @@ private fun setFieldValueJs(value: String) = """
// Fires a real Enter keydown first - the same event a site's own JS would see from a physical // Fires a real Enter keydown first - the same event a site's own JS would see from a physical
// keyboard - and only falls back to submitting the form directly if nothing handled it, so a // keyboard - and only falls back to submitting the form directly if nothing handled it, so a
// search-as-you-type page and a plain <form> both work without submitting twice. // search-as-you-type page and a plain <form> both work without submitting twice.
// Dismissing the host keyboard without blurring the page's own field would leave it focused,
// so the next tap anywhere with a text cursor pops the keyboard straight back up.
private const val BLUR_FIELD_JS = "document.activeElement && document.activeElement.blur();"
private const val SUBMIT_FIELD_JS = """ private const val SUBMIT_FIELD_JS = """
(function() { (function() {
var el = document.activeElement; var el = document.activeElement;
@@ -0,0 +1,332 @@
package app.seb3thehacker.gearslip.car.theme
import android.os.SystemClock
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.offset
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.drawOutline
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.translate
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.PressInteraction
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProvideTextStyle
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/** What a control is for, which picks its colours from the Material scheme. */
enum class GsTone { Primary, Tonal, Neutral, Outline, Danger }
data class GsColors(val container: Color, val content: Color, val border: Color = Color.Unspecified)
@Composable
fun gsColors(tone: GsTone): GsColors {
val s = MaterialTheme.colorScheme
return when (tone) {
GsTone.Primary -> GsColors(s.primary, s.onPrimary)
GsTone.Tonal -> GsColors(s.secondaryContainer, s.onSecondaryContainer)
GsTone.Neutral -> GsColors(s.surfaceContainerHighest, s.onSurfaceVariant)
GsTone.Outline -> GsColors(Color.Transparent, s.primary, s.outline)
GsTone.Danger -> GsColors(s.error, s.onError)
}
}
@Composable
fun gsShape(): Shape = RoundedCornerShape(gsLook().corner)
/** The current theme's control look, swapped for its light-mode variant on a light UI. */
@Composable
fun gsLook(): ControlLook {
val look = LocalGsTheme.current.control
val light = look.light ?: return look
return if (MaterialTheme.colorScheme.surface.luminance() > 0.5f) light else look
}
/**
* Paints a control's container in the current theme's finish. Transparent containers stay
* transparent in every theme. Use directly for custom-shaped tappables; [GsButton] and
* [GsIconButton] already apply it. [latched] holds a control down - a selected option, the current
* screen's nav button - in themes that have a pressed-in look.
*/
@Composable
fun Modifier.gsSurface(
container: Color,
shape: Shape = gsShape(),
border: Color = Color.Unspecified,
pressed: Boolean = false,
latched: Boolean = false,
): Modifier {
val look = gsLook()
val down = pressed || latched
// The keycap's face sinks onto its base while pressed, and stays down while latched: the base
// shrinks to nothing and the face moves down by the same amount, so the control's overall size
// never changes.
val lipNow by animateDpAsState(if (down) 0.dp else look.lip, tween(if (pressed) 50 else 110), label = "keycap")
// Tactile: the drop shadow collapses and the face drops by [ControlLook.sink].
val lift by animateFloatAsState(if (down) 0f else 1f, tween(if (pressed) 50 else 140), label = "lift")
val sinkNow by animateDpAsState(if (down) look.sink else 0.dp, tween(if (pressed) 50 else 140), label = "sink")
var m = this
if (container.alpha > 0f && look.shadow > 0.dp) {
m = m.drawBehind { drawSoftShadow(shape, look.shadow.toPx(), look.shadowAlpha * lift, lift) }
}
if (look.sink > 0.dp) m = m.offset(y = sinkNow)
if (container.alpha > 0f && look.lip > 0.dp) {
val base = lerp(container, Color.Black, 0.35f)
m = m
.padding(top = look.lip - lipNow, bottom = lipNow)
.drawBehind {
translate(top = lipNow.toPx()) {
drawOutline(shape.createOutline(size, layoutDirection, this), base)
}
}
}
m = m.clip(shape)
if (container.alpha > 0f) {
val shade = if (container.luminance() > 0.5f) look.bottomShadeOnLight else look.bottomShade
m = if (look.topLight > 0f || shade > 0f) {
m.background(
Brush.verticalGradient(
0f to lerp(container, Color.White, look.topLight),
0.5f to container,
1f to lerp(container, Color.Black, shade),
),
)
} else {
m.background(container)
}
if (latched && look.insetAlpha > 0f) {
val inset = look.insetAlpha
m = m.drawBehind {
drawRect(Brush.verticalGradient(0f to Color.Black.copy(alpha = inset), 0.3f to Color.Transparent))
}
}
if (look.rimAlpha > 0f) {
m = m.border(1.dp, Brush.verticalGradient(listOf(Color.White.copy(alpha = look.rimAlpha), Color.Transparent)), shape)
}
if (!latched && (look.bevelTop > 0f || look.bevelBottom > 0f)) {
m = m.border(
1.dp,
Brush.verticalGradient(
0f to Color.White.copy(alpha = look.bevelTop),
0.3f to Color.Transparent,
0.7f to Color.Transparent,
1f to Color.Black.copy(alpha = look.bevelBottom),
),
shape,
)
}
}
if (border.isSpecified) m = m.border(BorderStroke(1.dp, border), shape)
if (pressed) m = m.drawWithContent {
drawContent()
drawRect(Color.Black.copy(alpha = look.pressedDim))
}
return m
}
/**
* A blurred shadow falling [elevation] below the control, faked with a few stacked, growing
* outlines: cheap, works on every API level, and already soft enough that the car's video
* compression has nothing crisp to smear.
*/
private fun DrawScope.drawSoftShadow(shape: Shape, elevation: Float, alpha: Float, lift: Float) {
if (alpha <= 0f) return
val blur = elevation * 2f
val steps = 6
for (i in steps downTo 1) {
val grow = blur * i / steps - blur * 0.35f
val outline = shape.createOutline(
Size((size.width + grow * 2).coerceAtLeast(0f), (size.height + grow * 2).coerceAtLeast(0f)),
layoutDirection,
this,
)
translate(-grow, elevation * lift - grow) {
drawOutline(outline, Color.Black.copy(alpha = alpha / steps))
}
}
}
/**
* Whether a control should look pressed. Held for at least [MIN_PRESS_MS] after the finger lifts:
* a quick tap would otherwise come and go between two frames of the car's video stream.
*/
@Composable
private fun rememberPressed(source: MutableInteractionSource): Boolean {
var shown by remember { mutableStateOf(false) }
LaunchedEffect(source) {
var since = 0L
var release: Job? = null
source.interactions.collect { interaction ->
when (interaction) {
is PressInteraction.Press -> {
release?.cancel()
since = SystemClock.uptimeMillis()
shown = true
}
is PressInteraction.Release, is PressInteraction.Cancel -> {
release?.cancel()
release = launch {
delay((MIN_PRESS_MS - (SystemClock.uptimeMillis() - since)).coerceAtLeast(0))
shown = false
}
}
}
}
}
return shown
}
private const val MIN_PRESS_MS = 140L
@Composable
private fun Modifier.gsPressable(
source: MutableInteractionSource,
pressed: Boolean,
enabled: Boolean,
onClick: () -> Unit,
onLongClick: (() -> Unit)? = null,
role: Role = Role.Button,
shape: Shape = gsShape(),
): Modifier {
val look = gsLook()
// Reached with the car's knob or D-pad rather than a finger: a ring shows where focus is.
val focused by source.collectIsFocusedAsState()
val ring = MaterialTheme.colorScheme.primary
return this
.scale(if (pressed) look.pressedScale else 1f)
.then(if (focused) Modifier.border(3.dp, ring, shape) else Modifier)
.combinedClickable(
interactionSource = source,
indication = null,
enabled = enabled,
role = role,
onLongClick = onLongClick,
onClick = onClick,
)
}
private fun GsColors.disabled(enabled: Boolean, onSurface: Color): GsColors =
if (enabled) this
else GsColors(
container = if (container.alpha > 0f) onSurface.copy(alpha = 0.12f) else container,
content = onSurface.copy(alpha = 0.38f),
border = if (border.isSpecified) onSurface.copy(alpha = 0.12f) else border,
)
/** A themed text (or text-and-icon) button. */
@Composable
fun GsButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
tone: GsTone = GsTone.Primary,
colors: GsColors = gsColors(tone),
enabled: Boolean = true,
contentPadding: PaddingValues = PaddingValues(horizontal = 24.dp, vertical = 10.dp),
content: @Composable RowScope.() -> Unit,
) {
val c = colors.disabled(enabled, MaterialTheme.colorScheme.onSurface)
val shape = gsShape()
val source = remember { MutableInteractionSource() }
val pressed = rememberPressed(source)
Row(
modifier
.defaultMinSize(minWidth = 64.dp, minHeight = 48.dp)
.gsPressable(source, pressed, enabled, onClick, shape = shape)
.gsSurface(c.container, shape, c.border, pressed)
.padding(contentPadding),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
CompositionLocalProvider(LocalContentColor provides c.content) {
ProvideTextStyle(MaterialTheme.typography.labelLarge) { content() }
}
}
}
/** A themed square icon button. Pass a [modifier] size to override the default [size]. */
@Composable
fun GsIconButton(
icon: ImageVector,
contentDescription: String?,
onClick: () -> Unit,
modifier: Modifier = Modifier,
tone: GsTone = GsTone.Neutral,
colors: GsColors = gsColors(tone),
enabled: Boolean = true,
size: Dp = 48.dp,
iconSize: Dp = 24.dp,
shape: Shape = gsShape(),
) {
GsIconBox(onClick, modifier.size(size), colors, enabled, shape) {
Icon(icon, contentDescription, Modifier.size(iconSize))
}
}
/** [GsIconButton] with arbitrary content, for glyphs drawn in code or app icons. */
@Composable
fun GsIconBox(
onClick: () -> Unit,
modifier: Modifier = Modifier,
colors: GsColors = gsColors(GsTone.Neutral),
enabled: Boolean = true,
shape: Shape = gsShape(),
/** Held down, as a keycap theme draws it - for a toggle or the current screen's nav button. */
latched: Boolean = false,
onLongClick: (() -> Unit)? = null,
content: @Composable BoxScope.() -> Unit,
) {
val c = colors.disabled(enabled, MaterialTheme.colorScheme.onSurface)
val source = remember { MutableInteractionSource() }
val pressed = rememberPressed(source)
Box(
modifier
.gsPressable(source, pressed, enabled, onClick, onLongClick, shape = shape)
.gsSurface(c.container, shape, c.border, pressed, latched),
contentAlignment = Alignment.Center,
) {
CompositionLocalProvider(LocalContentColor provides c.content) { content() }
}
}
@@ -0,0 +1,173 @@
package app.seb3thehacker.gearslip.car.theme
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithContent
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.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlin.math.sin
/**
* A capsule progress bar: a tinted track with a glossy fill set inside it, lighter along the
* top and darker along the bottom. Below its own height the fill narrows into a smaller oval, so
* a countdown runs smoothly all the way to empty. The flat theme draws Material's wavy bar instead.
*/
@Composable
fun GsProgressBar(
fraction: Float,
modifier: Modifier = Modifier,
height: Dp = 22.dp,
color: Color = MaterialTheme.colorScheme.primary,
/** Brightens the fill, for a bar that's being dragged. */
active: Boolean = false,
) {
val scheme = MaterialTheme.colorScheme
val shown = fraction.coerceIn(0f, 1f)
val inset = 3.dp
val fill = if (active) lerp(color, Color.White, 0.12f) else color
if (LocalGsTheme.current.id == GsThemes.Flat.id) {
WavyBar(shown, fill, lerp(scheme.surfaceContainerHighest, color, 0.12f), modifier.fillMaxWidth().height(height))
return
}
BoxWithConstraints(
modifier
.fillMaxWidth()
.height(height)
.clip(CircleShape)
.background(lerp(scheme.surfaceContainerHighest, color, 0.12f))
.padding(inset),
contentAlignment = Alignment.CenterStart,
) {
if (shown <= 0f) return@BoxWithConstraints
Box(
Modifier
.width(maxWidth * shown)
.fillMaxHeight()
.clip(CircleShape)
.background(
Brush.verticalGradient(
0f to lerp(fill, Color.White, 0.30f),
0.55f to fill,
1f to lerp(fill, Color.Black, 0.22f),
),
)
// The gloss: a soft white band across the top half.
.drawWithContent {
drawContent()
drawRect(
Brush.verticalGradient(
0f to Color.White.copy(alpha = 0.35f),
0.5f to Color.White.copy(alpha = 0.05f),
0.5f to Color.Transparent,
),
)
},
)
}
}
/**
* Material 3's wavy progress bar, drawn here because the stable library doesn't offer it yet: the
* filled part is a sine wave that drifts along, then a gap, then a straight track and a stop dot
* at the end.
*/
@Composable
private fun WavyBar(fraction: Float, color: Color, track: Color, modifier: Modifier) {
val phase by rememberInfiniteTransition(label = "wave").animateFloat(
0f, 1f, infiniteRepeatable(tween(1_000, easing = LinearEasing), RepeatMode.Restart), label = "phase",
)
Canvas(modifier) {
val stroke = 4.dp.toPx()
val amplitude = (size.height / 2 - stroke).coerceIn(0f, 3.dp.toPx())
val wavelength = 32.dp.toPx()
val gap = 6.dp.toPx()
val mid = size.height / 2
val start = stroke / 2
val end = size.width - stroke / 2
val split = start + (end - start) * fraction
if (split > start) {
val wave = Path()
var x = start
val shift = phase * wavelength
while (x <= split) {
val y = mid + amplitude * sin(2 * Math.PI * (x - shift) / wavelength).toFloat()
if (x == start) wave.moveTo(x, y) else wave.lineTo(x, y)
x += 1f
}
drawPath(wave, color, style = Stroke(stroke, cap = StrokeCap.Round))
}
val trackStart = if (split > start) split + gap + stroke else start
if (trackStart < end) {
drawLine(track, Offset(trackStart, mid), Offset(end, mid), stroke, StrokeCap.Round)
}
drawCircle(color, stroke / 2, Offset(end, mid))
}
}
/**
* [GsProgressBar] that seeks: tapping or dragging moves it. The position is committed on release
* rather than on every pixel of a drag, because each commit is a request to the media app.
*/
@Composable
fun GsSeekBar(
fraction: Float,
onSeek: (Float) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
) {
var dragging by remember { mutableStateOf<Float?>(null) }
// A taller touch area than the bar itself, so it's easy to hit on a car screen.
Box(
modifier
.fillMaxWidth()
.height(36.dp)
.then(
if (!enabled) Modifier else Modifier
.pointerInput(Unit) { detectTapGestures { onSeek((it.x / size.width).coerceIn(0f, 1f)) } }
.pointerInput(Unit) {
detectHorizontalDragGestures(
onDragStart = { dragging = (it.x / size.width).coerceIn(0f, 1f) },
onDragEnd = { dragging?.let(onSeek); dragging = null },
onDragCancel = { dragging = null },
) { change, _ -> dragging = (change.position.x / size.width).coerceIn(0f, 1f) }
},
),
contentAlignment = Alignment.Center,
) {
GsProgressBar(dragging ?: fraction, active = dragging != null)
}
}
@@ -0,0 +1,43 @@
package app.seb3thehacker.gearslip.car.theme
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
/**
* An on/off switch in the current theme's finish: the track is pressed in while on, the knob is
* raised like a button. The whole switch is one tap target, as big as a nav bar button.
*/
@Composable
fun GsSwitch(checked: Boolean, onCheckedChange: (Boolean) -> Unit, modifier: Modifier = Modifier) {
val scheme = MaterialTheme.colorScheme
val knob = 26.dp
val travel = 64.dp - knob - 8.dp
val x by animateDpAsState(if (checked) travel else 0.dp, tween(140), label = "switch")
GsIconBox(
onClick = { onCheckedChange(!checked) },
modifier = modifier.size(width = 64.dp, height = 36.dp),
colors = if (checked) GsColors(scheme.primary, scheme.onPrimary) else GsColors(scheme.surfaceContainerHighest, scheme.onSurfaceVariant),
shape = CircleShape,
latched = checked,
) {
Box(Modifier.matchParentSize().padding(4.dp), contentAlignment = Alignment.CenterStart) {
Box(
Modifier
.offset(x = x)
.size(knob)
.gsSurface(if (checked) scheme.onPrimary else scheme.outline, CircleShape),
)
}
}
}
@@ -0,0 +1,114 @@
package app.seb3thehacker.gearslip.car.theme
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* How the car UI's controls look, separate from its colours: [MaterialTheme]'s colour scheme still
* decides what colour a button is, and this decides how that colour is drawn. A theme is plain
* data, so a user-supplied theme later is just another instance - nothing about the built-in ones
* is special beyond being listed in [GsThemes.builtIn].
*/
@Immutable
data class GsTheme(
/** Stable key persisted in settings; never reuse one for a different look. */
val id: String,
val name: String,
val control: ControlLook,
)
/** The finish on anything tappable that has a container: buttons, toggles, keypad keys. */
@Immutable
data class ControlLook(
val corner: Dp = 12.dp,
/** How far the top edge is lifted towards white, 0..1, fading evenly to the base colour at the centre. */
val topLight: Float = 0f,
/** How far the bottom edge is pushed towards black, 0..1. */
val bottomShade: Float = 0f,
/**
* [bottomShade] for containers that are already light, where lifting the top towards white
* barely shows: those get their depth from a darker bottom edge instead.
*/
val bottomShadeOnLight: Float = 0f,
/** Alpha of the thin highlight along the top rim, fading out down the sides; 0 for none. */
val rimAlpha: Float = 0f,
/** A solid darker base under the button, like a keycap; 0 for none. */
val lip: Dp = 0.dp,
/** How far a soft drop shadow falls below the button; 0 for none. Its blur is twice this. */
val shadow: Dp = 0.dp,
val shadowAlpha: Float = 0f,
/**
* A 1px bevel on the edge only - light along the top, dark along the bottom - so depth reads
* at any size without shading the face itself. 0 for none.
*/
val bevelTop: Float = 0f,
val bevelBottom: Float = 0f,
/** How far the face drops while pressed; 0 for none. */
val sink: Dp = 0.dp,
/**
* Alpha of the shadow cast inside the top edge of a latched control (a selected option, the
* current screen's nav button), so it reads as pushed in. 0 for none.
*/
val insetAlpha: Float = 0f,
/**
* Alpha of the dark overlay while pressed. Heavier than a ripple on purpose: the car screen
* shows a video stream a frame or two behind the finger, so subtle feedback gets lost.
*/
val pressedDim: Float = 0.22f,
/** Scale while pressed, 1 for none. */
val pressedScale: Float = 0.95f,
/** Replaces this look when the UI is light, for effects that only work on one background. */
val light: ControlLook? = null,
)
object GsThemes {
val Keycap = GsTheme(
id = "keycap",
name = "Keycap",
control = ControlLook(lip = 4.dp, pressedScale = 1f),
)
val Tactile = GsTheme(
id = "tactile",
name = "Tactile",
control = ControlLook(
topLight = 0.05f,
shadow = 3.dp,
shadowAlpha = 0.32f,
bevelTop = 0.16f,
bevelBottom = 0.22f,
sink = 2.dp,
insetAlpha = 0.30f,
pressedDim = 0.12f,
pressedScale = 1f,
// A wide soft shadow bands once the car's video stream compresses it on a light
// background, so light mode gets a tight one and leans on the bevel instead.
light = ControlLook(
topLight = 0.05f,
shadow = 1.5.dp,
shadowAlpha = 0.38f,
bevelTop = 0.24f,
bevelBottom = 0.30f,
sink = 2.dp,
insetAlpha = 0.30f,
pressedDim = 0.12f,
pressedScale = 1f,
),
),
)
val Flat = GsTheme(
id = "flat",
name = "Flat",
control = ControlLook(),
)
val default = Tactile
val builtIn = listOf(Tactile, Keycap, Flat)
fun byId(id: String?): GsTheme = builtIn.firstOrNull { it.id == id } ?: default
}
val LocalGsTheme = staticCompositionLocalOf { GsThemes.default }
@@ -8,6 +8,7 @@ import android.content.ServiceConnection
import android.content.res.Configuration import android.content.res.Configuration
import android.graphics.Rect import android.graphics.Rect
import android.location.Location import android.location.Location
import android.net.Uri
import android.os.Handler import android.os.Handler
import android.os.IBinder import android.os.IBinder
import android.os.Looper import android.os.Looper
@@ -94,18 +95,27 @@ class CarAppConnection(private val context: Context) {
/** Whose icon the header shows for the standard app-icon action. */ /** Whose icon the header shows for the standard app-icon action. */
val appPackage: String? get() = component?.packageName val appPackage: String? get() = component?.packageName
/** Which app is bound right now, if any - lets a caller tell "still this one" from "a new one". */
val connectedComponent: ComponentName? get() = component
private var frameWidth = 800 private var frameWidth = 800
private var frameHeight = 480 private var frameHeight = 480
private var frameDensity = 160 private var frameDensity = 160
/** A destination to navigate to as soon as the app is created; see [navigateTo]. */
private var pendingDestination: String? = null
// --- connection --------------------------------------------------------------------- // --- connection ---------------------------------------------------------------------
fun connect(app: TemplateApp, width: Int, height: Int, densityDpi: Int) { fun connect(app: TemplateApp, width: Int, height: Int, densityDpi: Int, destination: String? = null) {
disconnect() // Keeps the location hold through a reconnect: stopping and restarting the service can be
// refused once the phone is locked, leaving the new app without location.
disconnect(keepLocation = true)
frameWidth = width frameWidth = width
frameHeight = height frameHeight = height
frameDensity = densityDpi frameDensity = densityDpi
component = app.component component = app.component
pendingDestination = destination
update { Status(phase = Phase.BINDING, app = app.label) } update { Status(phase = Phase.BINDING, app = app.label) }
val intent = Intent(CarAppService.SERVICE_INTERFACE).setComponent(app.component) val intent = Intent(CarAppService.SERVICE_INTERFACE).setComponent(app.component)
@@ -125,11 +135,23 @@ class CarAppConnection(private val context: Context) {
} }
binding = connection binding = connection
LocationKeepAlive.start(context) val bind = bind@{
val bind = { // Replaced by another connect (or a disconnect) while waiting on the location service.
if (binding !== connection) return@bind
var bindError: Throwable? = null var bindError: Throwable? = null
val flags = Context.BIND_AUTO_CREATE or LocationKeepAlive.BIND_INCLUDE_CAPABILITIES
val bound = runCatching { val bound = runCatching {
context.bindService(intent, connection, Context.BIND_AUTO_CREATE or LocationKeepAlive.BIND_INCLUDE_CAPABILITIES) // Without BIND_FOREGROUND_SERVICE, an app bound by a foreground service (which is
// all Gearslip is once the phone screen leaves it) only reaches "important
// foreground", and that state loses network under Doze and background network
// restrictions: the map and GPS keep working while search and routing go dead.
// In the preview Gearslip is the top app, which hides this. Retried without the
// flag in case a build of Android refuses it from an unprivileged caller.
runCatching { context.bindService(intent, connection, flags or BIND_FOREGROUND_SERVICE) }
.getOrElse {
GearslipLog.w("host: foreground binding refused (${it.message}); binding without it")
context.bindService(intent, connection, flags)
}
}.getOrElse { }.getOrElse {
GearslipLog.e("bindService threw for ${app.component}", it) GearslipLog.e("bindService threw for ${app.component}", it)
bindError = it bindError = it
@@ -143,8 +165,19 @@ class CarAppConnection(private val context: Context) {
} }
} }
// BIND_INCLUDE_CAPABILITIES only lends the location capability Gearslip holds at bind
// time, so the keep-alive has to be running first - a nav app that starts its own
// location service before then is refused, and MapQuest crashes on that.
if (holdsLocation) {
bind() bind()
} else {
holdsLocation = true
LocationKeepAlive.acquire(context) { bind() }
} }
}
/** Whether this connection has a hold on [LocationKeepAlive]. */
private var holdsLocation = false
/** /**
* A service that names a permission can only be bound by an app holding it. Some navigation apps * A service that names a permission can only be bound by an app holding it. Some navigation apps
@@ -179,7 +212,9 @@ class CarAppConnection(private val context: Context) {
return "$kind at $step: ${firstLine.substringAfterLast("Exception: ", firstLine)}" return "$kind at $step: ${firstLine.substringAfterLast("Exception: ", firstLine)}"
} }
fun disconnect() { fun disconnect() = disconnect(keepLocation = false)
private fun disconnect(keepLocation: Boolean) {
lent?.let { sendSurfaceDestroyed(it) } lent?.let { sendSurfaceDestroyed(it) }
lent = null lent = null
val carApp = this.carApp val carApp = this.carApp
@@ -187,7 +222,10 @@ class CarAppConnection(private val context: Context) {
runCatching { carApp.onAppPause(noop("onAppPause")) } runCatching { carApp.onAppPause(noop("onAppPause")) }
runCatching { carApp.onAppStop(noop("onAppStop")) } runCatching { carApp.onAppStop(noop("onAppStop")) }
} }
LocationKeepAlive.stop(context) if (holdsLocation && !keepLocation) {
holdsLocation = false
LocationKeepAlive.release(context)
}
binding?.let { runCatching { context.unbindService(it) } } binding?.let { runCatching { context.unbindService(it) } }
binding = null binding = null
this.carApp = null this.carApp = null
@@ -196,6 +234,7 @@ class CarAppConnection(private val context: Context) {
_panMode.value = false _panMode.value = false
surfaceCallback = null surfaceCallback = null
component = null component = null
pendingDestination = null
_template.value = null _template.value = null
update { Status() } update { Status() }
} }
@@ -242,12 +281,34 @@ class CarAppConnection(private val context: Context) {
private fun onHandshakeAccepted() { private fun onHandshakeAccepted() {
val app = carApp ?: return val app = carApp ?: return
GearslipLog.i("host: handshake accepted; creating the app") GearslipLog.i("host: handshake accepted; creating the app")
val intent = Intent(Intent.ACTION_MAIN).setComponent(component) val intent = pendingDestination?.let { navigateIntent(it) }
?: Intent(Intent.ACTION_MAIN).setComponent(component)
pendingDestination = null
call("onAppCreate", onValue = { onCreated() }) { call("onAppCreate", onValue = { onCreated() }) {
app.onAppCreate(carHost, intent, carConfiguration(), it) app.onAppCreate(carHost, intent, carConfiguration(), it)
} }
} }
/**
* "navigate to X" deep link, the same one Android Auto's own Assistant integration sends -
* [ACTION_NAVIGATE] with the destination as a `geo:` search query, rather than anything
* app-specific.
*/
private fun navigateIntent(destination: String) =
Intent(CarContext.ACTION_NAVIGATE, Uri.parse("geo:0,0?q=" + Uri.encode(destination)))
.setComponent(component)
/**
* Sends a fresh destination to an app that's already running, instead of tearing it down
* and rebuilding the whole session - see [app.seb3thehacker.gearslip.car.CarServices.connectNav].
*/
fun navigateTo(destination: String) {
val app = carApp ?: return
call("onNewIntent", onValue = {}) {
app.onNewIntent(navigateIntent(destination), it)
}
}
private fun onCreated() { private fun onCreated() {
val app = carApp ?: return val app = carApp ?: return
GearslipLog.i("host: created; starting") GearslipLog.i("host: created; starting")
@@ -378,16 +439,47 @@ class CarAppConnection(private val context: Context) {
} }
} }
/** Takes the Surface back, e.g. when the SurfaceView goes away. */ /**
fun detachSurface() { * Takes the Surface back, e.g. when the SurfaceView goes away. With [surface], only if that is
* still the one lent - a replaced view's teardown must not take back its successor's.
*/
fun detachSurface(surface: Surface? = null) {
main.post { main.post {
val current = lent ?: return@post val current = lent ?: return@post
if (surface != null && current.surface !== surface) return@post
lent = null lent = null
sendSurfaceDestroyed(current) sendSurfaceDestroyed(current)
update { it.copy(surfaceAttached = false) } update { it.copy(surfaceAttached = false) }
} }
} }
private val _surfaceEpoch = MutableStateFlow(0)
/** Bumped to throw the lent surface away, so the stage builds a fresh one. */
val surfaceEpoch: StateFlow<Int> = _surfaceEpoch.asStateFlow()
/**
* Called as the map comes back into view: replaces the surface only if it died while the map
* was covered. A live one is left alone, because every hand-over costs the app its map - some
* (MapQuest) rebuild theirs from a world view and fly back to the car's position.
*
* 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 replaceDeadSurface() {
main.post {
val current = lent ?: return@post
if (current.surface.isValid) return@post
GearslipLog.w("host: the lent surface died; building a new one")
lent = null
sendSurfaceDestroyed(current)
_surfaceEpoch.value = _surfaceEpoch.value + 1
}
}
private fun sendSurfaceAvailable() { private fun sendSurfaceAvailable() {
val callback = surfaceCallback ?: return val callback = surfaceCallback ?: return
val surface = lent ?: return val surface = lent ?: return
@@ -412,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)
@@ -664,5 +756,8 @@ class CarAppConnection(private val context: Context) {
private companion object { private companion object {
/** The newest template API this host claims to understand. */ /** The newest template API this host claims to understand. */
const val HOST_API_LEVEL = CarAppApiLevels.LEVEL_8 const val HOST_API_LEVEL = CarAppApiLevels.LEVEL_8
/** Context.BIND_FOREGROUND_SERVICE; the constant itself is hidden from the SDK. */
const val BIND_FOREGROUND_SERVICE = 0x04000000
} }
} }
@@ -0,0 +1,42 @@
package app.seb3thehacker.gearslip.host
import android.content.pm.PackageManager
import android.content.pm.ServiceInfo
import android.os.Bundle
import org.xmlpull.v1.XmlPullParser
/**
* An app's Android Auto descriptor: the `com.google.android.gms.car.application` meta-data,
* pointing at an xml file whose `<uses name="..."/>` lines say what the app does in a car -
* `media`, `notification` (messaging), `template` and so on.
*/
object CarDescriptor {
const val META = "com.google.android.gms.car.application"
/** The meta-data holding the descriptor, from the service first and then the app. */
fun meta(manager: PackageManager, pkg: String, service: ServiceInfo? = null): Bundle? =
service?.metaData?.takeIf { it.containsKey(META) }
?: runCatching { manager.getApplicationInfo(pkg, PackageManager.GET_META_DATA).metaData }
.getOrNull()?.takeIf { it.containsKey(META) }
/**
* The `uses` names in [pkg]'s descriptor. Null when it has no descriptor, or one that can't
* be read; empty when it lists nothing.
*/
fun uses(manager: PackageManager, pkg: String, service: ServiceInfo? = null): Set<String>? {
val resId = meta(manager, pkg, service)?.getInt(META) ?: return null
if (resId == 0) return null
return runCatching {
manager.getResourcesForApplication(pkg).getXml(resId).use { xml ->
buildSet {
while (xml.next() != XmlPullParser.END_DOCUMENT) {
if (xml.eventType == XmlPullParser.START_TAG && xml.name == "uses") {
xml.getAttributeValue(null, "name")?.let(::add)
}
}
}
}
}.getOrNull()
}
}
@@ -20,6 +20,7 @@ import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import app.seb3thehacker.gearslip.GearslipLog import app.seb3thehacker.gearslip.GearslipLog
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import kotlin.math.roundToInt
/** /**
* Turns the Car App Library's model objects into things Compose can draw. * Turns the Car App Library's model objects into things Compose can draw.
@@ -32,6 +33,16 @@ import java.util.concurrent.TimeUnit
/** The app's text, preferring the longest variant that will fit; variants are ordered longest first. */ /** The app's text, preferring the longest variant that will fit; variants are ordered longest first. */
fun CarText?.text(): String = this?.takeIf { !it.isEmpty }?.toCharSequence()?.toString().orEmpty() fun CarText?.text(): String = this?.takeIf { !it.isEmpty }?.toCharSequence()?.toString().orEmpty()
/**
* Icons keep coming back under fresh [CarIcon]/IconCompat objects - every template refetch
* deserializes its own copies over the binder - so caching by object identity would never hit.
* [describe] is stable across those copies for the same underlying icon (same URI or resource),
* which is what makes it usable as a cache key. Capacity is in entries, not bytes: cover art at
* car-screen size is small and a shelf plus its sections stays well under a hundred distinct
* icons on screen at once.
*/
private val iconCache = android.util.LruCache<String, ImageBitmap>(200)
/** /**
* Loads an icon the app described. * Loads an icon the app described.
* *
@@ -41,10 +52,50 @@ fun CarText?.text(): String = this?.takeIf { !it.isEmpty }?.toCharSequence()?.to
*/ */
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 drawable: Drawable = runCatching { icon.loadDrawable(context) }.getOrNull() ?: return null val key = icon.cacheKey(context)
val width = drawable.intrinsicWidth.takeIf { it > 0 } ?: ICON_FALLBACK_PX iconCache.get(key)?.let { return it }
val height = drawable.intrinsicHeight.takeIf { it > 0 } ?: ICON_FALLBACK_PX val drawable: Drawable = runCatching { icon.loadDrawable(context) }
return runCatching { drawable.toBitmap(width, height).asImageBitmap() }.getOrNull() .onFailure { GearslipLog.w("icon: loadDrawable threw for ${icon.describe()}: ${it.javaClass.simpleName}: ${it.message}") }
.getOrNull()
?: run {
GearslipLog.w("icon: loadDrawable returned null for ${icon.describe()}")
return null
}
val rawWidth = drawable.intrinsicWidth.takeIf { it > 0 } ?: ICON_FALLBACK_PX
val rawHeight = drawable.intrinsicHeight.takeIf { it > 0 } ?: ICON_FALLBACK_PX
// A handful of apps (Spotify's search glyph, 12x12) hand over an icon so small it turns to
// mush once Compose stretches it up to button size. Rasterizing it larger here instead lets
// Android's own bitmap scaler do that smoothing while the source is still a Drawable, rather
// than Compose scaling an already-tiny texture at draw time.
val scale = (ICON_FALLBACK_PX.toFloat() / maxOf(rawWidth, rawHeight)).coerceAtLeast(1f)
val width = (rawWidth * scale).toInt()
val height = (rawHeight * scale).toInt()
val bitmap = runCatching { drawable.toBitmap(width, height).asImageBitmap() }
.onFailure { GearslipLog.w("icon: toBitmap threw for ${icon.describe()}: ${it.javaClass.simpleName}: ${it.message}") }
.getOrNull() ?: return null
iconCache.put(key, bitmap)
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. */
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_ADAPTIVE_BITMAP,
-> "uri=$uri"
androidx.core.graphics.drawable.IconCompat.TYPE_RESOURCE -> "resource=$resPackage:$resId"
else -> "type=$type"
} }
/** Standard icons carry no bitmap - the host draws its own, so callers need to know which. */ /** Standard icons carry no bitmap - the host draws its own, so callers need to know which. */
@@ -64,9 +115,16 @@ fun CarColor?.color(dark: Boolean, default: Color): Color = when (this?.type) {
else -> default else -> default
} }
/** "500 ft", "2.4 mi" - the app picks the unit, the host formats it. */ /**
* "500 ft", "2.4 mi". The app picks the unit and its rounding; when that's the other system from
* the one chosen in Gearslip's settings, the distance is converted and rounded here instead.
*/
fun Distance?.display(): String { fun Distance?.display(): String {
val distance = this ?: return "" val distance = this ?: return ""
val appImperial = displayUnit in setOf(Distance.UNIT_MILES, Distance.UNIT_MILES_P1, Distance.UNIT_FEET, Distance.UNIT_YARDS)
val appMetric = displayUnit in setOf(Distance.UNIT_METERS, Distance.UNIT_KILOMETERS, Distance.UNIT_KILOMETERS_P1)
val imperial = app.seb3thehacker.gearslip.car.CarSettings.imperial()
if ((imperial && appMetric) || (!imperial && appImperial)) return convertDistance(distance.meters(), imperial)
val unit = when (displayUnit) { val unit = when (displayUnit) {
Distance.UNIT_METERS -> "m" Distance.UNIT_METERS -> "m"
Distance.UNIT_KILOMETERS, Distance.UNIT_KILOMETERS_P1 -> "km" Distance.UNIT_KILOMETERS, Distance.UNIT_KILOMETERS_P1 -> "km"
@@ -83,6 +141,32 @@ fun Distance?.display(): String {
return "%.${decimals}f $unit".format(distance.displayDistance) return "%.${decimals}f $unit".format(distance.displayDistance)
} }
private fun Distance.meters(): Double = displayDistance * when (displayUnit) {
Distance.UNIT_KILOMETERS, Distance.UNIT_KILOMETERS_P1 -> 1000.0
Distance.UNIT_MILES, Distance.UNIT_MILES_P1 -> 1609.344
Distance.UNIT_FEET -> 0.3048
Distance.UNIT_YARDS -> 0.9144
else -> 1.0
}
/**
* Rounded the way a nav app would: feet in steps of 50 under a tenth of a mile, metres in steps
* of 10 (50 past 300) under a kilometre, one decimal under 10 miles or km, whole numbers above.
*/
private fun convertDistance(meters: Double, imperial: Boolean): String {
if (imperial) {
val miles = meters / 1609.344
if (miles < 0.1) return "${(meters / 0.3048 / 50).roundToInt() * 50} ft"
return if (miles < 10) "%.1f mi".format(miles) else "%.0f mi".format(miles)
}
if (meters < 1000) {
val step = if (meters < 300) 10 else 50
return "${(meters / step).roundToInt() * step} m"
}
val km = meters / 1000
return if (km < 10) "%.1f km".format(km) else "%.0f km".format(km)
}
/** "18 min" from the app's remaining-time estimate. */ /** "18 min" from the app's remaining-time estimate. */
fun remainingTime(seconds: Long): String { fun remainingTime(seconds: Long): String {
if (seconds <= 0) return "" if (seconds <= 0) return ""
@@ -16,10 +16,34 @@ 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.mapquest.android.ace", // MapQuest: map, search, routing - works well
"nl.flitsmeister", // Flitsmeister: map, routing, alerts - works well
) )
fun works(packageName: String): Boolean = packageName in working fun works(packageName: String): Boolean = packageName in working
/**
* Apps whose media player works through Gearslip's own player even though their car screen
* doesn't. Only the player tile gets the check, not the "· Browse" one.
*/
private val workingPlayer = setOf(
"com.spotify.music", // Spotify: play, browse, seek through Gearslip's player
)
fun playerWorks(packageName: String): Boolean = packageName in workingPlayer
/**
* Apps whose car screen refuses Gearslip but whose player works. When the player covers the
* 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(
"com.spotify.music", // Spotify 9.1.86 and later: car library 1.9 rejects Gearslip
)
fun screenBroken(packageName: String): Boolean = packageName in brokenScreen
/** /**
* Registers a car template service but never gives Gearslip a usable screen - left off the * Registers a car template service but never gives Gearslip a usable screen - left off the
* launcher entirely rather than shown as a tile that goes nowhere. * launcher entirely rather than shown as a tile that goes nowhere.
@@ -27,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
@@ -35,7 +60,15 @@ object KnownApps {
private val broken = setOf( private val broken = setOf(
"com.google.android.apps.messaging", // Google Messages "com.google.android.apps.messaging", // Google Messages
"com.waze", // Waze "com.waze", // Waze
"com.generalmagic.magicearth", // Magic Earth: never gets past connecting
) )
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. */
private val partial = setOf(
"app.vela", // Vela Maps: connects and draws, but rough enough not to call it working yet
)
fun isPartial(packageName: String): Boolean = packageName in partial
} }
@@ -9,7 +9,10 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.content.pm.ServiceInfo import android.content.pm.ServiceInfo
import android.os.Handler
import android.os.IBinder import android.os.IBinder
import android.os.Looper
import app.seb3thehacker.gearslip.GearslipLog
/** /**
* Lets a hosted navigation app keep receiving the phone's location while Gearslip is not on screen. * Lets a hosted navigation app keep receiving the phone's location while Gearslip is not on screen.
@@ -34,10 +37,29 @@ class LocationKeepAlive : Service() {
.setContentTitle("Gearslip is sharing location with the car map") .setContentTitle("Gearslip is sharing location with the car map")
.setOngoing(true) .setOngoing(true)
.build() .build()
// The system can refuse a location-type foreground service if Gearslip is no longer in
// an eligible state by the time this runs - the phone screen locking mid-session is the
// common case. That refusal throws, and left uncaught it takes the whole app down with
// it; a nav app losing its background location fix is a small loss next to that.
runCatching {
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION) startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION)
}.onSuccess {
settle(State.ON)
}.onFailure {
GearslipLog.w("location keep-alive: could not start the foreground service - ${it.message}")
settle(State.FAILED)
stopSelf()
}
return START_NOT_STICKY return START_NOT_STICKY
} }
override fun onDestroy() {
if (state == State.ON) state = State.OFF
super.onDestroy()
}
enum class State { OFF, STARTING, ON, FAILED }
companion object { companion object {
private const val CHANNEL = "gearslip_location" private const val CHANNEL = "gearslip_location"
private const val NOTIFICATION_ID = 44 private const val NOTIFICATION_ID = 44
@@ -45,14 +67,73 @@ class LocationKeepAlive : Service() {
/** Public flag value; the constant itself is hidden from the SDK. */ /** Public flag value; the constant itself is hidden from the SDK. */
const val BIND_INCLUDE_CAPABILITIES = 0x1000 const val BIND_INCLUDE_CAPABILITIES = 0x1000
fun start(context: Context) { private val main = Handler(Looper.getMainLooper())
val granted = context.checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) ==
PackageManager.PERMISSION_GRANTED @Volatile var state = State.OFF
if (granted) runCatching { context.startForegroundService(Intent(context, LocationKeepAlive::class.java)) } private set
/** Each connected car app holds one; the service stops only when the last lets go. */
private var holders = 0
private val waiting = mutableListOf<() -> Unit>()
/**
* Takes a hold on the service, starting it if it isn't running, and calls [ready] (on the
* main thread) once it's running, has failed, or [timeoutMs] has passed - whichever comes
* first. A nav app bound before this has settled has no location capability to borrow,
* and some (MapQuest) crash outright when their own location service is refused.
*/
fun acquire(context: Context, timeoutMs: Long = 3_000, ready: () -> Unit) {
main.post {
holders++
if (state == State.OFF || state == State.FAILED) begin(context.applicationContext)
if (state != State.STARTING) {
ready()
return@post
}
var done = false
val once = { if (!done) { done = true; ready() } }
waiting += once
main.postDelayed({
if (!done) GearslipLog.w("location keep-alive: still not running after ${timeoutMs}ms, binding anyway")
waiting.remove(once)
once()
}, timeoutMs)
}
} }
fun stop(context: Context) { /** Lets go of one hold; the last one stops the service. */
fun release(context: Context) {
main.post {
holders = (holders - 1).coerceAtLeast(0)
if (holders > 0) return@post
context.stopService(Intent(context, LocationKeepAlive::class.java)) context.stopService(Intent(context, LocationKeepAlive::class.java))
state = State.OFF
}
}
private fun begin(context: Context) {
val granted = context.checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
if (!granted) {
GearslipLog.w("location keep-alive: no location permission, car maps won't get a fix in the background")
settle(State.FAILED)
return
}
state = State.STARTING
runCatching { context.startForegroundService(Intent(context, LocationKeepAlive::class.java)) }
.onFailure {
GearslipLog.w("location keep-alive: start refused - ${it.message}")
settle(State.FAILED)
}
}
private fun settle(to: State) {
main.post {
state = to
val run = waiting.toList()
waiting.clear()
run.forEach { it() }
}
} }
} }
} }
@@ -0,0 +1,47 @@
package app.seb3thehacker.gearslip.media
import android.graphics.Bitmap
import android.graphics.Color
import androidx.core.graphics.scale
/**
* The colour a piece of album art reads as, for tinting what sits around it. Not the plain
* average, which turns most covers brown-grey: the art is shrunk to a few hundred pixels,
* sorted into hue buckets weighted by how saturated and bright each pixel is, and the heaviest
* bucket wins. Art with no real colour in it (black and white, near-grey) gives null.
*/
object ArtColor {
private const val SAMPLE = 24
private const val BUCKETS = 24
/** Hue in degrees and saturation, 0..1; null when the art has no colour worth picking. */
class Tint(val hue: Float, val saturation: Float)
fun of(art: Bitmap): Tint? {
// A hardware bitmap can't be read pixel by pixel; copy it into memory first.
val readable = if (art.config == Bitmap.Config.HARDWARE) art.copy(Bitmap.Config.ARGB_8888, false) else art
val small = runCatching { readable?.scale(SAMPLE, SAMPLE) }.getOrNull() ?: return null
val weight = FloatArray(BUCKETS)
val hueSum = FloatArray(BUCKETS)
val satSum = FloatArray(BUCKETS)
val hsv = FloatArray(3)
for (y in 0 until small.height) for (x in 0 until small.width) {
Color.colorToHSV(small.getPixel(x, y), hsv)
val (h, s, v) = Triple(hsv[0], hsv[1], hsv[2])
// Near-grey and near-black pixels (letterbox bars among them) say nothing about the art's colour.
if (s < 0.1f || v < 0.08f) continue
val w = s * v
val b = (h / 360f * BUCKETS).toInt().coerceIn(0, BUCKETS - 1)
weight[b] += w
hueSum[b] += h * w
satSum[b] += s * w
}
if (small !== readable) small.recycle()
if (readable !== art) readable?.recycle()
val best = weight.indices.maxBy { weight[it] }
// A sliver of colour in an otherwise grey cover shouldn't tint the whole panel.
if (weight[best] < SAMPLE * SAMPLE * 0.005f) return null
return Tint(hueSum[best] / weight[best], satSum[best] / weight[best])
}
}
@@ -2,8 +2,13 @@ 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.support.v4.media.session.MediaControllerCompat
import android.support.v4.media.session.MediaSessionCompat
import android.support.v4.media.session.PlaybackStateCompat
import android.media.browse.MediaBrowser import android.media.browse.MediaBrowser
import android.media.session.MediaController import android.media.session.MediaController
import android.media.session.MediaSession import android.media.session.MediaSession
@@ -15,6 +20,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
@@ -32,7 +38,8 @@ class MediaEntry(
val playable: Boolean, val playable: Boolean,
) )
class CustomAction(val id: String, val name: String, val extras: Bundle?) /** [icon] is a drawable id inside the media app's own package, not Gearslip's; 0 for none. */
class CustomAction(val id: String, val name: String, val extras: Bundle?, val icon: Int = 0)
/** One entry in the app's own queue - not its library, whatever it has lined up to play next. */ /** One entry in the app's own queue - not its library, whatever it has lined up to play next. */
class QueueTrack( class QueueTrack(
@@ -60,7 +67,40 @@ class NowPlaying(
val error: String? = null, val error: String? = null,
/** Which [QueueTrack.queueId] is playing now, so "up next" can show what follows it. */ /** Which [QueueTrack.queueId] is playing now, so "up next" can show what follows it. */
val activeQueueItemId: Long = MediaSession.QueueItem.UNKNOWN_ID.toLong(), val activeQueueItemId: Long = MediaSession.QueueItem.UNKNOWN_ID.toLong(),
/** The track's heart rating, for apps that take one; null when the app doesn't rate by heart. */
val heart: Boolean? = null,
) { ) {
/** The app's own button for [words], matched on its id or name - how most apps offer like, shuffle and repeat. */
fun customFor(vararg words: String) = custom.firstOrNull { a ->
words.any { a.id.contains(it, ignoreCase = true) || a.name.contains(it, ignoreCase = true) }
}
/** Thumbs-down buttons say "like" too ("Dislike"), so those are skipped. */
val likeAction
get() = custom.firstOrNull { a ->
val text = "${a.id} ${a.name}"
listOf("like", "favorit", "favourit", "heart", "love", "thumb").any { text.contains(it, ignoreCase = true) } &&
listOf("dislike", "down").none { text.contains(it, ignoreCase = true) }
}
val shuffleAction get() = customFor("shuffle")
/** The app's buttons that aren't like, shuffle or repeat - "Start radio" and the like. */
val otherActions: List<CustomAction>
get() {
val known = listOf(likeAction, shuffleAction, repeatAction)
return custom.filter { a -> known.none { it === a } }
}
val repeatAction get() = customFor("repeat", "loop")
/**
* Liked or not; null when the app offers no way to like at all. An app's own like button
* only says which way it goes next by its name ("Remove from Liked Songs"), so that's read.
*/
val liked: Boolean?
get() = heart ?: likeAction?.name?.let { name ->
listOf("remove", "unlike", "unfav", "unheart").any { name.contains(it, ignoreCase = true) }
}
val playing get() = state == PlaybackState.STATE_PLAYING || state == PlaybackState.STATE_BUFFERING val playing get() = state == PlaybackState.STATE_PLAYING || state == PlaybackState.STATE_BUFFERING
val hasTrack get() = title.isNotEmpty() || state != PlaybackState.STATE_NONE val hasTrack get() = title.isNotEmpty() || state != PlaybackState.STATE_NONE
@@ -104,7 +144,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()
@@ -124,8 +164,47 @@ 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()
/** Shuffle and repeat, as [PlaybackStateCompat]'s modes; [PlaybackStateCompat.SHUFFLE_MODE_INVALID] when unknown. */
class Modes(
val shuffle: Int = PlaybackStateCompat.SHUFFLE_MODE_INVALID,
val repeat: Int = PlaybackStateCompat.REPEAT_MODE_INVALID,
)
private val _modes = MutableStateFlow(Modes())
val modes: StateFlow<Modes> = _modes.asStateFlow()
private var browser: MediaBrowser? = null private var browser: MediaBrowser? = null
private var controller: MediaController? = null private var controller: MediaController? = null
/**
* The same session through the support library's controller - the framework one has no
* shuffle or repeat calls at all. Every app built on MediaSessionCompat or Media3 answers
* these; one on the bare framework API never reports a mode, and the buttons stay off.
*/
private var compat: MediaControllerCompat? = null
private val compatCallback = object : MediaControllerCompat.Callback() {
override fun onSessionReady() = refreshModes()
override fun onShuffleModeChanged(shuffleMode: Int) = refreshModes()
override fun onRepeatModeChanged(repeatMode: Int) = refreshModes()
}
private fun attach(c: MediaController) {
controller = c
c.registerCallback(callback)
update(c.metadata, c.playbackState)
_queue.value = queueOf(c.queue)
compat = runCatching {
MediaControllerCompat(context, MediaSessionCompat.Token.fromToken(c.sessionToken)).also {
it.registerCallback(compatCallback, main)
}
}.onFailure { GearslipLog.w("media: no support-library view of the session: ${it.message}") }.getOrNull()
refreshModes()
}
private fun refreshModes() {
val c = compat
_modes.value = if (c == null) Modes() else Modes(shuffle = c.shuffleMode, repeat = c.repeatMode)
}
private var subscribed: String? = null private var subscribed: String? = null
private var rootId: String? = null private var rootId: String? = null
private val path = ArrayDeque<Pair<String, String>>() // id to title private val path = ArrayDeque<Pair<String, String>>() // id to title
@@ -159,7 +238,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)
@@ -172,22 +251,25 @@ 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
} }
GearslipLog.i("media: controlling ${app.label} through its session; its library is not shared") GearslipLog.i("media: controlling ${app.label} through its session; its library is not shared")
controller = session.also { attach(session)
it.registerCallback(callback)
update(it.metadata, it.playbackState)
_queue.value = queueOf(it.queue)
}
_browse.value = BrowseState(loading = false, unavailable = true) _browse.value = BrowseState(loading = false, unavailable = true)
_phase.value = Phase.READY _phase.value = Phase.READY
} }
@@ -195,11 +277,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
GearslipLog.i("media: connected to ${app.label}, root=${mb.root}") GearslipLog.i("media: connected to ${app.label}, root=${mb.root}")
controller = MediaController(context, mb.sessionToken).also { attach(MediaController(context, mb.sessionToken))
it.registerCallback(callback)
update(it.metadata, it.playbackState)
_queue.value = queueOf(it.queue)
}
rootId = mb.root rootId = mb.root
path.clear() path.clear()
_phase.value = Phase.READY _phase.value = Phase.READY
@@ -241,9 +319,12 @@ class CarMedia(private val context: Context) {
speed = state?.playbackSpeed ?: 1f, speed = state?.playbackSpeed ?: 1f,
updatedAt = state?.lastPositionUpdateTime ?: 0, updatedAt = state?.lastPositionUpdateTime ?: 0,
actions = state?.actions ?: 0, actions = state?.actions ?: 0,
custom = state?.customActions.orEmpty().map { CustomAction(it.action, it.name.toString(), it.extras) }, custom = state?.customActions.orEmpty().map { CustomAction(it.action, it.name.toString(), it.extras, it.icon) },
error = state?.errorMessage?.toString(), error = state?.errorMessage?.toString(),
activeQueueItemId = state?.activeQueueItemId ?: MediaSession.QueueItem.UNKNOWN_ID.toLong(), activeQueueItemId = state?.activeQueueItemId ?: MediaSession.QueueItem.UNKNOWN_ID.toLong(),
heart = if (controller?.ratingType == Rating.RATING_HEART) {
metadata?.getRating(MediaMetadata.METADATA_KEY_USER_RATING)?.takeIf { it.isRated }?.hasHeart() ?: false
} else null,
) )
} }
@@ -320,9 +401,40 @@ 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)
/** 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() {
val now = _now.value
now.likeAction?.let { custom(it); return }
now.heart?.let { controller?.transportControls?.setRating(Rating.newHeartRating(!it)) }
}
fun toggleShuffle() {
_now.value.shuffleAction?.let { custom(it); return }
val next = if (_modes.value.shuffle == PlaybackStateCompat.SHUFFLE_MODE_NONE) {
PlaybackStateCompat.SHUFFLE_MODE_ALL
} else {
PlaybackStateCompat.SHUFFLE_MODE_NONE
}
compat?.transportControls?.setShuffleMode(next)
}
/** Off, then the whole queue, then this one track, then off again - the order every player uses. */
fun cycleRepeat() {
_now.value.repeatAction?.let { custom(it); return }
val next = when (_modes.value.repeat) {
PlaybackStateCompat.REPEAT_MODE_NONE -> PlaybackStateCompat.REPEAT_MODE_ALL
PlaybackStateCompat.REPEAT_MODE_ALL, PlaybackStateCompat.REPEAT_MODE_GROUP -> PlaybackStateCompat.REPEAT_MODE_ONE
else -> PlaybackStateCompat.REPEAT_MODE_NONE
}
compat?.transportControls?.setRepeatMode(next)
}
fun disconnect() { fun disconnect() {
connecting = null connecting = null
main.removeCallbacksAndMessages(null) main.removeCallbacksAndMessages(null)
runCatching { compat?.unregisterCallback(compatCallback) }
compat = null
_modes.value = Modes()
runCatching { controller?.unregisterCallback(callback) } runCatching { controller?.unregisterCallback(callback) }
runCatching { subscribed?.let { browser?.unsubscribe(it) } } runCatching { subscribed?.let { browser?.unsubscribe(it) } }
runCatching { browser?.disconnect() } runCatching { browser?.disconnect() }
@@ -334,8 +446,38 @@ class CarMedia(private val context: Context) {
_queue.value = emptyList() _queue.value = emptyList()
} }
/**
* 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
} }
} }
@@ -1,13 +1,16 @@
package app.seb3thehacker.gearslip.media package app.seb3thehacker.gearslip.media
import android.content.Context
import app.seb3thehacker.gearslip.GearslipLog import app.seb3thehacker.gearslip.GearslipLog
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import java.io.File
import java.net.HttpURLConnection import java.net.HttpURLConnection
import java.net.URL import java.net.URL
import java.net.URLEncoder import java.net.URLEncoder
import java.security.MessageDigest
class LyricLine(val timeMs: Long, val text: String) class LyricLine(val timeMs: Long, val text: String)
@@ -20,24 +23,67 @@ class LyricsResult(val lines: List<LyricLine>, val synced: Boolean)
* Media apps almost never publish lyrics in their session metadata, so this is the only general * Media apps almost never publish lyrics in their session metadata, so this is the only general
* source. It sends the track's title, artist, album and length to lrclib.net, which is why the * source. It sends the track's title, artist, album and length to lrclib.net, which is why the
* lookup happens only when the driver opens the lyrics view and never in the background. * lookup happens only when the driver opens the lyrics view and never in the background.
*
* Results are cached twice over: in memory for the life of the process, and on disk under the
* cache directory so a song looked up on a past drive doesn't need the network again on this one.
* A miss (no lyrics found) is cached in memory only - not on disk - so a transient lookup failure
* doesn't harden into a permanent "no lyrics" the next time the same track plays.
*/ */
object Lyrics { object Lyrics {
private val cache = HashMap<String, LyricsResult?>() private val cache = HashMap<String, LyricsResult?>()
suspend fun find(title: String, artist: String, album: String, durationMs: Long): LyricsResult? { suspend fun find(context: Context, title: String, artist: String, album: String, durationMs: Long): LyricsResult? {
if (title.isBlank()) return null if (title.isBlank()) return null
val key = "$title|$artist|$album|$durationMs" val key = "$title|$artist|$album|$durationMs"
synchronized(cache) { if (cache.containsKey(key)) return cache[key] } synchronized(cache) { if (cache.containsKey(key)) return cache[key] }
val fromDisk = withContext(Dispatchers.IO) { readDisk(context, key) }
if (fromDisk != null) {
synchronized(cache) { cache[key] = fromDisk }
return fromDisk
}
val result = withContext(Dispatchers.IO) { val result = withContext(Dispatchers.IO) {
runCatching { runCatching {
exact(title, artist, album, durationMs) ?: search(title, artist) exact(title, artist, album, durationMs) ?: search(title, artist)
}.onFailure { GearslipLog.w("lyrics: lookup failed: ${it.message}") }.getOrNull() }.onFailure { GearslipLog.w("lyrics: lookup failed: ${it.message}") }.getOrNull()
} }
synchronized(cache) { cache[key] = result } synchronized(cache) { cache[key] = result }
if (result != null) withContext(Dispatchers.IO) { writeDisk(context, key, result) }
return result return result
} }
private fun cacheFile(context: Context, key: String): File {
val digest = MessageDigest.getInstance("SHA-1").digest(key.toByteArray())
val name = digest.joinToString("") { "%02x".format(it) }
return File(context.cacheDir, "lyrics").apply { mkdirs() }.resolve("$name.json")
}
private fun readDisk(context: Context, key: String): LyricsResult? = runCatching {
val file = cacheFile(context, key)
if (!file.exists()) return null
val json = JSONObject(file.readText())
val lines = json.getJSONArray("lines").let { arr ->
(0 until arr.length()).map { i ->
val line = arr.getJSONObject(i)
LyricLine(line.getLong("t"), line.getString("x"))
}
}
LyricsResult(lines, json.getBoolean("synced"))
}.getOrNull()
private fun writeDisk(context: Context, key: String, result: LyricsResult) {
runCatching {
val lines = JSONArray()
result.lines.forEach { line ->
lines.put(JSONObject().put("t", line.timeMs).put("x", line.text))
}
val json = JSONObject().put("synced", result.synced).put("lines", lines)
cacheFile(context, key).writeText(json.toString())
}.onFailure { GearslipLog.w("lyrics: could not write disk cache: ${it.message}") }
}
private fun exact(title: String, artist: String, album: String, durationMs: Long): LyricsResult? { private fun exact(title: String, artist: String, album: String, durationMs: Long): LyricsResult? {
if (artist.isBlank()) return null if (artist.isBlank()) return null
val query = buildString { val query = buildString {
@@ -1,6 +1,7 @@
package app.seb3thehacker.gearslip.media package app.seb3thehacker.gearslip.media
import app.seb3thehacker.gearslip.host.AndroidAuto import app.seb3thehacker.gearslip.host.AndroidAuto
import app.seb3thehacker.gearslip.host.CarDescriptor
import app.seb3thehacker.gearslip.host.KnownApps import app.seb3thehacker.gearslip.host.KnownApps
import android.content.ComponentName import android.content.ComponentName
import android.content.Context import android.content.Context
@@ -19,15 +20,13 @@ class MediaApp(
/** /**
* Finds media apps that opted in to Android Auto. * Finds media apps that opted in to Android Auto.
* *
* An app is a car media app when it exports a MediaBrowserService *and* declares the * An app is a car media app when it exports a MediaBrowserService *and* its car descriptor (see
* `com.google.android.gms.car.application` meta-data pointing at a descriptor with * [CarDescriptor]) lists `media`. The descriptor is what separates Deezer from a phone-only
* `<uses name="media"/>`. The second half is what separates Deezer from a phone-only player * player that merely exposes a browser service to Bluetooth and Wear - and from messengers like
* that merely exposes a browser service to Bluetooth and Wear. * Signal and Molly, which declare only `notification` but keep a browser service for voice notes.
*/ */
object MediaCatalog { object MediaCatalog {
private const val CAR_META = "com.google.android.gms.car.application"
fun installed(context: Context): List<MediaApp> { fun installed(context: Context): List<MediaApp> {
val manager = context.packageManager val manager = context.packageManager
return manager.queryIntentServices(Intent(MediaBrowserService.SERVICE_INTERFACE), PackageManager.GET_META_DATA) return manager.queryIntentServices(Intent(MediaBrowserService.SERVICE_INTERFACE), PackageManager.GET_META_DATA)
@@ -35,7 +34,7 @@ object MediaCatalog {
.filter { it.serviceInfo.packageName != context.packageName } .filter { it.serviceInfo.packageName != context.packageName }
.filterNot { AndroidAuto.matches(it.serviceInfo.packageName, it.loadLabel(manager).toString()) } .filterNot { AndroidAuto.matches(it.serviceInfo.packageName, it.loadLabel(manager).toString()) }
.filterNot { KnownApps.isHidden(it.serviceInfo.packageName) } .filterNot { KnownApps.isHidden(it.serviceInfo.packageName) }
.filter { declaresCar(manager, it.serviceInfo) } .filter { declaresMedia(manager, it.serviceInfo) }
.map { .map {
val info = it.serviceInfo val info = it.serviceInfo
MediaApp( MediaApp(
@@ -49,11 +48,10 @@ object MediaCatalog {
.toList() .toList()
} }
private fun declaresCar(manager: PackageManager, service: android.content.pm.ServiceInfo): Boolean { /** A descriptor that can't be read keeps the app, rather than dropping a real player over an odd manifest. */
if (service.metaData?.containsKey(CAR_META) == true) return true private fun declaresMedia(manager: PackageManager, service: android.content.pm.ServiceInfo): Boolean {
return runCatching { if (CarDescriptor.meta(manager, service.packageName, service) == null) return false
manager.getApplicationInfo(service.packageName, PackageManager.GET_META_DATA) val uses = CarDescriptor.uses(manager, service.packageName, service)
.metaData?.containsKey(CAR_META) == true return uses.isNullOrEmpty() || "media" in uses
}.getOrDefault(false)
} }
} }
@@ -15,6 +15,9 @@ import kotlinx.coroutines.flow.update
/** How a notification can be answered: the action the app attached, and the field it fills. */ /** How a notification can be answered: the action the app attached, and the field it fills. */
class ReplyTarget(val action: Notification.Action, val input: android.app.RemoteInput) class ReplyTarget(val action: Notification.Action, val input: android.app.RemoteInput)
/** One message of a conversation. [sender] is null for the phone's owner ("You"). */
data class ChatLine(val sender: String?, val text: String, val at: Long)
/** /**
* One notification as the car shows it. Only what the car UI needs is kept - the phone's * One notification as the car shows it. Only what the car UI needs is kept - the phone's
* Notification object stays with the listener, apart from the reply action. * Notification object stays with the listener, apart from the reply action.
@@ -24,6 +27,7 @@ data class CarNotification(
val key: String, val key: String,
/** Unique per post, so an update to an already-shown notification still counts as new. */ /** Unique per post, so an update to an already-shown notification still counts as new. */
val id: Long, val id: Long,
val packageName: String,
val appLabel: String, val appLabel: String,
val title: String, val title: String,
val text: String, val text: String,
@@ -32,6 +36,16 @@ data class CarNotification(
/** Null when the app offered no reply, or once the notification has left the phone. */ /** Null when the app offered no reply, or once the notification has left the phone. */
val reply: ReplyTarget?, val reply: ReplyTarget?,
val replied: Boolean = false, val replied: Boolean = false,
/** The thread's recent messages, oldest first, when the app posts it as a conversation. */
val lines: List<ChatLine> = emptyList(),
val isGroup: Boolean = false,
/** The app's own "mark as read" action, fired once the driver has opened the thread. */
val markRead: Notification.Action? = null,
val read: Boolean = false,
/** How many posts this popup stands for: new messages in one chat update it, not queue. */
val burst: Int = 1,
/** Uptime when it popped up, which sets when the popup goes. 0 until it's shown. */
val shownAt: Long = 0L,
) )
/** /**
@@ -68,7 +82,10 @@ object CarNotifications {
fun nextId(): Long = nextId++ fun nextId(): Long = nextId++
/** [quiet] adds to the history without a popup or unread badge (used to seed on connect). */ /**
* [quiet] adds to the history without a popup or unread badge: used to seed on connect, and
* for anything that isn't worth interrupting a drive for (see the listener's filter).
*/
fun post(notification: CarNotification, quiet: Boolean) { fun post(notification: CarNotification, quiet: Boolean) {
_history.update { list -> _history.update { list ->
(listOf(notification) + list.filterNot { it.key == notification.key }).take(MAX_HISTORY) (listOf(notification) + list.filterNot { it.key == notification.key }).take(MAX_HISTORY)
@@ -76,20 +93,30 @@ object CarNotifications {
val echo = notification.key == lastReplyKey && val echo = notification.key == lastReplyKey &&
System.currentTimeMillis() - lastReplyAt < ECHO_WINDOW_MS System.currentTimeMillis() - lastReplyAt < ECHO_WINDOW_MS
if (quiet || echo) return if (quiet || echo) return
// History still keeps it; only the popup is held back while driving. val now = android.os.SystemClock.uptimeMillis()
if (CarMotion.moving) return _popup.update { shown ->
_popup.value = notification if (shown?.key == notification.key) {
notification.copy(burst = shown.burst + 1, shownAt = now)
} else {
notification.copy(shownAt = now)
}
}
} }
/** The phone dismissed it. It stays in history, but its reply intent is no longer trustworthy. */ /** The phone dismissed it. It stays in history, but its reply intent is no longer trustworthy. */
fun gone(key: String) { fun gone(key: String) {
_history.update { list -> list.map { if (it.key == key) it.copy(reply = null) else it } } _history.update { list -> list.map { if (it.key == key) it.copy(reply = null, markRead = null) else it } }
} }
fun dismissPopup(id: Long) { fun dismissPopup(id: Long) {
_popup.update { if (it?.id == id) null else it } _popup.update { if (it?.id == id) null else it }
} }
/** While an app's own messages screen is up, its new messages land there, not in a popup. */
fun dismissPopupFrom(packageName: String) {
_popup.update { if (it?.packageName == packageName) null else it }
}
/** Called while the notification screen is showing: nothing is unread there. */ /** Called while the notification screen is showing: nothing is unread there. */
fun markRead() { fun markRead() {
_unread.value = 0 _unread.value = 0
@@ -102,6 +129,22 @@ object CarNotifications {
_unread.value = 0 _unread.value = 0
} }
/**
* Fires the app's own "mark as read", which also clears it from the phone - and with it the
* reply action, so this is the driver's call, not something done on opening the thread.
*/
fun markThreadRead(context: Context, notification: CarNotification): Boolean {
val action = notification.markRead ?: return false
return try {
action.actionIntent.send(context, 0, Intent())
_history.update { list -> list.map { if (it.key == notification.key) it.copy(read = true, markRead = null) else it } }
true
} catch (e: PendingIntent.CanceledException) {
GearslipLog.w("notify: ${notification.appLabel} withdrew its mark-as-read")
false
}
}
/** Fills the app's reply field and fires its action. Returns false if the app has withdrawn it. */ /** Fills the app's reply field and fires its action. Returns false if the app has withdrawn it. */
fun reply(context: Context, notification: CarNotification, text: String): Boolean { fun reply(context: Context, notification: CarNotification, text: String): Boolean {
val target = notification.reply ?: return false val target = notification.reply ?: return false
@@ -3,6 +3,7 @@ package app.seb3thehacker.gearslip.notify
import android.app.Notification import android.app.Notification
import android.service.notification.NotificationListenerService import android.service.notification.NotificationListenerService
import android.service.notification.StatusBarNotification import android.service.notification.StatusBarNotification
import androidx.core.app.NotificationCompat
import androidx.core.graphics.drawable.toBitmap import androidx.core.graphics.drawable.toBitmap
/** /**
@@ -29,7 +30,10 @@ class GearslipNotificationListener : NotificationListenerService() {
override fun onNotificationRemoved(sbn: StatusBarNotification) = CarNotifications.gone(sbn.key) override fun onNotificationRemoved(sbn: StatusBarNotification) = CarNotifications.gone(sbn.key)
private fun ingest(sbn: StatusBarNotification, quiet: Boolean) { private fun ingest(sbn: StatusBarNotification, quiet: Boolean) {
if (sbn.packageName == packageName) return // Gearslip's own notifications are skipped, except a dev build's test message.
if (sbn.packageName == packageName &&
!(app.seb3thehacker.gearslip.BuildConfig.DEBUG && sbn.notification.channelId == TestMessage.CHANNEL)
) return
val n = sbn.notification val n = sbn.notification
// Ongoing entries (media, navigation, downloads) and group headers are status, not news. // Ongoing entries (media, navigation, downloads) and group headers are status, not news.
if (n.flags and (Notification.FLAG_ONGOING_EVENT or Notification.FLAG_GROUP_SUMMARY) != 0) return if (n.flags and (Notification.FLAG_ONGOING_EVENT or Notification.FLAG_GROUP_SUMMARY) != 0) return
@@ -40,22 +44,56 @@ class GearslipNotificationListener : NotificationListenerService() {
val text = (extras.getCharSequence(Notification.EXTRA_BIG_TEXT) val text = (extras.getCharSequence(Notification.EXTRA_BIG_TEXT)
?: extras.getCharSequence(Notification.EXTRA_TEXT))?.toString().orEmpty() ?: extras.getCharSequence(Notification.EXTRA_TEXT))?.toString().orEmpty()
if (title.isBlank() && text.isBlank()) return if (title.isBlank() && text.isBlank()) return
// The player and the map already show these.
if (extras.containsKey(Notification.EXTRA_MEDIA_SESSION) || n.category == Notification.CATEGORY_NAVIGATION) return
val style = runCatching { NotificationCompat.MessagingStyle.extractMessagingStyleFromNotification(n) }.getOrNull()
// In MessagingStyle a message with no sender is the phone owner's own.
val me = style?.user?.name?.toString()
val lines = style?.messages.orEmpty().takeLast(MAX_LINES).mapNotNull { m ->
val body = m.text?.toString()?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
val sender = m.person?.name?.toString()?.takeIf { it.isNotBlank() && it != me }
ChatLine(sender, body, m.timestamp)
}
CarNotifications.post( CarNotifications.post(
CarNotification( CarNotification(
key = sbn.key, key = sbn.key,
id = CarNotifications.nextId(), id = CarNotifications.nextId(),
packageName = sbn.packageName,
appLabel = appLabel(sbn.packageName), appLabel = appLabel(sbn.packageName),
title = title, title = style?.conversationTitle?.toString()?.takeIf { it.isNotBlank() } ?: title,
text = text, text = lines.lastOrNull()?.text ?: text,
postedAt = sbn.postTime, postedAt = sbn.postTime,
icon = icon(sbn), icon = icon(sbn),
reply = replyOf(n), reply = replyOf(n),
lines = lines,
isGroup = style?.isGroupConversation == true,
markRead = markReadOf(n),
), ),
quiet, quiet || !alerts(sbn, style != null),
) )
} }
/**
* Whether a notification is worth a popup in the car: messages, missed calls, alarms and
* reminders. Everything else still lands in the dashboard's list, just without interrupting.
* An incoming call is left out too, since the call card already covers it.
*/
private fun alerts(sbn: StatusBarNotification, conversation: Boolean): Boolean {
if (sbn.packageName in SYSTEM_PACKAGES) return false
val ranking = Ranking()
if (currentRanking?.getRanking(sbn.key, ranking) == true &&
ranking.importance < android.app.NotificationManager.IMPORTANCE_DEFAULT
) return false
val n = sbn.notification
return conversation || replyOf(n) != null || n.category in ALERT_CATEGORIES
}
private fun markReadOf(n: Notification): Notification.Action? =
(n.actions.orEmpty().toList() + Notification.WearableExtender(n).actions)
.firstOrNull { it.actionIntent != null && it.semanticAction == Notification.Action.SEMANTIC_ACTION_MARK_AS_READ }
private fun appLabel(packageName: String): String = runCatching { private fun appLabel(packageName: String): String = runCatching {
packageManager.getApplicationLabel(packageManager.getApplicationInfo(packageName, 0)).toString() packageManager.getApplicationLabel(packageManager.getApplicationInfo(packageName, 0)).toString()
}.getOrDefault(packageName.substringAfterLast('.')) }.getOrDefault(packageName.substringAfterLast('.'))
@@ -79,6 +117,15 @@ class GearslipNotificationListener : NotificationListenerService() {
private companion object { private companion object {
const val ICON_PX = 96 const val ICON_PX = 96
const val MAX_LINES = 8
val ALERT_CATEGORIES = setOf(
Notification.CATEGORY_MESSAGE,
Notification.CATEGORY_MISSED_CALL,
Notification.CATEGORY_ALARM,
Notification.CATEGORY_REMINDER,
Notification.CATEGORY_EVENT,
)
val SYSTEM_PACKAGES = setOf("android", "com.android.systemui", "com.android.vending", "com.google.android.gms")
val QUIET_CATEGORIES = setOf( val QUIET_CATEGORIES = setOf(
Notification.CATEGORY_TRANSPORT, Notification.CATEGORY_TRANSPORT,
Notification.CATEGORY_PROGRESS, Notification.CATEGORY_PROGRESS,
@@ -0,0 +1,39 @@
package app.seb3thehacker.gearslip.notify
import android.content.Context
import android.content.Intent
import app.seb3thehacker.gearslip.host.AndroidAuto
import app.seb3thehacker.gearslip.host.CarDescriptor
import app.seb3thehacker.gearslip.host.KnownApps
/** A messaging app that has told the system it belongs in a car. */
class MessagingApp(val packageName: String, val label: String)
/**
* Finds messaging apps that opted in to Android Auto: those whose car descriptor lists
* `notification` (Signal, Molly, WhatsApp, Telegram, Google Messages). Android Auto has no
* screen of their own to open; it shows their conversations from the notifications they post,
* and so does Gearslip.
*/
object MessagingCatalog {
fun installed(context: Context): List<MessagingApp> {
val manager = context.packageManager
val launchable = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER)
return manager.queryIntentActivities(launchable, 0)
.asSequence()
.map { it.activityInfo.packageName to it.loadLabel(manager).toString() }
.distinctBy { it.first }
.filter { (pkg, _) -> pkg != context.packageName }
.filterNot { (pkg, label) -> AndroidAuto.matches(pkg, label) || KnownApps.isHidden(pkg) }
.filter { (pkg, _) -> CarDescriptor.uses(manager, pkg)?.contains("notification") == true }
.map { (pkg, _) ->
val label = runCatching {
manager.getApplicationLabel(manager.getApplicationInfo(pkg, 0)).toString()
}.getOrDefault(pkg)
MessagingApp(pkg, label)
}
.sortedBy { it.label.lowercase() }
.toList()
}
}
@@ -0,0 +1,112 @@
package app.seb3thehacker.gearslip.notify
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.Person
import androidx.core.app.RemoteInput
import app.seb3thehacker.gearslip.GearslipLog
/**
* Dev builds only: posts a chat message from Gearslip to itself, shaped like Signal's or
* WhatsApp's (MessagingStyle, a Reply action, a Mark as read action), so popups and voice reply
* can be tried without asking someone to text you. The listener lets these through only in a
* debug build; everything else Gearslip posts is still ignored.
*/
object TestMessage {
const val CHANNEL = "gearslip_test_message"
private const val NOTIFICATION_ID = 77
private const val KEY_REPLY = "reply"
private const val ACTION_REPLY = "app.seb3thehacker.gearslip.TEST_REPLY"
private const val ACTION_READ = "app.seb3thehacker.gearslip.TEST_READ"
private val SAMPLES = listOf(
"Alex" to "Running ten minutes late, save me a seat?",
"Sam" to "Are you still coming over tonight?",
"Jordan" to "Can you grab milk on the way home?",
"Riley" to "Call me when you're parked.",
)
private var next = 0
private val me = Person.Builder().setName("You").build()
fun post(context: Context) {
val (name, text) = SAMPLES[next++ % SAMPLES.size]
val sender = Person.Builder().setName(name).build()
val style = NotificationCompat.MessagingStyle(me)
.addMessage(text, System.currentTimeMillis(), sender)
show(context, style)
GearslipLog.i("test message: posted \"$text\" from $name")
}
private fun show(context: Context, style: NotificationCompat.MessagingStyle) {
val manager = context.getSystemService(NotificationManager::class.java)
// High importance, like a real messaging app's chats: lower ones never pop up in the car.
manager.createNotificationChannel(
NotificationChannel(CHANNEL, "Test messages", NotificationManager.IMPORTANCE_HIGH),
)
val replyAction = NotificationCompat.Action.Builder(
android.R.drawable.ic_menu_send, "Reply",
broadcast(context, ACTION_REPLY, mutable = true),
)
.addRemoteInput(RemoteInput.Builder(KEY_REPLY).setLabel("Reply").build())
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY)
.setShowsUserInterface(false)
.build()
val readAction = NotificationCompat.Action.Builder(
android.R.drawable.ic_menu_view, "Mark as read",
broadcast(context, ACTION_READ, mutable = false),
)
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_MARK_AS_READ)
.setShowsUserInterface(false)
.build()
val notification = NotificationCompat.Builder(context, CHANNEL)
.setSmallIcon(android.R.drawable.sym_action_chat)
.setStyle(style)
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.addAction(replyAction)
.addAction(readAction)
.setAutoCancel(true)
.build()
runCatching { NotificationManagerCompat.from(context).notify(NOTIFICATION_ID, notification) }
.onFailure { GearslipLog.w("test message: could not post (${it.message})") }
}
private fun broadcast(context: Context, action: String, mutable: Boolean): PendingIntent =
PendingIntent.getBroadcast(
context, action.hashCode(),
Intent(context, TestMessageReceiver::class.java).setAction(action),
PendingIntent.FLAG_UPDATE_CURRENT or
if (mutable) PendingIntent.FLAG_MUTABLE else PendingIntent.FLAG_IMMUTABLE,
)
internal fun onAction(context: Context, intent: Intent) {
when (intent.action) {
ACTION_REPLY -> {
val text = RemoteInput.getResultsFromIntent(intent)?.getCharSequence(KEY_REPLY)?.toString()
GearslipLog.i("test message: reply received: \"$text\"")
// Show the reply in the conversation, the way a chat app confirms it was sent.
val active = context.getSystemService(NotificationManager::class.java)
.activeNotifications.firstOrNull { it.id == NOTIFICATION_ID }?.notification
val style = active?.let { NotificationCompat.MessagingStyle.extractMessagingStyleFromNotification(it) }
?: NotificationCompat.MessagingStyle(me)
style.addMessage(text.orEmpty(), System.currentTimeMillis(), null as Person?)
show(context, style)
}
ACTION_READ -> {
GearslipLog.i("test message: marked as read")
NotificationManagerCompat.from(context).cancel(NOTIFICATION_ID)
}
}
}
}
class TestMessageReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) = TestMessage.onAction(context, intent)
}
@@ -0,0 +1,228 @@
package app.seb3thehacker.gearslip.speech
import android.Manifest
import android.annotation.SuppressLint
import android.content.Context
import android.content.pm.PackageManager
import android.content.res.AssetManager
import android.media.AudioFormat
import android.media.AudioManager
import android.media.AudioRecord
import android.media.MediaRecorder
import android.media.ToneGenerator
import android.os.Handler
import android.os.Looper
import android.os.SystemClock
import app.seb3thehacker.gearslip.GearslipLog
import java.util.Locale
import java.util.concurrent.Executors
import kotlin.math.log10
import kotlin.math.max
import kotlin.math.sqrt
/**
* Gearslip's own speech-to-text, so voice works on phones with no speech service (GrapheneOS ships
* 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.
*
* Whisper needs the whole phrase before it starts, so this records from the phone's microphone,
* 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.
*/
object SpeechEngine {
/** FUTO's ACFT Whisper tiny (Apache-2.0), re-quantized to 5 bits, inside the APK. */
private const val MODEL = "speech/tiny_acft_q5_1.bin"
/** The error for an engine that can't run at all, which callers may answer with a fallback. */
const val NOT_LOADED = "The speech model didn't load."
private const val RATE = 16_000
private const val FRAME = 480 // 30 ms
private const val MAX_MS = 15_000L
private const val NO_SPEECH_MS = 6_000L
private const val END_SILENCE_MS = 1_100L
private const val CALIBRATE_MS = 300L
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_MS = 150
/** 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 val worker = Executors.newSingleThreadExecutor { Thread(it, "gearslip-speech") }
private val main = Handler(Looper.getMainLooper())
private var libraryLoaded = false
private var handle = 0L
@Volatile private var run = 0
/** Loads the model ahead of time, so the first reply doesn't wait for it. */
fun warm(context: Context) {
val app = context.applicationContext
worker.execute { ensureLoaded(app) }
}
/**
* Listens for one phrase. [cue] plays a short tone first, so the driver knows when to talk.
* [onSpeaking] fires at the first word, [onHeard] when the driver stops and the words are being
* worked out; [onResult] gets the text, [onError] a sentence fit to show on the car screen.
*/
fun listen(
context: Context,
cue: Boolean = true,
onSpeaking: () -> Unit = {},
onHeard: () -> Unit,
onResult: (String) -> Unit,
onError: (String) -> Unit,
) {
val app = context.applicationContext
if (app.checkSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {
onError("Needs the microphone permission.")
return
}
val id = ++run
fun post(block: () -> Unit) { main.post { if (id == run) block() } }
worker.execute {
if (id != run) return@execute
if (!ensureLoaded(app)) return@execute post { onError(NOT_LOADED) }
if (cue) cue()
if (id != run) return@execute
val audio = try {
record(id) { post(onSpeaking) }
} catch (t: Throwable) {
GearslipLog.e("speech: recording failed", t)
return@execute post { onError("The microphone isn't available right now.") }
}
if (id != run) return@execute
if (audio == null) return@execute post { onError("Didn't catch that.") }
post(onHeard)
val started = SystemClock.uptimeMillis()
val text = clean(transcribe(audio))
GearslipLog.i(
"speech: ${audio.size * 1000L / RATE} ms of audio took ${SystemClock.uptimeMillis() - started} ms",
)
post { if (text.isEmpty()) onError("Didn't catch that.") else onResult(text) }
}
}
/** Stops listening; a phrase already being worked out is dropped. */
fun cancel() { run++ }
/**
* A short beep on the media stream, so it plays through the car like everything else, then a
* pause long enough for it to finish there before the microphone opens and hears it.
*/
private fun cue() {
val tone = runCatching { ToneGenerator(AudioManager.STREAM_MUSIC, CUE_VOLUME) }.getOrNull() ?: return
try {
tone.startTone(ToneGenerator.TONE_PROP_BEEP, CUE_MS)
Thread.sleep(CUE_MS + CUE_TAIL_MS)
} finally {
tone.release()
}
}
// --- recording --------------------------------------------------------------------------
/**
* 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.
*/
@SuppressLint("MissingPermission") // checked in listen()
private fun record(id: Int, onSpeaking: () -> Unit): FloatArray? {
val min = AudioRecord.getMinBufferSize(RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT)
val recorder = AudioRecord(
MediaRecorder.AudioSource.VOICE_RECOGNITION, RATE,
AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT, max(min, FRAME * 8),
)
check(recorder.state == AudioRecord.STATE_INITIALIZED) { "AudioRecord didn't initialize" }
val samples = FloatArray((RATE * MAX_MS / 1000).toInt())
var count = 0
val frame = ShortArray(FRAME)
var noise = 0.0
var noiseFrames = 0
var speechStart = -1
var lastSpeech = 0
recorder.startRecording()
try {
while (id == run && count + FRAME <= samples.size) {
val n = recorder.read(frame, 0, FRAME)
if (n <= 0) continue
var sum = 0.0
for (i in 0 until n) {
val v = frame[i] / 32768f
samples[count + i] = v
sum += v * v
}
count += n
val db = 20 * log10(sqrt(sum / n) + 1e-9)
val ms = count * 1000L / RATE
if (ms <= CALIBRATE_MS) {
noise += db; noiseFrames++
continue
}
val floor = if (noiseFrames > 0) noise / noiseFrames else -60.0
val talking = db > max(floor + 12, -45.0)
if (talking) {
if (speechStart < 0) {
speechStart = max(0, count - n - PRE_ROLL)
onSpeaking()
}
lastSpeech = count
}
if (speechStart < 0 && ms > NO_SPEECH_MS) return null
if (speechStart >= 0 && (count - lastSpeech) * 1000L / RATE > END_SILENCE_MS) break
}
} finally {
runCatching { recorder.stop() }
recorder.release()
}
if (speechStart < 0) return null
return samples.copyOfRange(speechStart, count)
}
// --- the model --------------------------------------------------------------------------
private fun ensureLoaded(context: Context): Boolean {
if (handle != 0L) return true
if (!libraryLoaded) {
runCatching { System.loadLibrary("gearslip_speech") }
.onFailure { GearslipLog.e("speech: native library missing", it); return false }
libraryLoaded = true
}
val started = SystemClock.uptimeMillis()
handle = nativeInitAsset(context.assets, MODEL)
if (handle == 0L) {
GearslipLog.w("speech: couldn't load the model")
return false
}
GearslipLog.i("speech: loaded the model in ${SystemClock.uptimeMillis() - started} ms")
return true
}
private fun transcribe(audio: FloatArray): String {
val bytes = nativeTranscribe(handle, audio, language(), threads())
return String(bytes, Charsets.UTF_8)
}
/** The phone's language, as Whisper names it. Older Java codes differ for a few. */
private fun language(): String = when (val lang = Locale.getDefault().language) {
"iw" -> "he"
"in" -> "id"
"ji" -> "yi"
"" -> "en"
else -> lang
}
/** The big cores only: more threads than that slow Whisper down on a phone. */
private fun threads() = Runtime.getRuntime().availableProcessors().coerceIn(1, 8).let { if (it >= 8) 4 else max(1, it / 2) }
/** Whisper marks silence and noise as "[BLANK_AUDIO]" or "(wind blowing)"; none of it is words. */
private fun clean(text: String): String =
text.replace(Regex("""\[[^\]]*\]|\([^)]*\)|\*[^*]*\*"""), " ").replace(Regex("\\s+"), " ").trim()
private external fun nativeInitAsset(assets: AssetManager, path: String): Long
private external fun nativeTranscribe(handle: Long, samples: FloatArray, language: String, threads: Int): ByteArray
private external fun nativeFree(handle: Long)
}
@@ -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()
@@ -1,20 +1,44 @@
package app.seb3thehacker.gearslip.ui package app.seb3thehacker.gearslip.ui
import android.Manifest
import android.app.Activity
import android.content.pm.ActivityInfo
import android.content.pm.PackageManager
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
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.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
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
private enum class Screen { HOME, LOGS, SETTINGS, CAR_PREVIEW, CERT_SETUP, SETUP_GUIDE, HELP } private enum class Screen { HOME, LOGS, SETTINGS, CAR_PREVIEW, CERT_SETUP, SETUP_GUIDE, HELP, WHATS_NEW }
/** 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
@@ -35,18 +59,52 @@ fun GearslipApp(onDisconnect: () -> Unit, onRequestCallScreening: () -> Unit) {
} }
BackHandler(enabled = screen != Screen.HOME) { screen = Screen.HOME } BackHandler(enabled = screen != Screen.HOME) { screen = Screen.HOME }
// Home stays upright; every other screen turns with the phone.
LaunchedEffect(screen) {
(context as? Activity)?.requestedOrientation = if (screen == Screen.HOME) {
ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
} else {
ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
}
}
// The guide asks for this now, but anyone who finished it before that never saw the page.
val notificationPermission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {}
LaunchedEffect(Unit) {
if (!needsSetupGuide &&
context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
) {
notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
Column(
Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.statusBars)
.consumeWindowInsets(WindowInsets.statusBars),
) {
if (BuildConfig.DEBUG && screen != Screen.CAR_PREVIEW) DevBuildBanner()
Column(Modifier.weight(1f)) {
when (screen) { when (screen) {
Screen.HOME -> HomeScreen( Screen.HOME -> HomeScreen(
onOpenLogs = { screen = Screen.LOGS }, onOpenLogs = { screen = Screen.LOGS },
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.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 })
Screen.CAR_PREVIEW -> CarPreviewScreen(onBack = { screen = Screen.HOME }) Screen.CAR_PREVIEW -> CarPreviewScreen(onBack = { screen = Screen.HOME })
Screen.HELP -> HelpScreen(onBack = { screen = Screen.HOME }, onRequestCallScreening = onRequestCallScreening) Screen.HELP -> HelpScreen(
onBack = { screen = Screen.HOME },
onRequestCallScreening = onRequestCallScreening,
onReplayTutorial = { screen = Screen.SETUP_GUIDE },
)
Screen.CERT_SETUP -> CertSetupScreen( Screen.CERT_SETUP -> CertSetupScreen(
onDone = { onDone = {
screen = if (AppSettings.hasSeenPermissionsSetup(context)) Screen.HOME else Screen.SETUP_GUIDE screen = if (AppSettings.hasSeenPermissionsSetup(context)) Screen.HOME else Screen.SETUP_GUIDE
@@ -57,10 +115,35 @@ fun GearslipApp(onDisconnect: () -> Unit, onRequestCallScreening: () -> Unit) {
onDone = { screen = Screen.HOME }, onDone = { screen = Screen.HOME },
) )
} }
}
}
CompatWarning() CompatWarning()
} }
/**
* Debug builds only ([BuildConfig.DEBUG]); a release build never composes this. Sits above every
* phone screen, below the status bar - not the car screen, which is a separate render target
* ([app.seb3thehacker.gearslip.car.CarUi] carries its own corner badge) - so a debug install is
* never mistaken for a release one.
*/
@Composable
private fun DevBuildBanner() {
Surface(
modifier = Modifier.fillMaxWidth().height(28.dp),
color = Color(0xFFF5C518),
contentColor = Color.Black,
) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(
"DEV BUILD",
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
)
}
}
}
/** Shown once, on first run: the certificate is known to fail on head units newer than ~2020. */ /** Shown once, on first run: the certificate is known to fail on head units newer than ~2020. */
@Composable @Composable
private fun CompatWarning() { private fun CompatWarning() {
@@ -1,7 +1,6 @@
package app.seb3thehacker.gearslip.ui package app.seb3thehacker.gearslip.ui
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
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
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@@ -12,15 +11,18 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.FilledTonalButton
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.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
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
@@ -51,7 +53,8 @@ private fun setupSteps(onRequestCallScreening: () -> Unit) = listOf(
Step( Step(
"Allow the microphone, so music can reach the car.", "Allow the microphone, so music can reach the car.",
"Android asks for the microphone before it will let any app pass music along. " + "Android asks for the microphone before it will let any app pass music along. " +
"Gearslip never listens to you: it copies what a music app is playing, and nothing else.", "Gearslip listens only when you tap Reply or press the voice button. The rest of the " +
"time it copies what a music app is playing, and nothing else.",
), ),
Step( Step(
"Optional: stop Android asking about audio every drive.", "Optional: stop Android asking about audio every drive.",
@@ -80,11 +83,24 @@ private fun setupSteps(onRequestCallScreening: () -> Unit) = listOf(
/** The full setup reference, behind the help button: what the one-time guide walks through, kept /** The full setup reference, behind the help button: what the one-time guide walks through, kept
* 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)
@Composable @Composable
fun HelpScreen(onBack: () -> Unit, onRequestCallScreening: () -> Unit) { fun HelpScreen(onBack: () -> Unit, onRequestCallScreening: () -> Unit, onReplayTutorial: () -> Unit) {
val steps = remember(onRequestCallScreening) { setupSteps(onRequestCallScreening) } val steps = remember(onRequestCallScreening) { setupSteps(onRequestCallScreening) }
Scaffold(containerColor = MaterialTheme.colorScheme.background) { padding -> Scaffold(
containerColor = MaterialTheme.colorScheme.background,
topBar = {
TopAppBar(
title = { Text("How to connect") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
)
},
) { padding ->
Column( Column(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
@@ -92,17 +108,11 @@ fun HelpScreen(onBack: () -> Unit, onRequestCallScreening: () -> Unit) {
.padding(horizontal = 24.dp) .padding(horizontal = 24.dp)
.verticalScroll(rememberScrollState()), .verticalScroll(rememberScrollState()),
) { ) {
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) { FilledTonalButton(onClick = onReplayTutorial, modifier = Modifier.fillMaxWidth().height(52.dp)) {
IconButton(onClick = onBack) { Text("‹", style = MaterialTheme.typography.headlineMedium) } Text("Replay the guided tutorial")
Text(
"How to connect",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onBackground,
)
} }
Spacer(Modifier.height(16.dp)) 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)) {
@@ -136,22 +146,3 @@ fun HelpScreen(onBack: () -> Unit, onRequestCallScreening: () -> Unit) {
} }
} }
} }
/** The small circular "?" button that opens [HelpScreen] from the top right of the home screen. */
@Composable
fun HelpButton(onClick: () -> Unit) {
Surface(
onClick = onClick,
shape = MaterialTheme.shapes.extraLarge,
color = MaterialTheme.colorScheme.surfaceContainerHigh,
) {
Box(Modifier.padding(10.dp), contentAlignment = Alignment.Center) {
Text(
"?",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
}
@@ -1,5 +1,6 @@
package app.seb3thehacker.gearslip.ui package app.seb3thehacker.gearslip.ui
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.Row import androidx.compose.foundation.layout.Row
@@ -12,28 +13,35 @@ import androidx.compose.foundation.layout.size
import android.content.Context import android.content.Context
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Info
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.PlayArrow
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.seb3thehacker.gearslip.AppSettings import app.seb3thehacker.gearslip.BuildConfig
import app.seb3thehacker.gearslip.SessionStatus import app.seb3thehacker.gearslip.SessionStatus
import app.seb3thehacker.gearslip.notify.TestMessage
@Composable @Composable
fun HomeScreen( fun HomeScreen(
@@ -41,11 +49,10 @@ 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()
val context = LocalContext.current
var debug by remember { mutableStateOf(AppSettings.debugMode(context)) }
Scaffold(containerColor = MaterialTheme.colorScheme.background) { padding -> Scaffold(containerColor = MaterialTheme.colorScheme.background) { padding ->
Column( Column(
@@ -60,80 +67,94 @@ fun HomeScreen(
.verticalScroll(rememberScrollState()), .verticalScroll(rememberScrollState()),
) { ) {
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(24.dp))
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { Row(
Column { Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text( Text(
"Gearslip", "Gearslip",
style = MaterialTheme.typography.displayLarge, style = MaterialTheme.typography.displayLarge,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onBackground, color = MaterialTheme.colorScheme.onBackground,
) )
IconButton(
onClick = onOpenSettings,
modifier = Modifier.size(56.dp),
) {
Icon(Icons.Filled.Settings, contentDescription = "Settings", modifier = Modifier.size(40.dp))
}
}
Text( Text(
"v${LocalContext.current.appVersionName()}", "v${LocalContext.current.appVersionName()}",
style = MaterialTheme.typography.labelLarge, style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
}
HelpButton(onClick = onOpenHelp)
}
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
StatusCard(status) StatusCard(status)
Spacer(Modifier.height(24.dp))
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(Modifier.weight(1f)) {
Text("Debug mode", style = MaterialTheme.typography.titleMedium)
Text(
if (debug) "Full protocol logging, live logs and car preview."
else "Quiet logging. Warnings and errors are still recorded.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(checked = debug, onCheckedChange = {
debug = it
AppSettings.setDebugMode(context, it)
})
}
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
} }
// Actions sit at the bottom, where a thumb reaches them. // Sits at the bottom, where a thumb reaches it.
Column( Column(Modifier.padding(vertical = 16.dp)) {
Modifier.padding(vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
val live = status.phase == SessionStatus.Phase.CONNECTING || val live = status.phase == SessionStatus.Phase.CONNECTING ||
status.phase == SessionStatus.Phase.PROJECTING status.phase == SessionStatus.Phase.PROJECTING
if (live) { if (live) {
Button(onClick = onDisconnect, modifier = Modifier.fillMaxWidth().height(56.dp)) { Button(onClick = onDisconnect, modifier = Modifier.fillMaxWidth().height(56.dp)) {
Text("Disconnect") Text("Disconnect")
} }
Spacer(Modifier.height(16.dp))
} }
if (!live && debug) {
FilledTonalButton( Surface(color = MaterialTheme.colorScheme.surfaceContainerHigh, shape = MaterialTheme.shapes.extraLarge) {
onClick = onOpenCarPreview, Column {
modifier = Modifier.fillMaxWidth().height(56.dp), if (!live) {
ActionRow(Icons.Filled.PlayArrow, "Car preview", onOpenCarPreview)
RowDivider()
}
ActionRow(Icons.Filled.List, "Live logs", onOpenLogs)
RowDivider()
ActionRow(Icons.Filled.Info, "Connection help", onOpenHelp)
RowDivider()
ActionRow(Icons.Filled.Star, "What's new", onOpenWhatsNew)
if (BuildConfig.DEBUG) {
val context = LocalContext.current
RowDivider()
ActionRow(Icons.Filled.Send, "Send a test message") { TestMessage.post(context) }
}
}
}
}
}
}
}
@Composable
private fun ActionRow(icon: ImageVector, label: String, onClick: () -> Unit) {
Row(
Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 20.dp, vertical = 18.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
Text("Car preview") Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
} Text(label, style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f))
} Icon(
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { Icons.AutoMirrored.Filled.KeyboardArrowRight,
if (debug) { contentDescription = null,
FilledTonalButton(onClick = onOpenLogs, modifier = Modifier.weight(1f).height(56.dp)) { tint = MaterialTheme.colorScheme.onSurfaceVariant,
Text("Live logs") )
}
}
OutlinedButton(onClick = onOpenSettings, modifier = Modifier.weight(1f).height(56.dp)) {
Text("Settings")
}
}
}
} }
} }
@Composable
private fun RowDivider() {
HorizontalDivider(
modifier = Modifier.padding(horizontal = 20.dp),
color = MaterialTheme.colorScheme.outlineVariant,
)
} }
@Composable @Composable
@@ -22,10 +22,12 @@ 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.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.Surface
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
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
@@ -37,12 +39,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.text.font.FontWeight
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.CertProvider import app.seb3thehacker.gearslip.CertProvider
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -73,6 +77,7 @@ fun SettingsScreen(onBack: () -> Unit) {
verticalArrangement = Arrangement.spacedBy(32.dp), verticalArrangement = Arrangement.spacedBy(32.dp),
) { ) {
Spacer(Modifier.height(0.dp)) Spacer(Modifier.height(0.dp))
GearslipEnabledSection()
CertificateSection() CertificateSection()
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
} }
@@ -89,6 +94,31 @@ private fun SectionTitle(text: String) {
) )
} }
/** Off, a stock Android Auto app or head unit answers the USB connection instead of Gearslip. */
@Composable
private fun GearslipEnabledSection() {
val context = LocalContext.current
var enabled by remember { mutableStateOf(AppSettings.gearslipEnabled(context)) }
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
SectionTitle("Gearslip")
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text("Enabled", style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
Switch(checked = enabled, onCheckedChange = {
enabled = it
AppSettings.setGearslipEnabled(context, it)
})
}
if (!enabled) {
Text(
"Off - stock Android Auto can take the USB connection instead.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@Composable @Composable
private fun CertificateSection() { private fun CertificateSection() {
val context = LocalContext.current val context = LocalContext.current
@@ -90,6 +90,17 @@ private fun hasNotificationAccess(context: Context): Boolean {
} }
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 ---------
add(
GuidePage(
title = "Drive safely",
explanation = "This guide sets up the screen you will see mounted in your car. " +
"Gearslip is unofficial software: it is not made or reviewed by any carmaker, " +
"and it can fail without warning. Never interact with it while driving - set it " +
"up, mount the phone, then keep your attention on the road.",
),
)
// --- 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))) { if (CarSettings.pipeAudio.value && !granted(context, arrayOf(Manifest.permission.RECORD_AUDIO))) {
add( add(
@@ -142,6 +153,17 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
) )
} }
if (!granted(context, arrayOf(Manifest.permission.POST_NOTIFICATIONS))) {
add(
GuidePage(
title = "Notifications",
explanation = "Gearslip shows a notification while it's connected to the car or " +
"sending it audio, with a button to stop. Without this permission, Android " +
"hides it.",
permissions = arrayOf(Manifest.permission.POST_NOTIFICATIONS),
),
)
}
if (!hasNotificationAccess(context)) { if (!hasNotificationAccess(context)) {
add( add(
GuidePage( GuidePage(
@@ -0,0 +1,82 @@
package app.seb3thehacker.gearslip.ui
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.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.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import app.seb3thehacker.gearslip.BuildConfig
import app.seb3thehacker.gearslip.Changelog
/** Every release's notes, newest first: the same list the car screen shows after an update. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun WhatsNewScreen(onBack: () -> Unit) {
Scaffold(
containerColor = MaterialTheme.colorScheme.background,
topBar = {
TopAppBar(
title = { Text("What's new") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
)
},
) { padding ->
Column(
Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 24.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(28.dp),
) {
Spacer(Modifier.height(0.dp))
Changelog.releases.forEach { release ->
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
if (release.versionCode == BuildConfig.VERSION_CODE) "${release.versionName} (this version)" else release.versionName,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
release.changes.forEach { change ->
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Box(
Modifier
.padding(top = 8.dp)
.size(6.dp)
.background(MaterialTheme.colorScheme.primary, CircleShape),
)
Text(change, style = MaterialTheme.typography.bodyLarge)
}
}
}
}
Spacer(Modifier.height(16.dp))
}
}
}
@@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M14,9c0,-2.04,1.24,-3.79,3,-4.57V4c0,-1.1,-0.9,-2,-2,-2H4C2.9,2,2.01,2.9,2.01,4L2,22l4,-4h9c1.1,0,2,-0.9,2,-2v-2.42C15.24,12.8,14,11.05,14,9ZM10,14H6v-2h4V14ZM13,11H6V9h7V11ZM13,8H6V6h7V8Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M20,6.18C19.69,6.07,19.35,6,19,6c-1.66,0,-3,1.34,-3,3c0,1.66,1.34,3,3,3s3,-1.34,3,-3V3h2V1h-4V6.18Z"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M12,14c1.66,0,2.99,-1.34,2.99,-3L15,5c0,-1.66,-1.34,-3,-3,-3S9,3.34,9,5v6C9,12.66,10.34,14,12,14ZM17.3,11c0,3,-2.54,5.1,-5.3,5.1S6.7,14,6.7,11H5c0,3.41,2.72,6.23,6,6.72V21h2v-3.28c3.28,-0.48,6,-3.3,6,-6.72H17.3Z"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M12,2L4.5,20.29L5.21,21L12,18L18.79,21L19.5,20.29L12,2Z"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#FF000000"
android:pathData="M80,880L80,800Q126,800 171,794Q216,788 259,772Q259,772 259,772Q259,772 259,772Q213,749 186.5,705.5Q160,662 160,611L160,520L320,520Q320,520 320,520Q320,520 320,520L320,400L455,400Q455,400 455,400Q455,400 455,400L324,138L396,102L527,364Q547,404 524,442Q501,480 456,480L400,480L400,520Q400,553 376.5,576.5Q353,600 320,600L240,600L240,611Q240,646 261.5,672.5Q283,699 316,708L328,711Q368,721 373,761Q378,801 342,821Q282,854 215.5,867Q149,880 80,880ZM652,766L595,710Q616,689 628,661.5Q640,634 640,602Q640,570 628,542.5Q616,515 595,494L652,437Q684,469 702,511.5Q720,554 720,602Q720,650 702,692Q684,734 652,766ZM765,880L708,823Q751,780 775.5,723.5Q800,667 800,602Q800,536 775.5,480Q751,424 708,381L765,324Q819,378 849.5,449Q880,520 880,602Q880,683 849.5,754.5Q819,826 765,880Z"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M3,9v6h4l5,5V4L7,9H3zM16.5,12c0,-1.77 -1.02,-3.29 -2.5,-4.03v8.05c1.48,-0.73 2.5,-2.25 2.5,-4.02zM14,3.23v2.06c2.89,0.86 5,3.54 5,6.71s-2.11,5.85 -5,6.71v2.06c4.01,-0.91 7,-4.49 7,-8.77s-2.99,-7.86 -7,-8.77z"/>
</vector>
+3
View File
@@ -17,6 +17,7 @@ okhttp = "5.4.0"
kotlinxSerializationJson = "1.11.0" kotlinxSerializationJson = "1.11.0"
ossLicenses = "17.5.1" ossLicenses = "17.5.1"
webkit = "1.16.0" webkit = "1.16.0"
androidxMedia = "1.6.0"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -40,6 +41,8 @@ androidx-car-app-testing = { group = "androidx.car.app", name = "app-testing", v
# Only for the ICarHardwareHost AIDL stubs, which the host half has to implement but which # Only for the ICarHardwareHost AIDL stubs, which the host half has to implement but which
# live in the projected artifact rather than the core one. # live in the projected artifact rather than the core one.
androidx-car-app-projected = { group = "androidx.car.app", name = "app-projected", version.ref = "carApp" } androidx-car-app-projected = { group = "androidx.car.app", name = "app-projected", version.ref = "carApp" }
# MediaControllerCompat: reads and sets shuffle and repeat, which the framework session API has no calls for.
androidx-media = { group = "androidx.media", name = "media", version.ref = "androidxMedia" }
androidx-activity-ktx = { group = "androidx.activity", name = "activity-ktx", version.ref = "activityCompose" } androidx-activity-ktx = { group = "androidx.activity", name = "activity-ktx", version.ref = "activityCompose" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" } androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" }
Binary file not shown.
Binary file not shown.